teqo-backup/AGENTS.md
Francisco Ignácio Fontoura Solla 77023db99d B34+: scale and DRY after municipality chip lists.
Memoize access reads per req, cache portfolio index, trim chip revalidates, and shrink RSC payload via catalog-backed labels.
2026-07-28 19:49:29 -03:00

1368 lines
164 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Payload CMS Development Rules
You are an expert Payload CMS developer. When working with Payload projects, follow these rules:
## Teqo Project Context (read this first)
Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public site + editorial CMS + an internal `/campanha` campaign-management area, all in this one Next.js + Payload app. Full architecture/decision doc lives outside this repo at `plano-arquitetura-campanha-2026.md` (Cowork workspace) — check it for the "why" behind decisions below.
**Locked decisions — do not relitigate without a new concrete reason:**
- Single Next.js app, three route groups: `(frontend)` public site, `(payload)` admin, `(campaign)` internal campaign tool. No separate Rust service or frontend app for `/campanha`; it is modeled with Payload collections. The campaign roles are `coordinator` (label "Coordenador Geral"), `advisor` ("Assessor"), `leader` ("Liderança"), and `candidate` ("Candidato", unrestricted visibility), all on the separate auth collection `campaignUser` (see "Campaign auth" below). An advisor sees and manages ONLY the municipalities they administer; a leader is locked down to the supporter contact tool.
- **2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit** (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: `municipality`. Leaderships are one record per person (`contact` UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with `declaredVotes` (staff-entered, leader-visible) vs `estimatedVotes` (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `remodelagem-pracas.md`). Details: "Campaign Municípios model" below.
- Hosting stays on Vercel for now. No self-host/Coolify migration in progress. **The functions run in `gru1` (São Paulo), pinned in `vercel.json` — do not drop it back to the `iad1` default.** The Neon database is in `sa-east-1`, which is the same AWS region as `gru1`: with the default `iad1` every Payload query crossed the equator twice (~120 ms per round trip, and a page issues a dozen), which is what made `/campanha` feel broken until 2026-07-26. Hobby allows a single region but lets you choose which one. Verify after any deploy that changes function config: `curl -sI https://pt.jorgesolla.com.br/campanha` must answer `x-vercel-id: gru1::gru1::…`, never `gru1::iad1::…`.
- Donations are NOT handled in this app — they run through QueroApoiar (`apoiar.me/jorgesolla`, TSE-homologated). The site only needs a "Doar" link/CTA out to that URL.
- **Embasamento de produto/domínio:** `docs/research/` guarda o compêndio de literatura (campanha de DF na Bahia, geografia do voto, cartografia eleitoral) e o relatório de discovery aprovado (2026-07-21) com o playbook dado→decisão. Para métricas, análises territoriais, mapa e priorização em `/campanha`, consulte-o antes de inventar — o kernel: a disputa de DF é conta de quociente fragmentada; leitura útil é relativa e local (captura, LQ, quantis, cobertura de meta por pledges), nunca % estadual absoluto.
**Real conventions already established in this codebase (follow these, not just the generic patterns below):**
- `Contact` is the normalized "person" record (name, email, phone, state/city via `src/lib/cities.ts`, CEP). Don't create a parallel "person" collection for new features (e.g. líderanças/apoiadores) — add a join collection that relates to `Contact`, the same way `Signature` and `Subscription` do.
- Every action that writes to more than one collection (see `src/app/(frontend)/actions/*.ts`) wraps the writes in a Payload transaction (`payload.db.beginTransaction/commitTransaction/rollbackTransaction`) and passes `req: { transactionID }` to every `payload.create`/`payload.update` call. Follow this pattern for any new multi-collection write.
- LGPD consent is tracked via a `Consent` collection (richText, versioned, optional stable `key`) referenced by relationship from `Signature`/`Subscription`/campaign `leadership`. New campaign opt-in flows resolve Consent by stable `key` (e.g. `lideranca-autopreenchimento`) and fail closed when missing — don't invent a parallel consent mechanism or hardcode document IDs for new work.
- Cache invalidation: collections/globals call `revalidateDocumentById` (`src/utilities/documents.ts`) / `revalidateGlobal` (`src/utilities/globals.ts`) in an `afterChange` hook. Add the same hook to any new collection/global that backs a public page. Collections whose listing is cached under a shared tag also call the listing helper (`revalidateCollectionListing`, e.g. `revalidatePostsListing()` → `revalidateTag('posts')`) — see "Posts & Tags" below.
- Admin UI is organized via `admin.group` (e.g. `'Abaixo-assinados'`, `'Contatos'`, `'Configurações'`, `'Paginas'`, `'Campanha'`, `'Dados Eleitorais'`) — group new collections consistently instead of leaving them ungrouped.
- i18n: Payload admin defaults to `pt` (not `pt-BR` — see `payload.config.ts`, `payload/i18n/pt`).
- Language & naming conventions: ALL code identifiers are in English — variable, function, parameter, type/interface names, local constants, and Next.js dynamic route segment folder names used as param keys (e.g. `[type]/[category]/[slug]`, not `[tipo]/[categoria]`). Portuguese is allowed ONLY in: user-visible string literals (JSX text, button/labels), image `alt` text, SEO/metadata values (title/description/keywords), Payload admin config text (`labels`/`singular`/`plural`/`admin.description`/field `label`), and intentional URL slug/enum VALUES kept in Portuguese for SEO (e.g. the `post.type` enum values `noticia|campanha|artigo|evento`, tag/category slugs like `saude`, `eleitoral`). Never translate those data/URL values or admin labels. Renaming a route segment folder changes only the param KEY, never the public URL (segment values come from the data).
**Database & local development (safety-critical — production is a live Neon Postgres with real citizens' PII):**
- Local dev and tests must NEVER touch production. Local dev runs against a local Docker Postgres (`pnpm db:start`); `pnpm dev` refuses a non-local `DATABASE_URL` (guard: `scripts/guard-dev-db.mjs`) unless `ALLOW_REMOTE_DB=true`. Tests load `.env.test` (`teqo_test`) and refuse any database whose name doesn't end in `_test` (`tests/helpers/assertTestDatabase.ts`). Never repoint `.env`/`.env.local`/`.env.test` `DATABASE_URL` at Neon. Full workflow lives in the `local-database` skill (`.cursor/skills/local-database`).
- Schema changes go through committed Payload migrations — `push: false` everywhere, never flip it on against a remote DB. Edit configs → `pnpm migrate:create <name>` → commit `src/migrations/*` (both `.ts` and `.json`, plus `index.ts`) → `pnpm migrate` locally. `pnpm build` runs `payload migrate`, so **every Vercel deploy applies pending migrations to prod**. Prod is baselined at migration `20260715_163458_initial` — never regenerate or replace the initial migration. Full workflow (incl. hand-written data/reconciliation migrations) lives in the `payload-migrations` skill (`.cursor/skills/payload-migrations`).
- `Consent.text` was reconciled from `varchar` to `jsonb` via `20260715_163500_consent_text_to_jsonb` (matching the `richText` field). Any future field-type change is a migration, not a manual DB edit.
- To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data).
- `pnpm db:doctor` diagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hosting `teqo` + `teqo_test`). `pnpm dev` runs the same preflight via `guard-dev-db` and refuses to start with a named remedy instead of a later ECONNREFUSED.
- To (re)populate news content, `pnpm db:seed:posts` fetches articles live from jorgesolla.com.br into the LOCAL db (same non-local `DATABASE_URL` guard as `pnpm dev`). It writes from a CLI process outside the deployed runtime, so after seeding — or after ANY direct-DB change — you must bust the production `posts` cache via `POST /api/revalidate`. See "Posts & Tags" below.
- To (re)populate the TSE 2022 election baseline, `pnpm db:seed:tse` downloads TSE open data and imports the Bahia scope into the LOCAL db (same guard). Since the 2026-07-23 hardening, elections-tab reads are cached under the `election-tse` tag — after re-seeding a database the deployed app reads, bust it via `POST /api/revalidate?tag=election-tse`. See "Election baseline data (TSE 2022)" below.
## 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`](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](docs/plans/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`](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`](docs/plans/exportar-csv-assinaturas.md).
**Recently resolved (2026-07-23):** **Engineering-standards hardening** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md); ledger: [`docs/TECH-DEBT.md`](docs/TECH-DEBT.md); safety net: [`docs/TESTING.md`](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`](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`](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`](docs/plans/pagina-territorios-identidade.md).
**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](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-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`](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`](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`](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`](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`](docs/plans/roles-admin-payload.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`](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`](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`](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`](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`](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-label`s, 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`](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`](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`](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`](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 `Polygon`s 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`](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`](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`](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`](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`](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`](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`](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`](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`](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`](docs/plans/cenarios-estimativa-votos.md). Desbloqueia **E8**.
## Posts & Tags (news / publications)
The public news/publications system is backed by two collections (both in the `Publicações` admin group) plus the helpers in `src/utilities/posts.ts`.
- **`post`** (`src/collections/Post.ts`) — fields: `title`, `slug` (unique, indexed, auto-slugified from `title` when left empty), `type` (select enum `noticia|campanha|artigo|evento`), `category` (required single relationship → `tag`), `tags` (`hasMany` relationship → `tag`), `subtitle`, `coverImage` (upload → `media`), `publishedDate`, `body` (richText). Has drafts/versions (`schedulePublish`, `maxPerDoc: 5`).
- **`tag`** (`src/collections/Tag.ts`) — fields: `name`, `slug` (unique, indexed, auto-slugified from `name`), and `hidden` (checkbox, admin label "Esconder", default `false`). Tags are the shared taxonomy for both `post.category` and `post.tags`.
**Tags are a taxonomy, not a person record.** The `Contact` convention above applies to _people_; `tag` is publication metadata (categories + a visibility control flag), so post/tag relations do not touch `Contact`. Don't fold taxonomy into `Contact` or vice-versa.
**Electoral visibility control (`hidden` + fail-closed `isPostVisible`).** Marking a tag `hidden` hides every post that references it — as `category` or in `tags` — with a single toggle. This exists so campaign / pre-candidacy content (tagged `eleitoral`) can be pulled from the public site during the electoral period. `isPostVisible(post)` (`src/utilities/posts.ts`) returns true only when the post is `published` AND none of its related tags (`category` + `tags`) is `hidden`. It **fails closed**: an unpopulated relation (a bare numeric id) is treated as hiding, so callers must fetch with `depth >= 1`. Every public read filters through `getVisiblePosts()`, which applies this predicate to the cached published list.
**URLs and Portuguese SEO values.** The canonical article path is `/[type]/[category]/[slug]` (e.g. `/noticia/saude/<slug>`), with `/[type]` and `/[type]/[category]` listing routes and the home "Últimas notícias" list. The `type` enum values (`noticia|campanha|artigo|evento`) and tag/category slugs (`saude`, `eleitoral`, …) are deliberately Portuguese for SEO and are **data, not identifiers** — never translate them (see the naming-conventions bullet above). The route folders are `[type]/[category]/[slug]` (English param keys); the article route redirects any stale/mismatched URL to the canonical path derived from the post's _current_ `type` + category slug. Article pages emit JSON-LD (`Article`) and Open Graph metadata.
**Caching (mixed ISR, `posts` tag).** Public reads go through the `unstable_cache` wrappers in `src/utilities/posts.ts` (`getCachedPublishedPosts`, `getCachedPostBySlug`), all tagged `posts`. The routes set `export const dynamicParams = true` and build `generateStaticParams` from `getVisiblePosts()`: known paths are statically generated, unknown ones render on demand, and everything stays cached until the `posts` tag is busted. `revalidatePostsListing()` (`src/utilities/documents.ts`) is `revalidateTag('posts')` (the listing tag is `` `${collection}s` `` → `posts`), so one call busts the home page, every listing route, and every article route at once.
**Self-revalidation on admin edits.** Both collections have an `afterChange` hook. `post` busts its own document tag + the `posts` listing on every publish/update (skipping only the initial draft that the admin create view generates during render). `tag` is broader: on change it re-reads every post referencing that tag (as `category` or in `tags`, passing `req` for transaction safety) and revalidates each one plus the listing — so flipping `hidden` is reflected on the site immediately.
**Seeding news content (`pnpm db:seed:posts`).** `scripts/seed-posts.mjs` (loaded via `scripts/seed-loader.mjs`) fetches ~39 articles live from jorgesolla.com.br (WordPress REST API, with an HTML-crawl fallback), imports them as `type: 'noticia'` published posts, converts the WP HTML body to Payload Lexical, and uploads cover images to Vercel Blob. The taxonomy (categories + the `eleitoral` control tag, and the per-slug classification) is hardcoded in the script; it reads/writes the `hidden` flag. It is **idempotent by slug** (existing posts are skipped; tags/media are reused by slug/filename) and reuses the same non-local `DATABASE_URL` guard as `pnpm dev` (override only with `ALLOW_REMOTE_DB=true`). The Vercel Blob store is shared across environments, so before uploading a cover it deletes any orphan blob left under the same deterministic `<slug>.<ext>` key, keeping the seed re-runnable.
### Revalidating after a manual/direct DB change
Admin edits made through the deployed app self-revalidate via the `afterChange` hooks above. But any write that does **not** go through the deployed Payload runtime — direct SQL, `pnpm db:seed:posts`, a DB restore / `db:pull` — will **not** bust production's frozen `posts` cache. (Even when such a write triggers the hook, its `revalidateTag('posts')` runs in that CLI/local process and cannot touch the deployed server's cache.) The pages keep serving stale content until the tag is busted.
Bust it with the secured endpoint (`src/app/(frontend)/api/revalidate/route.ts`):
```bash
# News (`posts` tag — default)
curl -X POST "https://<prod-domain>/api/revalidate" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
# Privacy policy global after Onda 0 migration/seed (`global_privacy-policy`)
curl -X POST "https://<prod-domain>/api/revalidate?tag=global_privacy-policy" \
-H "x-revalidate-secret: $REVALIDATE_SECRET"
```
- **Auth:** send the secret via `x-revalidate-secret` or `Authorization: Bearer <secret>`; it is compared to `REVALIDATE_SECRET` with a constant-time check.
- `REVALIDATE_SECRET` must be set in the Vercel **production** env (and in your local env if you want to test the endpoint). It is documented in `.env.example`.
- **Tag:** optional `?tag=` query or JSON body `{ "tag": "..." }` (query wins). Allowlist: `posts` (default), `global_privacy-policy`, `election-tse` (bust after re-running `pnpm db:seed:tse` so the cached elections-tab reads refresh), `municipality-catalog` (bust after a migration/seed that adds or renames a município so chip indexes and `unstable_cache` loaders refresh). Unknown tag → `400`.
- **Responses:** `200 { revalidated: true, tag: '...' }` on success; `401` if the secret is missing/wrong; `500` if `REVALIDATE_SECRET` is not configured on the server.
- This POST is the required last step of the post-seed / direct-DB-change runbook.
## Campaign auth (`/campanha`)
The internal `/campanha` area is gated by its own authentication, deliberately kept **isolated from the Payload admin (`/admin`)** so a campaign session and an admin session can coexist in the same browser.
- **`campaignUser` collection** (`src/collections/CampaignUser.ts`) — a separate Payload auth collection with `name`, optional contact `phone`, and required `role`: `coordinator` ("Coordenador Geral"), `advisor` ("Assessor"), `candidate` ("Candidato"), or `leader` ("Liderança" — default and least privilege). Staff may sign in by email; leadership accounts use the normalized 11-digit phone as `username`. It is not `users`: `admin.user` remains `users`, so campaign users cannot log into `/admin`. Its static `auth.tokenExpiration` is the 14-day ceiling because Payload's Local API does not accept a per-login TTL and `useSessions` (enabled) stores/prunes sessions using that configured expiry.
- **Isolated session cookie `campaign-token`.** The flow sets its own httpOnly cookie named `campaign-token` (constant `CAMPAIGN_TOKEN_COOKIE` in `src/utilities/campaignAuth.ts`), scoped to `path: '/campanha'`, `sameSite: 'lax'`, and `secure` only in production. It is deliberately NOT the default `payload-token` cookie — using a distinct name + path is what lets a campaign session and a Payload admin session live side by side without clobbering each other. Login defaults to **8 hours**; checking "Lembrar de mim neste dispositivo" opts into **14 days**. `setCampaignAuthCookie` owns the invariant JWT `exp` = cookie `maxAge`: it re-signs the Payload token to 8 hours with `jwtSign` or keeps the original 14-day token. Password-reset and invite login paths omit the opt-in and therefore stay at 8 hours.
- **Session verification — `getCampaignUser()`** (`src/utilities/campaignAuth.ts`). Reads the `campaign-token` cookie, calls `payload.auth({ headers })`, accepts only `user.collection === 'campaignUser'`, then reloads that document by ID. The fresh read makes role downgrades effective immediately instead of trusting a stale JWT role.
- **Login/logout server actions** (`src/app/(campaign)/campanha/actions/auth.ts`). `loginCampaign` validates input with `campaignLoginSchema` (`src/lib/schemas/campaign-login.ts`), calls `payload.login({ collection: 'campaignUser' })`, chooses the 8-hour / 14-day TTL, and — because the Local API returns a token WITHOUT setting a cookie — calls `setCampaignAuthCookie` before redirecting to `/campanha`. `logoutCampaign` authenticates the current token and delegates revocation of its `_sid` to Payload's public transactional `logoutOperation`, then clears the cookie (`maxAge: 0`) and redirects to `/campanha/login`; malformed tokens or database failures never prevent local cookie/PWA cleanup.
- **Route layout.** The route group's root layout `src/app/(campaign)/layout.tsx` renders the `<html>`/`<body>` with `data-theme="campaign"`. The public login lives at `src/app/(campaign)/campanha/login/` (`page.tsx` + client `LoginForm.tsx`). Everything gated sits in the `(app)` group, whose layout (`src/app/(campaign)/campanha/(app)/layout.tsx`) calls `getCampaignUser()` and `redirect('/campanha/login')` when there is no session — that layout is the barrier.
- **Biometric login (WebAuthn, B40 — 2026-07-28).** A second way into the same session, never a second session: the biometric path ends at the same `setCampaignAuthCookie` with the 14-day TTL (enrolling the device _is_ the trust opt-in), and the password path is untouched. Credentials live in `campaignWebAuthnCredential` (owner-only access in `src/utilities/access/webauthnCredentials.ts`; `update` is denied to users because the signature counter and `lastUsedAt` are written with `overrideAccess: true` inside the transaction). Four anonymous-or-authenticated JSON routes sit in `src/app/(campaign)/campanha/webauthn/` — deliberately **outside `(app)`**, since two of them run before there is a session and the cookie's `/campanha` path already covers them. **No `Consent` key, and this is not an art. 11 question:** the OS never releases the fingerprint, so what we store is a public key.
- **There is no password-less login in Payload 3.82.0.** `loginOperation` requires `password`. `issueCampaignWebAuthnSession` (`src/utilities/campaignWebAuthnSession.ts`) mints the session from public exports only: `payload.db.findOne` → `checkLoginPermission` → `addSessionToUser` (`payload/shared`) → `getFieldsToSign` + `jwtSign`. **Never load the user with `payload.findByID` here.** `addSessionToUser` read-modify-writes the _whole_ user document, so a doc that passed through `CampaignUser`'s `afterRead` `removePrivateAuthFields` would be written back without `sessions` or `email` — logging the user out of every other device — and with `avatar` expanded into an object. `user.updatedAt = null` is set for the same reason Payload's own login does it. The mint runs inside `withPayloadTransaction` under `campaign-webauthn-session:{userId}`, because rewriting the whole `sessions` array makes two concurrent logins lose one.
- **RP ID must match the host that served the page.** `getCampaignWebAuthnRelyingParty` derives `rpID`/`origin` from `getCampaignInviteBaseURL`, _and_ compares the request's host against it — returning `null` when they differ, which hides the button. Without that comparison a Vercel preview resolves the canonical origin successfully and the browser throws `SecurityError` mid-ceremony.
- **The challenge cookie must carry the library's own challenge.** It is an HMAC-signed httpOnly cookie (5 min, single-use, path `/campanha`, precedent `supporterImportToken.ts`) — but store the exact base64url string `generateRegistrationOptions`/`generateAuthenticationOptions` returned. Generating our own alongside theirs makes every `register` answer 400.
- **Deleting a `campaignUser` needs the cascade in a hook.** Payload emits `ON DELETE SET NULL` for the required `user` relationship, which cannot apply to a `NOT NULL` column; `CampaignUser` has a `beforeDelete` that removes the user's credentials in the same transaction. E2E uses a CDP virtual authenticator (`WebAuthn.addVirtualAuthenticator`, `transport: 'internal'`), which also makes `isUserVerifyingPlatformAuthenticatorAvailable()` answer `true`.
- **Migrations.** `20260716_010420_add_campaign_user` created the auth collection; `20260728_041958_add_campaign_webauthn_credentials` added the WebAuthn credential table; the unpublished 2026-07-17 campaign chain was consolidated into `20260718_010733_consolidate_campaign_schema`, which adds role/username auth, contact phone, and the remaining final campaign schema.
## Campaign Municípios model (2026-07-23 remodel)
`/campanha` is organized around `municipality` ("Município") — the campaign's PREDEFINED operational territory. Deployed to production on 2026-07-23 (destructive remodel migrations applied via the Vercel build). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `docs/plans/remodelagem-pracas.md`).
- **Município definition:** 435 seeded rows — one per Bahia municipality (416), except Salvador (19 zone municipalities, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated). Canonical catalog: `src/lib/municipalityCatalog.ts` (`tests/fixtures/municipality-catalog.snapshot.json`). Seeded by migration `20260723_200000_remodel_municipalities` (+ reconcile `20260723_202000_reconcile_municipality_remodel`). Geography is admin-only; staff edit strategy fields and the coordinator assigns `advisors`.
- **Collections (admin group `Campanha`):** `municipality`, `leadership`, `votePledge`, `organization`, `stateDeputy` (dobradinhas), `campaignDemand`, `allocationDecision`, `municipalityUpdate`, `campaignInvite`, `campaignWebAuthnCredential` (biometric login — see "Campaign auth"), `supporter`, `activity`. **Global** (same admin group): `campaignGoals` (E8 "conta da cadeira" — `stateGoal` sets the optimistic suggested-goal scenario, `margin` the pessimistic haircut; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; no `afterChange` hook — nothing caches it).
- **Engagement level (E14):** `municipality.engagementLevel` (`n0…n4`, staff-only, no default) is the campaign's declared investment in the município — N0 monitorar … N4 investimento pleno. Only `isCampaignUnrestricted` actors move it, every move needs a rationale plus the signals that would reverse it, and the move writes an `allocationDecision` (`outcome: 'movimento'`) in the same transaction under an advisory lock. Hysteresis rules are pure (`src/lib/engagementLevel.ts`) and shared by client and server; the override is recorded with the rules it contradicted. Null means "Sem nível" — the triage queue, not a missing value to backfill.
- **Roles:** `coordinator` ("Coordenador Geral") and `candidate` ("Candidato", unrestricted visibility via `isCampaignUnrestricted`) see/manage everything staff sees; `advisor` ("Assessor") is scoped to `municipality.advisors`; `leader` ("Liderança") is **lockdown** — home is the supporter contact tool only (`LeaderContactsPanel`, `source: lideranca`), reads only supporters they created (`createdBy`), no municipalities/leaderships/pledges/demands/plans/election data.
- **Vote pledge asymmetry:** staff declare `declaredVotes` and record `estimatedVotes` (pessimistic/central/optimistic); leaders never see estimates. Aggregates use `estimated[S] ?? declared`, default `central` (`src/utilities/votePledgeData.ts`).
- **Demandas:** staff-only create/read (`canCreateCampaignDemand`); optional `leadership` provenance selected by staff. Escalated decisions: coordinator **or** candidate.
- **Dobradinhas:** `stateDeputy` entity + `municipality.stateDeputies` / `leadership.stateDeputies` hasMany; UI at `/campanha/dobradinhas`.
- **Map:** staff dashboard (`/campanha`) loads `MunicipalityMapPanel`; `/campanha/municipios` is list/overview only. **Keyed by "map key", not `ibgeCode`** (B8 F2, 2026-07-26): `mapKeyForMunicipality` returns the `municipalitySlug` for `kind === 'zona'` and the `ibgeCode` otherwise, so each of Salvador's 19 ZE is painted, hovered and opened on its own from `bahia-municipality-zones.topo.json`. Every bundle field follows that key (`municipalitiesByMapKey`, `territorialClassByMapKey`, `projectedValidVotesByMapKey`, `valuesByYear`, …) and each key maps to exactly one catalog entry, so rollups assign instead of accumulating. Salvador's **municipal** polygon is still drawn, underneath, as a non-interactive base with no data (`baseKeys` in `BahiaMap`) — two independently simplified meshes do not close on the same coastline, and deleting it opened a gap in the state outline; being out of the key index, it cannot double-count or steal a hover. Feature→key resolution is `featureMapKey` (`municipalitySlug ?? codarea ?? code`) — the three are **mutually exclusive** on the committed meshes, so it is a lookup over distinct names and not a precedence rule; the zone mesh also carries the city's `ibgeCode`, which is deliberately **not** a key property because all 19 zones share it. Since B8+ that contract is exercised on **real features of all three meshes** in `tests/int/bahiaGeometries.int.spec.ts` — over the full 417 / 27 / 19, not on hand-written literals and not on one city feature repeated per zone — and a failure of the zone chunk **degrades instead of killing the map**: `loadLayerFeatures` catches it and paints the state without the ZE (Salvador falls back to its non-interactive base polygon, not to an aggregated one — the bundle is keyed by slug), while `status === 'error'` stays reserved for the municipal mesh. **`competitiveRank` is still per city:** the TSE artifact only carries `federalRankByIbgeCode`, so all 19 zones share the city's placement — which is what the legend already claims, and an int test pins it. Scales (B13): **quantis** (default), **padrão próprio (LQ)**, **posição no município**, **% dos válidos** (B11) and **Total (votos)**. The three relative ones are **discrete classes, never a continuous ramp** — the pure math lives in `src/lib/mapScaleClasses.ts` (quantiles degrade to 3 classes below 10 municípios and the legend says so; LQ is cut on the E10 anchors in `src/lib/territorialClassAnchors.ts`). Diverging red×blue stays exclusive to the compare mode, where it means "Solla × adversário" — do not reuse it for LQ. Quantiles are computed over the **user's access scope**, so an advisor sees his portfolio classed against itself. Rank is unavailable in 2026 (TSE data) and LQ in 2026 reuses the 2022 statewide standard, the same precedent `percentValid` set. An optional proportional-symbol layer sizes bubbles by projected valid votes (area ∝ value) and colours them by the E10 class. A class label never renders alone: the readout always spells out the reason.
- **URLs:** `/campanha/municipios[/slug]`, `/campanha/dobradinhas`, `/campanha/liderancas[/id]`, `/campanha/organizacoes[/slug]`, `/campanha/demandas[/slug]`, `/campanha/atividades[/slug]`, `/campanha/atividades/giros` (compositor de giro, staff), `/campanha/apoiadores`, `/campanha/assessores` (coordinator/candidate), `/campanha/conceitos` (staff glossary), `/campanha/perfil`.
## Campaign supporters (C2)
Nominal supporter registry backing the campaign's base of declared supporters (roadmap item C2; engineering ready and merged to `main` on 2026-07-18, production-blocked on the legal batch). One collection in admin group `Campanha`:
- **`supporter`** (`src/collections/Supporter.ts`) — join `Contact`↔campaign with **optional `municipality`**. Fields include `source` (`import_csv | manual | convite | evento | lideranca` for leader-created rows).
- **Consent by stable key.** `campaignConsent.ts` was generalized to `getConsentByKey` / `requireConsentByKey` (fail-closed). The two keys are `apoiador-cadastro` (declared support) and `apoiador-intencao-voto` (vote intention — highlighted, separate consent). The app fails closed if either key is missing — same pattern as `lideranca-autopreenchimento`.
- **Access.** `coordinator`/`candidate` see/manage all; `advisor` sees supporters of administered municipalities; `leader` may create supporters in linked municipalities and read only rows they created (`createdBy`); supporters without a municipality remain `coordinator`-only. Delete is admin-only.
- **Server actions** (`src/app/(campaign)/campanha/actions/supporter.ts`): `createSupporter`, `setSupporterVoteIntention`, `previewSupporterImport` / `confirmSupporterImport` (CSV, `coordinator`-only, transactional with `req: { transactionID }`, dedup by phone via `contactPhoneInvariant`), and `removeSupporterData` (deletes the `supporter`; anonymizes/clears `Contact` PII only if no other join — `leadership`/`signature`/`subscription`/other `supporter` — references it).
- **UI** `/campanha/apoiadores` — list with KPIs (total / "Certo + Tende" / "Indecisos"), filters (vote intention, territory), detail with LGPD consent block + vote-intention segmented control (disabled until consent) + minimal share kit (`wa.me` + copy text), and a 3-step CSV import wizard (Upload → Preview → Confirm). Phone is required in v1.
- **C6 scale layer (merged to `main` 2026-07-19).** CSV import up to 5000 rows now completes in one transaction: `confirmSupporterImportRecord` acquires all phone advisory locks, then `bulkInsertSupporterImport` (`src/utilities/supporterImportBulk.ts`) inserts contacts + supporters via `payload.db.drizzle` on the Payload transaction session (`getPostgresTransactionDatabase`) in 500-row chunks with `ON CONFLICT DO NOTHING` on `(contact_id, municipality_id)`. `Contact.enforceUniqueContactPhone` honors `context.skipContactPhoneInvariant` **fail-closed** (only inside an active `req.transactionID`), so the bulk path skips the per-row re-check while the single-create path is unchanged. Preview no longer round-trips the full `ok` set: `previewSupporterImportText` stages it in `supporterImportBatch` (`src/collections/SupporterImportBatch.ts`, admin-hidden, `coordinator`/admin only, migration `20260719_011015_add_supporter_import_batch`) and returns an HMAC-SHA256 single-use `importToken` (10-min TTL, actor-bound, `src/utilities/supporterImportToken.ts`); `confirmSupporterImportRecord` verifies the token and consumes the batch (delete after commit). The list KPI is a single `COUNT(*) FILTER` aggregate (`src/utilities/supporterListOverviewAggregate.ts`) mirroring `buildSupporterListWhere` + the access constraint, replacing 3× `payload.count`. Shared shells — `campaignListUrl.ts`, `CampaignListPagination`, `campaignFormFields.ts`, `mapCampaignFormActionError.ts` — replace the three per-list pagination components and the duplicated form-error ladders. Scale/DRY follow-ups from the `/simplify` pass are registered as **C8** (`docs/plans/escala-dry-pos-c6.md`).
- **Mandatory production blocker:** do not import real supporter data or capture vote intention until electoral counsel documents the LGPD art. 11 basis (vote intention is sensitive data), approves the versioned consent texts, and an admin creates `Consent.key = 'apoiador-cadastro'` and `Consent.key = 'apoiador-intencao-voto'`. The app intentionally fails closed when these keys are absent.
## Campaign activities (C3, renamed by C13)
Mobilization events / agenda backing the campaign's field operations (roadmap item C3; implemented and merged to `main` on 2026-07-18). **Renamed "Plano de Ação" → "Atividade" on 2026-07-25 (C13)**: the collection slug is `activity`, the route is `/campanha/atividades`, and the vocabulary guard in `tests/unit/codebaseConventions.unit.spec.ts` now fails the build on `actionPlan` / `plano de ação` / `/campanha/planos` anywhere in `src`, `tests` or `scripts` (migrations excluded — frozen history). One collection in admin group `Campanha`:
- **`activity`** (`src/collections/Activity.ts`) — a calendarizable action/event (caminhada, comício, etc.) anchored on ONE `municipality`, with task checklist, update feed and result record. Fields: `title`, canonical immutable `slug`, `kind` / `status` enums in pt-BR, `startAt` (required beyond `rascunho`), `municipality` (required) + free-text `locality`, `organizations` (hasMany — supporting orgs), `advisors` (staff responsible; ex-`coordinators`), `deputyPresent` (checkbox "Deputado presente"), `responsible` → `Contact`, `leadership` optional, `tasks`/`updates` arrays (derived fields server-side), and the action result: `resultSummary` + `resultMedia` (upload → `media`) + derived `resultRecordedBy/At` — queryable history for future activities in the same município. No `Consent` (internal staff data).
- **Access.** `coordinator` everything; `advisor` where listed in `advisors` OR the activity's `municipality` is administered (may create, auto-included); `leader` has no access (lockdown).
- **UI** `/campanha/atividades` — list with tabs Próximos (default) / Todos / Realizados / Rascunhos, filters (kind, municipality), cards with kind/status badge + date/time + município + "Resp:" + task progress + "Deputado presente"; detail with tabs Visão geral / Tarefas / Atualizações (`/campanha/atividades/[slug]`, create at `/campanha/atividades/nova`); result form appears for staff when the activity is `realizado`.
## Campaign map geometries (B2)
Static Bahia map foundation for future Leaflet surfaces (roadmap B2; UI is B3). No migration, collection, Consent, or server action — same static-data pattern as `bahiaTerritories` / `bahiaTseZones`.
- **Artifacts (committed):** `src/lib/geometries/bahia-municipalities.topo.json` (~132 KB; Topology object `municipalities`, 417 features, `properties: { codarea, name }`), `src/lib/geometries/bahia-identity-territories.topo.json` (~15 KB; object `territories`, 27 features, `properties: { code, name }`) and — since B8 F2 (2026-07-26) — `src/lib/geometries/bahia-municipality-zones.topo.json` (**~7.7 KB** since B8+ recalibrated it; object `municipalityZones`, **19** features, `properties: { municipalitySlug, name, ibgeCode }`), the Salvador ZE mesh.
- **Ground-scale policy (B8+, 2026-07-27):** how much detail a committed mesh keeps is declared **in ground units** in `scripts/lib/topology.mjs` (the first module in `scripts/lib/` — **B5 F2** lands there rather than opening a second one), and both build scripts consume it. Two knobs, both meaningless as raw numbers: `topojson-simplify` can pick its Visvalingam threshold as a **quantile of the mesh's own triangles**, and `quantize(topo, n)` lays an `n`-step grid across **the topology's own bbox** — so the `0.35` and `1e4` copied verbatim between the two scripts cut Salvador at ~0.6 m² and ~3.9 m while cutting Bahia at ~1.78 km² and ~103 m. The zone mesh shipped at **480 points/feature against the state mesh's 21.9** because of it. Now `SIMPLIFY_TOLERANCE_M2` states a tolerance per mesh in **square metres of ground** (with the density it buys measured in the comment) and quantization digits are derived per topology from `GROUND_GRID_METRES`. Two things not to re-derive: quantization alone barely helps (even on the state grid the zones stayed at 203 points/feature — the absolute weight is the lever), and **simplify before quantize**, because quantizing a dense mesh to a ~100 m grid collapses vertices into duplicates and a later `merge` produces broken rings (measured: Salvador dissolving to 24 km² instead of 307). Tolerances differ per mesh on purpose: the municipal tolerance reaches 17.9 points/feature on the zones but sheds 5% of Salvador's land area (306 → 291 km²), which is the quantity the int spec uses to prove no neighbourhood was dropped or double-counted. Guard: `MAX_POINTS_PER_FEATURE = 60` in `tests/int/bahiaGeometries.int.spec.ts`, next to the byte budget (soft, ≤ ~600 KB each) — bytes only catch a mesh nobody simplified at all.
- **Code table:** `src/lib/bahiaMunicipalityCodes.ts` — canonical municipality name → IBGE 7-digit `codarea`, plus `codeForMunicipality` / `municipalityForCode`. Fixture `tests/fixtures/bahia-municipality-codes.official.json` + int tests for bijection/coverage.
- **Runtime helpers:** `src/lib/bahiaGeometries.ts` — typed topologies, feature arrays and `getMunicipalityFeature` (the territory and zone accessors were deleted by B8+: only the int specs used them, and a `Map` built in one line is what a spec wants). TopoJSON→GeoJSON decode is LAZY (dynamic import via `loadMunicipalityGeometryModule` / `loadMunicipalityZoneGeometryModule`; B5 F1 done) — the default `/campanha` server graph never pays it. Shared feature types live in `bahiaGeometriesTypes.ts`: `BahiaFeature<P>` is the one spelling of `Feature<Polygon | MultiPolygon, P>`, `BahiaMeshFeature` is what the map layer receives from any of the three meshes, and geometry math (point-in-polygon, centroid, area, bounds — all built on the exported `polygonRingsOf`) takes **`PolygonalFeature`**, which declares the actual requirement instead of dragging `GeoJsonProperties` (i.e. `any`) through the signature.
- **Territory polygons:** dissolved from IBGE municipality meshes via `bahiaIdentityTerritoryRecords` (`topojson` `merge`), not the IDE Bahia shapefile (IDE remains provenance/validation reference only).
- **Rebuild (Salvador zones):** `pnpm build:municipality-zone-geometries` (`scripts/build-municipality-zone-geometries.mjs`; `--report` reconciles without writing) downloads IBGE `BA_bairros_CD2022.zip` from geoftp, parses the shapefile with **`shpjs`** (devDependency — that cut is SHP-only, SIRGAS 2000), keeps `CD_MUN === '2927408'` (170 polygons) and dissolves neighbourhood→zone against **TRE-BA RA 02/2017** (`municipalityZoneNeighborhoods.ts`). Reconciliation is **curated data in the script header**, not cleverer matching: an alias table, a table for TRE neighbourhoods with no polygon of their own (the build refuses a merge that would cross zones), and a table for the one neighbourhood the resolution splits mid-street (it goes whole to one ZE — the neighbourhood is the atom). The 5 polygons the resolution never names are assigned by **longest shared boundary**, a rule validated against the 4 it names under another label. The build fails on an orphan polygon or a TRE neighbourhood with no polygon, so 170/170 → 19/19 is an invariant, not a hope. Same cache dir and the same "**does not touch the database**" guarantee as the sibling below.
- **Rebuild:** `pnpm build:geometries` (`scripts/build-bahia-geometries.mjs`) downloads IBGE Malhas (qualidade intermediaria) + Localidades, reconciles names with `canonicalizeMunicipalityName`, simplifies/quantizes, emits the artifacts above. Cache under `data/geometries/` (gitignored; override with `GEOMETRIES_CACHE_DIR`). **Does not touch the database** — no `assertLocalDatabase` / no Neon risk. Not part of `pnpm build` or `pnpm dev`. Details: `docs/plans/mapa-bahia-geometrias.md`. Scale/DRY follow-ups from `/simplify`: `docs/plans/escala-dry-pos-b2.md` (B5).
## Election baseline data (TSE 2022)
Public TSE 2022 results back the electoral baseline (roadmap items A3 + A4). Three collections in admin group `Dados Eleitorais`: `electionTally` (per city×zone totals: eligible voters, turnout, valid/blank/null votes), `electionCandidateVote` (nominal votes per candidate×city×zone), and `electionCandidate` (candidate registry with a cross-year `identityKey` hash — no CPF or voter ID is ever persisted). Read access requires an authenticated `campaignUser` or `users` account (`canReadElectionData`); writes are Payload-admin-only, and the import runs via CLI with `overrideAccess: true`. Since the Município remodel, the consumers are `municipalityElectoralBaseline.ts` (per-municipality series/turnout), `municipalityCandidateComparison.ts` (multi-candidate table) and `municipalityMapData.ts` (map, incl. the diverging compare mode); ticket roles live in `BASELINE_TICKET_2022`. Seeds cover 2014/2018/2022 (E2 extended the original 2022-only A3 scope). Two caching layers sit on top (2026-07-23 hardening): the immutable Solla/valid-votes series per municipality ships as a committed artifact — `pnpm build:election-aggregates` → `src/lib/electionAggregates/bahia-federal-baseline.json`, loader `src/lib/bahiaElectionAggregates.ts`, snapshot-tested against `municipalityCatalog` — so the map/dashboard never query these collections for historical years; and the elections-tab loaders (`municipalityElectoralBaseline`, `municipalityCandidateComparison`, `electionCandidateOptions`) are wrapped in `unstable_cache` under the `election-tse` tag (bust via `POST /api/revalidate?tag=election-tse` after re-seeding).
The artifact is at **v3**: v2 added E8's `campoFederalVotesByYear` / `federalTallyByYear` / `majoritarian2022`, and B13 (2026-07-26) added `federalRankByIbgeCode` — the candidate's placement among **every** federal-deputy candidate with votes > 0 in that geography, per year, keyed by IBGE code (417 codes; Salvador's 19 zone slugs sum to the whole city, which is what the map draws) and read through `getFederalCompetitiveRank`. A geography where he had no votes gets no entry, never `rank: 0`. Its input is `loadFederalVotesByCityZoneAndCandidate` in `municipalityElectoralBaseline.ts` — the sibling of `loadCampoFederalVotesByCityZone`, same statewide scan, **CLI/build-time only**. The file is 623 KB against the 700 KB budget asserted in `tests/unit/bahiaElectionAggregates.unit.spec.ts`; anything new that would blow it needs a real measurement, not a bumped constant. Rank exists only by IBGE code — add a per-slug variant when the dossier or E11 actually needs one.
`pnpm db:seed:tse` (`scripts/seed-tse-results.mjs`) downloads the TSE open-data zips (provenance URLs + SHA-256 in the script header), parses the Bahia scope, and imports via `payload.db.drizzle` in a transaction — idempotent by replace-per-scope `(year, office, turn)`, only `voteType: 'nominal'` rows in v1. It refuses a non-local `DATABASE_URL` (same guard family as the other seeds; override with `ALLOW_REMOTE_DB=true`) and caches downloads under `data/tse/` (gitignored). No revalidate step is needed: campaign pages are dynamic with auth, not ISR-cached.
### Local verification and deploy checklist
1. Confirm `DATABASE_URL` is `postgresql://teqo:teqo@localhost:5432/teqo` and `.env.test` points to `teqo_test`; never use Neon for development, tests, E2E, or local builds.
2. Run `pnpm db:start`, `pnpm migrate:status`, and `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate:status`; every migration must show `Ran: Yes`.
3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed, `pnpm exec tsc --noEmit`, `pnpm lint` (zero warnings — the script enforces `--max-warnings=0`), `pnpm format:check` (Prettier is the formatting authority; `pnpm format` fixes), `pnpm exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int), `pnpm test:e2e` (or `pnpm test:all`), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js.
- Never run `knip --fix` blind: when knip's module graph is incomplete (it still cannot load `payload.config.ts` — ledgered as P3), `--fix` deletes exports that migrations/tests still reference. Verify each removal (e.g. `git grep -w <symbol>`) before deleting.
- Run gate commands bare — never piped (`pnpm test | tail` swallows the exit code, and a real failure was masked exactly this way during Pass 2). Large outputs are captured by the terminal; read them from there.
4. Scan every edited runtime source file with Aikido and resolve new findings before handoff. Do not expand hardening work into unrelated pre-existing findings without an explicit scope change.
5. Review Vercel environment variables and migration SQL before deploy; `pnpm build` applies pending migrations to production automatically.
6. **Mandatory production blocker:** do not insert real leadership/support data or enable invites until electoral counsel documents the LGPD art. 11 basis, approves the specific versioned consent text, and an admin creates it with exactly `Consent.key = 'lideranca-autopreenchimento'`. The app intentionally fails closed when this key is absent.
7. **Remodel deploy (historical — applied to production 2026-07-23):** the destructive remodel chain (`20260721_020109_remodel_municipalities` → `20260723_200000_remodel_municipalities` + `20260723_202000_reconcile_municipality_remodel`) already ran in production via the Vercel build: campaign vertical reset, 435 municípios seeded, public-site data untouched. Treat any future destructive migration the same way — review its SQL before the first production build that includes it.
## Core Principles
1. **TypeScript-First**: Always use TypeScript with proper types from Payload
2. **Security-Critical**: Follow all security patterns, especially access control
3. **Type Generation**: Run `generate:types` script after schema changes
4. **Transaction Safety**: Always pass `req` to nested operations in hooks
5. **Access Control**: Understand Local API bypasses access control by default
6. **Access Control**: Ensure roles exist when modifiyng collection or globals with access controls
### Code Validation
- To validate typescript correctness after modifying code run `tsc --noEmit`
- Generate import maps after creating or modifying components.
- Any change to a collection/global/field schema requires a migration: `pnpm migrate:create <name>`, then `pnpm migrate` locally. Never rely on `push` (it is `false`). See the `payload-migrations` skill.
## Project Structure
Actual layout (campaign access control lives in `src/utilities/access/*` domain modules with `src/utilities/campaignAccess.ts` as the re-export surface; hooks are still written inline inside each collection/global file):
```
src/
├── app/
│ ├── (frontend)/ # Frontend routes + server actions (src/app/(frontend)/actions/*.ts)
│ ├── (payload)/ # Payload admin routes
│ └── (campaign)/ # Internal campaign-management area (auth barrier, see "Campaign auth")
├── collections/ # Users, CampaignUser, CampaignWebAuthnCredential, CampaignInvite, Municipality, Leadership, VotePledge, MunicipalityUpdate, Organization, StateDeputy, CampaignDemand, AllocationDecision, Supporter, SupporterImportBatch, Activity, ElectionTally, ElectionCandidateVote, ElectionCandidate, Media, Petition, Signature, Consent, Contact, Subscription, Post, Tag
├── globals/ # Global configs (SiteSettings, HomePage, Metadata, PrivacyPolicy, CampaignGoals)
├── components/ # campaign/<domínio>/ (municipality, activity, supporter, …, shared, shell — Pass 2 W2), ui from shadcn
├── lib/ # cities.ts, bahiaTerritories.ts, bahiaTseZones.ts, bahiaMunicipalityCodes.ts, bahiaGeometries.ts, geometries/*.topo.json, municipalityCatalog.ts, electionAggregates/ (committed TSE artifact) + bahiaElectionAggregates.ts, formData.ts, zod schemas (lib/schemas/*)
├── utilities/ # campaign*, access/* (per-domain RBAC; campaignAccess.ts re-exports), municipality*, posts, documents, globals, locks, etc.
└── payload.config.ts # Main config
```
## Configuration
### Minimal Config Pattern
```typescript
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { fileURLToPath } from 'url'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
user: 'users',
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [Users, Media],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET,
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: mongooseAdapter({
url: process.env.DATABASE_URL,
}),
})
```
## Collections
### Basic Collection
```typescript
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'author', 'status', 'createdAt'],
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', unique: true, index: true },
{ name: 'content', type: 'richText' },
{ name: 'author', type: 'relationship', relationTo: 'users' },
],
timestamps: true,
}
```
### Auth Collection with RBAC
```typescript
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
fields: [
{
name: 'roles',
type: 'select',
hasMany: true,
options: ['admin', 'editor', 'user'],
defaultValue: ['user'],
required: true,
saveToJWT: true, // Include in JWT for fast access checks
access: {
update: ({ req: { user } }) => user?.roles?.includes('admin'),
},
},
],
}
```
## Fields
### Common Patterns
```typescript
// Auto-generate slugs
import { slugField } from 'payload'
slugField({ fieldToUse: 'title' })
// Relationship with filtering
{
name: 'category',
type: 'relationship',
relationTo: 'categories',
filterOptions: { active: { equals: true } },
}
// Conditional field
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
admin: {
condition: (data) => data.featured === true,
},
}
// Virtual field
{
name: 'fullName',
type: 'text',
virtual: true,
hooks: {
afterRead: [({ siblingData }) => `${siblingData.firstName} ${siblingData.lastName}`],
},
}
```
## CRITICAL SECURITY PATTERNS
### 1. Local API Access Control (MOST IMPORTANT)
```typescript
// ❌ SECURITY BUG: Access control bypassed
await payload.find({
collection: 'posts',
user: someUser, // Ignored! Operation runs with ADMIN privileges
})
// ✅ SECURE: Enforces user permissions
await payload.find({
collection: 'posts',
user: someUser,
overrideAccess: false, // REQUIRED
})
// ✅ Administrative operation (intentional bypass)
await payload.find({
collection: 'posts',
// No user, overrideAccess defaults to true
})
```
**Rule**: When passing `user` to Local API, ALWAYS set `overrideAccess: false`
### 2. Transaction Safety in Hooks
```typescript
// ❌ DATA CORRUPTION RISK: Separate transaction
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { docId: doc.id },
// Missing req - runs in separate transaction!
})
},
],
}
// ✅ ATOMIC: Same transaction
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-log',
data: { docId: doc.id },
req, // Maintains atomicity
})
},
],
}
```
**Rule**: ALWAYS pass `req` to nested operations in hooks
### 3. Prevent Infinite Hook Loops
```typescript
// ❌ INFINITE LOOP
hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.update({
collection: 'posts',
id: doc.id,
data: { views: doc.views + 1 },
req,
}) // Triggers afterChange again!
},
],
}
// ✅ SAFE: Use context flag
hooks: {
afterChange: [
async ({ doc, req, context }) => {
if (context.skipHooks) return
await req.payload.update({
collection: 'posts',
id: doc.id,
data: { views: doc.views + 1 },
context: { skipHooks: true },
req,
})
},
],
}
```
## Access Control
### Collection-Level Access
```typescript
import type { Access } from 'payload'
// Boolean return
const authenticated: Access = ({ req: { user } }) => Boolean(user)
// Query constraint (row-level security)
const ownPostsOnly: Access = ({ req: { user } }) => {
if (!user) return false
if (user?.roles?.includes('admin')) return true
return {
author: { equals: user.id },
}
}
// Async access check
const projectMemberAccess: Access = async ({ req, id }) => {
const { user, payload } = req
if (!user) return false
if (user.roles?.includes('admin')) return true
const project = await payload.findByID({
collection: 'projects',
id: id as string,
depth: 0,
})
return project.members?.includes(user.id)
}
```
### Field-Level Access
```typescript
// Field access ONLY returns boolean (no query constraints)
{
name: 'salary',
type: 'number',
access: {
read: ({ req: { user }, doc }) => {
// Self can read own salary
if (user?.id === doc?.id) return true
// Admin can read all
return user?.roles?.includes('admin')
},
update: ({ req: { user } }) => {
// Only admins can update
return user?.roles?.includes('admin')
},
},
}
```
### Common Access Patterns
```typescript
// Anyone
export const anyone: Access = () => true
// Authenticated only
export const authenticated: Access = ({ req: { user } }) => Boolean(user)
// Admin only
export const adminOnly: Access = ({ req: { user } }) => {
return user?.roles?.includes('admin')
}
// Admin or self
export const adminOrSelf: Access = ({ req: { user } }) => {
if (user?.roles?.includes('admin')) return true
return { id: { equals: user?.id } }
}
// Published or authenticated
export const authenticatedOrPublished: Access = ({ req: { user } }) => {
if (user) return true
return { _status: { equals: 'published' } }
}
```
## Hooks
### Common Hook Patterns
```typescript
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
hooks: {
// Before validation - format data
beforeValidate: [
async ({ data, operation }) => {
if (operation === 'create') {
data.slug = slugify(data.title)
}
return data
},
],
// Before save - business logic
beforeChange: [
async ({ data, req, operation, originalDoc }) => {
if (operation === 'update' && data.status === 'published') {
data.publishedAt = new Date()
}
return data
},
],
// After save - side effects
afterChange: [
async ({ doc, req, operation, previousDoc, context }) => {
// Check context to prevent loops
if (context.skipNotification) return
if (operation === 'create') {
await sendNotification(doc)
}
return doc
},
],
// After read - computed fields
afterRead: [
async ({ doc, req }) => {
doc.viewCount = await getViewCount(doc.id)
return doc
},
],
// Before delete - cascading deletes
beforeDelete: [
async ({ req, id }) => {
await req.payload.delete({
collection: 'comments',
where: { post: { equals: id } },
req, // Important for transaction
})
},
],
},
}
```
## Queries
### Local API
```typescript
// Find with complex query
const posts = await payload.find({
collection: 'posts',
where: {
and: [{ status: { equals: 'published' } }, { 'author.name': { contains: 'john' } }],
},
depth: 2, // Populate relationships
limit: 10,
sort: '-createdAt',
select: {
title: true,
author: true,
},
})
// Find by ID
const post = await payload.findByID({
collection: 'posts',
id: '123',
depth: 2,
})
// Create
const newPost = await payload.create({
collection: 'posts',
data: {
title: 'New Post',
status: 'draft',
},
})
// Update
await payload.update({
collection: 'posts',
id: '123',
data: { status: 'published' },
})
// Delete
await payload.delete({
collection: 'posts',
id: '123',
})
```
### Query Operators
```typescript
// Equals
{ status: { equals: 'published' } }
// Not equals
{ status: { not_equals: 'draft' } }
// Greater than / less than
{ price: { greater_than: 100 } }
{ age: { less_than_equal: 65 } }
// Contains (case-insensitive)
{ title: { contains: 'payload' } }
// Like (all words present)
{ description: { like: 'cms headless' } }
// In array
{ category: { in: ['tech', 'news'] } }
// Exists
{ image: { exists: true } }
// Near (geospatial)
{ location: { near: [-122.4194, 37.7749, 10000] } }
```
### AND/OR Logic
```typescript
{
or: [
{ status: { equals: 'published' } },
{ author: { equals: user.id } },
],
}
{
and: [
{ status: { equals: 'published' } },
{ featured: { equals: true } },
],
}
```
## Getting Payload Instance
```typescript
// In API routes (Next.js)
import { getPayload } from 'payload'
import config from '@payload-config'
export async function GET() {
const payload = await getPayload({ config })
const posts = await payload.find({
collection: 'posts',
})
return Response.json(posts)
}
// In Server Components
import { getPayload } from 'payload'
import config from '@payload-config'
export default async function Page() {
const payload = await getPayload({ config })
const { docs } = await payload.find({ collection: 'posts' })
return <div>{docs.map(post => <h1 key={post.id}>{post.title}</h1>)}</div>
}
```
## Components
The Admin Panel can be extensively customized using React Components. Custom Components can be Server Components (default) or Client Components.
### Defining Components
Components are defined using **file paths** (not direct imports) in your config:
**Component Path Rules:**
- Paths are relative to project root or `config.admin.importMap.baseDir`
- Named exports: use `#ExportName` suffix or `exportName` property
- Default exports: no suffix needed
- File extensions can be omitted
```typescript
import { buildConfig } from 'payload'
export default buildConfig({
admin: {
components: {
// Logo and branding
graphics: {
Logo: '/components/Logo',
Icon: '/components/Icon',
},
// Navigation
Nav: '/components/CustomNav',
beforeNavLinks: ['/components/CustomNavItem'],
afterNavLinks: ['/components/NavFooter'],
// Header
header: ['/components/AnnouncementBanner'],
actions: ['/components/ClearCache', '/components/Preview'],
// Dashboard
beforeDashboard: ['/components/WelcomeMessage'],
afterDashboard: ['/components/Analytics'],
// Auth
beforeLogin: ['/components/SSOButtons'],
logout: { Button: '/components/LogoutButton' },
// Settings
settingsMenu: ['/components/SettingsMenu'],
// Views
views: {
dashboard: { Component: '/components/CustomDashboard' },
},
},
},
})
```
**Component Path Rules:**
- Paths are relative to project root or `config.admin.importMap.baseDir`
- Named exports: use `#ExportName` suffix or `exportName` property
- Default exports: no suffix needed
- File extensions can be omitted
### Component Types
1. **Root Components** - Global Admin Panel (logo, nav, header)
2. **Collection Components** - Collection-specific (edit view, list view)
3. **Global Components** - Global document views
4. **Field Components** - Custom field UI and cells
### Component Types
1. **Root Components** - Global Admin Panel (logo, nav, header)
2. **Collection Components** - Collection-specific (edit view, list view)
3. **Global Components** - Global document views
4. **Field Components** - Custom field UI and cells
### Server vs Client Components
**All components are Server Components by default** (can use Local API directly):
```tsx
// Server Component (default)
import type { Payload } from 'payload'
async function MyServerComponent({ payload }: { payload: Payload }) {
const posts = await payload.find({ collection: 'posts' })
return <div>{posts.totalDocs} posts</div>
}
export default MyServerComponent
```
**Client Components** need the `'use client'` directive:
```tsx
'use client'
import { useState } from 'react'
import { useAuth } from '@payloadcms/ui'
export function MyClientComponent() {
const [count, setCount] = useState(0)
const { user } = useAuth()
return (
<button onClick={() => setCount(count + 1)}>
{user?.email}: Clicked {count} times
</button>
)
}
```
### Using Hooks (Client Components Only)
```tsx
'use client'
import {
useAuth, // Current user
useConfig, // Payload config (client-safe)
useDocumentInfo, // Document info (id, collection, etc.)
useField, // Field value and setter
useForm, // Form state
useFormFields, // Multiple field values (optimized)
useLocale, // Current locale
useTranslation, // i18n translations
usePayload, // Local API methods
} from '@payloadcms/ui'
export function MyComponent() {
const { user } = useAuth()
const { config } = useConfig()
const { id, collection } = useDocumentInfo()
const locale = useLocale()
const { t } = useTranslation()
return <div>Hello {user?.email}</div>
}
```
### Collection/Global Components
```typescript
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
components: {
// Edit view
edit: {
PreviewButton: '/components/PostPreview',
SaveButton: '/components/CustomSave',
SaveDraftButton: '/components/SaveDraft',
PublishButton: '/components/Publish',
},
// List view
list: {
Header: '/components/ListHeader',
beforeList: ['/components/BulkActions'],
afterList: ['/components/ListFooter'],
},
},
},
}
```
### Field Components
```typescript
{
name: 'status',
type: 'select',
options: ['draft', 'published'],
admin: {
components: {
// Edit view field
Field: '/components/StatusField',
// List view cell
Cell: '/components/StatusCell',
// Field label
Label: '/components/StatusLabel',
// Field description
Description: '/components/StatusDescription',
// Error message
Error: '/components/StatusError',
},
},
}
```
**UI Field** (presentational only, no data):
```typescript
{
name: 'refundButton',
type: 'ui',
admin: {
components: {
Field: '/components/RefundButton',
},
},
}
```
### Performance Best Practices
1. **Import correctly:**
- Admin Panel: `import { Button } from '@payloadcms/ui'`
- Frontend: `import { Button } from '@payloadcms/ui/elements/Button'`
2. **Optimize re-renders:**
```tsx
// ❌ BAD: Re-renders on every form change
const { fields } = useForm()
// ✅ GOOD: Only re-renders when specific field changes
const value = useFormFields(([fields]) => fields[path])
```
3. **Prefer Server Components** - Only use Client Components when you need:
- State (useState, useReducer)
- Effects (useEffect)
- Event handlers (onClick, onChange)
- Browser APIs (localStorage, window)
4. **Minimize serialized props** - Server Components serialize props sent to client
### Styling Components
```tsx
import './styles.scss'
export function MyComponent() {
return <div className="my-component">Content</div>
}
```
```scss
// Use Payload's CSS variables
.my-component {
background-color: var(--theme-elevation-500);
color: var(--theme-text);
padding: var(--base);
border-radius: var(--border-radius-m);
}
// Import Payload's SCSS library
@import '~@payloadcms/ui/scss';
.my-component {
@include mid-break {
background-color: var(--theme-elevation-900);
}
}
```
### Type Safety
```tsx
import type {
TextFieldServerComponent,
TextFieldClientComponent,
TextFieldCellComponent,
SelectFieldServerComponent,
// ... etc
} from 'payload'
export const MyField: TextFieldClientComponent = (props) => {
// Fully typed props
}
```
### Import Map
Payload auto-generates `app/(payload)/admin/importMap.js` to resolve component paths.
**Regenerate manually:**
```bash
payload generate:importmap
```
**Set custom location:**
```typescript
export default buildConfig({
admin: {
importMap: {
baseDir: path.resolve(dirname, 'src'),
importMapFile: path.resolve(dirname, 'app', 'custom-import-map.js'),
},
},
})
```
## Custom Endpoints
```typescript
import type { Endpoint } from 'payload'
import { APIError } from 'payload'
// Always check authentication
export const protectedEndpoint: Endpoint = {
path: '/protected',
method: 'get',
handler: async (req) => {
if (!req.user) {
throw new APIError('Unauthorized', 401)
}
// Use req.payload for database operations
const data = await req.payload.find({
collection: 'posts',
where: { author: { equals: req.user.id } },
})
return Response.json(data)
},
}
// Route parameters
export const trackingEndpoint: Endpoint = {
path: '/:id/tracking',
method: 'get',
handler: async (req) => {
const { id } = req.routeParams
const tracking = await getTrackingInfo(id)
if (!tracking) {
return Response.json({ error: 'not found' }, { status: 404 })
}
return Response.json(tracking)
},
}
```
## Drafts & Versions
```typescript
export const Pages: CollectionConfig = {
slug: 'pages',
versions: {
drafts: {
autosave: true,
schedulePublish: true,
validate: false, // Don't validate drafts
},
maxPerDoc: 100,
},
access: {
read: ({ req: { user } }) => {
// Public sees only published
if (!user) return { _status: { equals: 'published' } }
// Authenticated sees all
return true
},
},
}
// Create draft
await payload.create({
collection: 'pages',
data: { title: 'Draft Page' },
draft: true, // Skips required field validation
})
// Read with drafts
const page = await payload.findByID({
collection: 'pages',
id: '123',
draft: true, // Returns draft if available
})
```
## Field Type Guards
```typescript
import {
fieldAffectsData,
fieldHasSubFields,
fieldIsArrayType,
fieldIsBlockType,
fieldSupportsMany,
fieldHasMaxDepth,
} from 'payload'
function processField(field: Field) {
// Check if field stores data
if (fieldAffectsData(field)) {
console.log(field.name) // Safe to access
}
// Check if field has nested fields
if (fieldHasSubFields(field)) {
field.fields.forEach(processField) // Safe to access
}
// Check field type
if (fieldIsArrayType(field)) {
console.log(field.minRows, field.maxRows)
}
// Check capabilities
if (fieldSupportsMany(field) && field.hasMany) {
console.log('Multiple values supported')
}
}
```
## Plugins
### Using Plugins
```typescript
import { seoPlugin } from '@payloadcms/plugin-seo'
import { redirectsPlugin } from '@payloadcms/plugin-redirects'
export default buildConfig({
plugins: [
seoPlugin({
collections: ['posts', 'pages'],
}),
redirectsPlugin({
collections: ['pages'],
}),
],
})
```
### Creating Plugins
```typescript
import type { Config, Plugin } from 'payload'
interface MyPluginConfig {
collections?: string[]
enabled?: boolean
}
export const myPlugin =
(options: MyPluginConfig): Plugin =>
(config: Config): Config => ({
...config,
collections: config.collections?.map((collection) => {
if (options.collections?.includes(collection.slug)) {
return {
...collection,
fields: [...collection.fields, { name: 'pluginField', type: 'text' }],
}
}
return collection
}),
})
```
## Best Practices
### Security
1. Always set `overrideAccess: false` when passing `user` to Local API
2. Field-level access only returns boolean (no query constraints)
3. Default to restrictive access, gradually add permissions
4. Never trust client-provided data
5. Use `saveToJWT: true` for roles to avoid database lookups
### Performance
1. Index frequently queried fields
2. Use `select` to limit returned fields
3. Set `maxDepth` on relationships to prevent over-fetching
4. Use query constraints over async operations in access control
5. Cache expensive operations in `req.context`
### Data Integrity
1. Always pass `req` to nested operations in hooks
2. Use context flags to prevent infinite hook loops
3. Enable transactions for MongoDB (requires replica set) and Postgres
4. Use `beforeValidate` for data formatting
5. Use `beforeChange` for business logic
### Type Safety
1. Run `generate:types` after schema changes
2. Import types from generated `payload-types.ts`
3. Type your user object: `import type { User } from '@/payload-types'`
4. Use `as const` for field options
5. Use field type guards for runtime type checking
### Organization
1. Keep collections in separate files
2. Extract access control to `access/` directory
3. Extract hooks to `hooks/` directory
4. Use reusable field factories for common patterns
5. Document complex access control with comments
## Common Gotchas
1. **Local API Default**: Access control bypassed unless `overrideAccess: false`
2. **Transaction Safety**: Missing `req` in nested operations breaks atomicity
3. **Hook Loops**: Operations in hooks can trigger the same hooks
4. **Field Access**: Cannot use query constraints, only boolean
5. **Relationship Depth**: Default depth is 2, set to 0 for IDs only
6. **Draft Status**: `_status` field auto-injected when drafts enabled
7. **Type Generation**: Types not updated until `generate:types` runs
8. **MongoDB Transactions**: Require replica set configuration
9. **SQLite Transactions**: Disabled by default, enable with `transactionOptions: {}`
10. **Point Fields**: Not supported in SQLite
## Additional Context Files
For deeper exploration of specific topics, refer to the context files located in `.cursor/rules/`:
### Available Context Files
1. **`payload-overview.md`** - High-level architecture and core concepts
- Payload structure and initialization
- Configuration fundamentals
- Database adapters overview
2. **`security-critical.md`** - Critical security patterns (⚠️ IMPORTANT)
- Local API access control
- Transaction safety in hooks
- Preventing infinite hook loops
3. **`collections.md`** - Collection configurations
- Basic collection patterns
- Auth collections with RBAC
- Upload collections
- Drafts and versioning
- Globals
4. **`fields.md`** - Field types and patterns
- All field types with examples
- Conditional fields
- Virtual fields
- Field validation
- Common field patterns
5. **`field-type-guards.md`** - TypeScript field type utilities
- Field type checking utilities
- Safe type narrowing
- Runtime field validation
6. **`access-control.md`** - Permission patterns
- Collection-level access
- Field-level access
- Row-level security
- RBAC patterns
- Multi-tenant access control
7. **`access-control-advanced.md`** - Complex access patterns
- Nested document access
- Cross-collection permissions
- Dynamic role hierarchies
- Performance optimization
8. **`hooks.md`** - Lifecycle hooks
- Collection hooks
- Field hooks
- Hook context patterns
- Common hook recipes
9. **`queries.md`** - Database operations
- Local API usage
- Query operators
- Complex queries with AND/OR
- Performance optimization
10. **`endpoints.md`** - Custom API endpoints
- REST endpoint patterns
- Authentication in endpoints
- Error handling
- Route parameters
11. **`adapters.md`** - Database and storage adapters
- MongoDB, PostgreSQL, SQLite patterns
- Storage adapter usage (S3, Azure, GCS, etc.)
- Custom adapter development
12. **`plugin-development.md`** - Creating plugins
- Plugin architecture
- Modifying configuration
- Plugin hooks
- Best practices
13. **`components.md`** - Custom Components
- Component types (Root, Collection, Global, Field)
- Server vs Client Components
- Component paths and definition
- Default and custom props
- Using hooks
- Performance best practices
- Styling components
## Resources
- Docs: https://payloadcms.com/docs
- LLM Context: https://payloadcms.com/llms-full.txt
- GitHub: https://github.com/payloadcms/payload
- Examples: https://github.com/payloadcms/payload/tree/main/examples
- Templates: https://github.com/payloadcms/payload/tree/main/templates