teqo/docs/CHANGELOG-AGENTS.md

265 lines
230 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.

# Changelog de contexto para agentes (fatias do AGENTS.md)
Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agentes paralelos): "Known Gaps" e todos os blocos "Recently resolved". Histórico — leia só quando precisar do contexto de uma entrega passada; o sempre-presente fica no `AGENTS.md`. Itens novos de "Recently resolved" passam a ser registrados aqui (uma entrada curta), não no AGENTS.md.
---
**Recently resolved (2026-08-11):** **C115** (Issue #636) — **Google Calendar → Teqo**: quem tem permissão no calendário remarca/renomeia/cancela o compromisso direto no Google e a mudança volta para a atividade (bidirecional sobre o espelho C114). **Motor único bidirecional** no `runSyncPass` do C114 (estende o dono): para cada evento nosso — conteúdo difere? → **regra do relógio**: Google mais novo aplica **título + horário** (all-day incluído; prefixo `[Município] ` só é destripado quando bate com o município — desfazemos só a nossa prefixação; cap 160) com **registro auditável** no `updates` feed (`Google Calendar: …`, `author: null`, feed mostra "Google Calendar" como autor pelo prefixo) — senão o Teqo re-asserta. **Baseline do relógio = `activity.lastMirroredChangeAt`** (novo campo system-write, carimbado no beforeChange só quando um campo espelhado muda): task toggle/update/resultado bumpam `updatedAt` sem tocar o espelho — comparar contra `updatedAt` silenciaria uma edição pendente do Google. **Escrita reversa sem usuário** (`req.user` stripped): staff que dispara a passada não ganha autoria do registro nem passa pelo gate de re-agendamento do deputado. **Cancelamento:** `listEvents` com `showDeleted=true` — evento `status: 'cancelled'` (lixeira) com atividade `confirmado` → cancela **se o cancelamento for mais novo que o último carimbo** (senão é reopen do staff: o trashed é limpo e o evento recriado — nunca re-cancela); `realizado` é histórico protegido. **Remoção permanente** distinguida de "nunca criado" por snapshot `lastSeenEventIds` (JSONB `{calendarId, ids}`, gravado só em passada bem-sucedida e **excluindo ids que o próprio motor deletou** — senão o reopen virava re-cancelamento fantasma); troca de `calendarId` → snapshot stale → recria sem cancelar. **Webhook público** `POST /campanha/agenda/google-webhook/[secret]` (fora de `(app)`, precedente `ical/[secret]`, `maxDuration=60`, `force-dynamic`, allowlist do `campaignJsonMutationRoute` com razão): pre-filter barato (secret ≥32 + headers) ANTES do bootstrap do Payload + validação fail-closed em lib pura testada (`isGooglePushNotificationValid` — secret da URL + `X-Goog-Channel-ID` + `X-Goog-Resource-ID` + `X-Goog-Channel-Token`, constant-time, 404; `not_exists` → estado de erro; `sync` → ignora); ping sem payload → passada completa; 200 mesmo se a passada falhar (sem retry storm). **Canal lazy sem cron (Hobby):** `ensureGoogleCalendarPushChannel` em toda passada — watch com UUID novo, TTL 30d, **segredo rotaciona a cada criação** (URL vazada auto-cura na renovação), **novo canal persistido ANTES do stop do antigo** (falha do stop não orfana o novo); expira em <48h → watch com id novo + `channels.stop` do antigo; troca de calendário → re-watch; falha → `pushChannelError` (state), nunca pausa (o reverso roda em todo trigger local; canal só dá imediatismo — limitação de 30d de silêncio documentada). Campos novos na `googleCalendarSync` (system-write; **identity fields com `read: false`** — o conjunto de credenciais do webhook nunca sai por path de usuário; create admin-only + leitura prefere linha configurada); migration **hand-written** (mesmo motivo do C114). **Título editável pelo Google** exigiu `mutationKind: 'googleCalendarSync'` dedicado (título muda, slug canônico congelado) + **skip do hook de sync na própria escrita reversa** (sem passada aninhada). UI (Impeccable B): seção "Edições pelo Google" no dialog (copy + expiração do canal + erro). Testes: unit (reverse mapping 18, validação do webhook 7, dialog/feed render 6, allowlist), int (13 novos no motor com stub: Google novo aplica+converge, task toggle não silencia edição pendente, Teqo novo re-asserta, cancelled cancela+limpa, reopen pós-cancelamento não re-cancela, remoção permanente via snapshot, create falhou não cancela, troca de calendário não cancela+re-watch, all-day ida-e-volta, realizado protegido, description-only sem registro, ciclo do canal cria/renova/rotaciona/stop, falha de canal não pausa — asserções herméticas a atividades ambiente de arquivos irmãos no DB compartilhado), e2e (webhook 404 fail-closed + caminhos 200 validados: sync/not_exists/change). Suíte completa verde (unit 2064, int 749, e2e 5/5). Intenção [`c115-google-calendar-edicao-bidirecional.md`](plans/c115-google-calendar-edicao-bidirecional.md); impl [`c115-google-calendar-edicao-bidirecional-impl.md`](plans/c115-google-calendar-edicao-bidirecional-impl.md).
**Recently resolved (2026-08-11):** **C129** (Issue #696) — dobradinha ganha **nome de legenda** (campo + display discreto sob o nome): campo `ballotName` (texto opcional, `maxLength: 30` — limite real do nome de urna do TSE; **sem index** — nunca é chave de busca/ordenação/facet, o nome real continua sendo a chave) em `stateDeputy`, ao lado de `party`. **Migration** `20260811_234822_add_state_deputy_ballot_name` (gerada; o diff do gerador re-emitia um no-op de `ADD VALUE 'lembranca'` por snapshot stale — limpa à mão para só o `ADD COLUMN`; nullable, sem backfill). **Display "discreto sob o nome"** (mesma família, `text-xs text-muted-foreground`, sem label — a posição é o rótulo) em 3 pontos: 2ª linha da célula de Nome de `/campanha/pessoas` via seam pura `peopleNameSubline` em `peopleData` (C130 estende com o fallback `?? city` — regra combinada final legenda-sobrepõe-base registrada nos dois planos; nesta branch, sem legenda nada muda na linha, aceite da intenção), sublinha fora da região click-to-edit da célula de Nome na lista de Dobradinhas (irmã da célula no flex-col — clicar nela nunca abre o editor do nome), e sublinha no Nome da seção Contato da ficha. **Edição** (onde já se edita dado de dobradinha, B163): coluna "Nome de legenda" na lista de Dobradinhas (mesma máquina de célula do partido, `editTrigger="cell"`, **default-hidden** em `DEFAULT_HIDDEN_COLUMN_IDS.dobradinhas` — precedente E-mail B197; a sublinha é o display always-on e a coluna o opt-in de edição) + campo no `StateDeputyForm` (criar e editar; schemas create/update/inline `trimmedOptionalText(30)`/`trimmedNullableText(30)` — vazio limpa o campo). Escrita: `updateStateDeputyBallotNameRecord` espelhando o partido (`runStaffEntityMutation`, staff-only, sem transação/locks/Consent novos — escrita single-collection); `CampaignInlineEditableField` ganhou `'ballotName'` (aditivo). Sem migration destrutiva/access/Consent. Testes: unit (merge carrega `ballotName`; `peopleNameSubline` legenda-only/null; default de colunas dobradinhas `['email','ballotName']`), int novo `campaignStateDeputyBallotName` (staff seta/limpa, advisor ok, leader negado, criação com legenda); verificado ao vivo em dev (pessoas/dobradinhas/ficha, edição inline persistindo na sublinha). Gates: unit 2062, int 750, build OK; knip = P3 ledgered pré-existente (idêntico no baseline). Intenção [`pessoas-nome-de-legenda-dobradinha.md`](plans/pessoas-nome-de-legenda-dobradinha.md); impl [`pessoas-nome-de-legenda-dobradinha-impl.md`](plans/pessoas-nome-de-legenda-dobradinha-impl.md). (feat(C129): nome de legenda da dobradinha — campo ballotName + display discreto sob o nome (Closes #696))
**Recently resolved (2026-08-11):** **C126** (Issue #686) — os int specs do espelho Google (C114) deixam de flakear sob a suíte paralela: o motor ganhou **escopo injetável** `activityWhere?: Where` em `CampaignCalendarSyncOptions` (`src/utilities/googleCalendarSync.ts`) — appendado ao `and` do push set (`loadSyncActivities`) **e** do delete-guard set (`loadAliveActivityIds`); produção (hooks + action) omite o param → espelho cheio byte-identical. A race era mais ampla que o par da Issue: **5** specs criam atividades in-window no banco compartilhado (`campaignActivity`, `calendarFeed`, `homeSearchActivities`, `municipalityDossierData` + `campaignSuggestions` com `realizado` no lookback — o 5º que a Issue não nomeou), e `outcome.created` global contava todas; serialização não era construtível (vitest não serializa par-a-par; `fileParallelism:false` mataria o paralelismo da suíte) e "nunca relaxar a asserção" foi mantido à letra. O spec roda cada pass via helper `runSync(client, reason)` com `activityWhere: { title: { like: 'C114%' } }` (mesmo filtro do afterEach) e ganhou **dois pins**: (1) atividade estrangeira in-window **não** é espelhada; (2) o delete-guard honra o escopo (evento de atividade alheia viva é removido — espelho escopado é autoritativo no seu escopo). **Conflito com o C122 (#705, mergeado antes):** o C122 endereçou a C126 pela abordagem oposta (asserções globais **removidas** em favor de scoping por event-id, com delegação explícita do desfecho: "quem a detém decide") — a resolução do rebase mantém o contrato da Issue (contagens globais restauradas e determinísticas via escopo) e absorve a infra do C122 (credential compartilhada `googleCalendarTestKey`, cleanup de config por owned id, datas C127 derivadas do agora). **Contraprova:** fix 3/3 rodadas × 5 arquivos verdes (62/62); motor antigo re-flakeia 3–5 testes/rodada nas mesmas condições, e o pin 1 falha 100% — regressão viva. Gates: unit 2050, int 747, lint/typecheck/format/knip/cycles/build verdes. Sem migration/access/Consent/UI. Débito C127/#688 (datas hardcoded) já resolvido pelo C122 (#705). Impl [`c126-espelho-google-int-flake-isolamento-impl.md`](plans/c126-espelho-google-int-flake-isolamento-impl.md) (Issue = spec, sem intenção própria).
**Recently resolved (2026-08-11):** **OPS41** (Issue #689) — o `AGENTS.md` de main carregava **marcadores de conflito commitados** no bullet "Per-worktree environments" (` <<<<<<< HEAD` indentado + lado HEAD pré-OPS33 com artefato `- # ` + fechamento corrompido `> > > > > > > 9e18bec8`; o `=======` foi removido no próprio commit). Origem: o commit OPS33 f0f5b73f entrou com o arquivo **já conflitado** (resolve do conflito OPS33×OPS28/OPS31 commitado com marcadores); OPS37 herdou o estado. Resolução **perda zero validada por diff**: manter o lado superset (OPS28 + OPS31 + OPS33 — claim determinístico, `--issue N`, prompt citado) e descartar o lado HEAD; −3 linhas no diff; scan whitespace-tolerant de todos os arquivos trackeados confirmou `AGENTS.md` como único afetado. **Guardrail anti-reincidência** (2ª vez que marcador chega a main): novo describe `no conflict markers in committed files` em `tests/unit/codebaseConventions.unit.spec.ts` — varre `git ls-files`, falha em `<<<<<<<`/`>>>>>>>` (tolerante a indentação) e na forma corrompida commitada `^\s*(> ){7}`; roda no CI via `pnpm test:unit`. Sem migration/access/Consent/UI/schema. Intenção: body da Issue (sem plano linkado); impl [`agents-md-sem-marcadores-conflito-impl.md`](plans/agents-md-sem-marcadores-conflito-impl.md).
**Recently resolved (2026-08-11):** **Plan-issue — rascunho UI/UX do gate vira HTML+Tailwind commitado no repo (ad-hoc, decisão do usuário).** O artefato deixa de ser **Cursor canvas** (`~/.cursor/projects/<workspace>/canvases/plan-<id>-ui-draft.canvas.tsx`, fora do repo, só renderizava no IDE de quem planejou e apodrecia) e passa a ser **HTML + Tailwind v4** (`docs/plans/<slug>-ui-draft.html` — `@tailwindcss/browser@4` via jsdelivr, mesma major do app 4.1.x; rede só na hora de renderizar) **commitado com o plano** e **renderizado em PNG** por `pnpm ui-draft:render` (`scripts/render-ui-draft.mjs`, Chromium do Playwright via `@playwright/test`, fallback `load` se o CDN não settle — nunca toca banco/app): uma PNG por cena `<section data-shot="<rótulo>">` (ex. `mobile`, `desktop-vazio`) → `docs/plans/<slug>-ui-draft-<rótulo>.png`, ou full-page sem `data-shot`; PNGs **embutidos no plano** (visíveis no chat do gate E no PR — o aceite vira registro permanente; o HTML é a fonte iterável). **Contrato de fidelidade explícito em `ui-draft-html.md`** (substitui `ui-draft-canvas.md`, deletado; refs atualizadas em SKILL.md/frontmatter, intention-template.md — campo renomeado `Canvas UI:` → `Rascunho UI:` + secção de embed, skills-map.md, shaping.md): o rascunho DEVE ter layout real, espaçamentos/tamanhos na escala do app (`p-4`, `gap-3`, `h-11`, `text-sm`), breakpoints reais (390/1280) e estados críticos como cenas estáticas; é PROIBIDO ser funcional (zero JS de comportamento — cada estado é cena separada), usar código de app (`src/`), brand/polish (gradientes/emojis/sombras/logo/paleta do produto — cinza neutro + um acento), mock de dados realistas ou decisão de engenharia. Gate: iterar HTML → re-render → embutir até confirmação explícita. Verificado: render real do skeleton (classes v4 aplicadas — oklch zinc, rounded-lg 8px, text-lg 18px). Sem migration/access/Consent/UI de produto/schema. Sem issue — ad-hoc no worktree `work/5`.
**Recently resolved (2026-08-11):** **OPS40** (Issue #682) — `pnpm dev` honra o `PORT` do `.env.local`: `next dev` resolve a porta **antes** de `@next/env` carregar env-files (commander `.env('PORT')` no `bin/next` — causa confirmada no fonte 15.4.11), então o `PORT=3100+slot` do provisionador de worktree era ignorado e todo dev server subia em 3000 (colisão entre worktrees paralelos; o e2e escapava porque o webServer injeta `PORT` no env real). **`pnpm dev`/`devsafe` agora passam por `scripts/dev.mjs`** (wrapper): importa o `guard-dev-db.mjs` (preflight fail-closed inline, mesma saída), `loadCliEnv()` (`.env.local` → `.env`, `override:false`) e entrega a porta ao `next dev` como **flag CLI `-p`** — a única alavanca que o repo controla. Precedência: env real do processo (webServer do Playwright manda) → `.env.local` (worktree) → `.env` → sem `-p` (default 3000 + `allowRetry` do Next intactos no main repo). Derivação pura unit-testada em `scripts/lib/cli.mjs` (`resolveDevPort` — null quando ausente, throw fail-closed em PORT inválida; `nextDevArgs`); spawn `node next/dist/bin/next` com `NODE_OPTIONS=--no-deprecation` (paridade cross-env), stdio inherit, forward SIGINT/SIGTERM/SIGHUP, propagação de exit code; extras (`--turbopack`) repassados após o `-p` derivado. Sem migration/access/Consent/UI. Verificação: boot manual no worktree → "Local: http://localhost:3140" (antes 3000), unit 8/8 novos, e2e dev-mode `campaignAuth` 3/3 (2 runs de prewarm estouraram o orçamento sob load ~50+ da máquina — flake de contenção fichado B17/B27/B32/B42, terceira run verde com cache quente). Nota fora de escopo: `AGENTS.md` em `main` carrega marcadores de conflito commitados (OPS33/OPS37) — área de main-mess da OPS38; o novo bullet do Database section foi adicionado **fora** da região conflitada. Intenção [`worktree-dev-port-nao-honrado.md`](plans/worktree-dev-port-nao-honrado.md); impl [`worktree-dev-port-nao-honrado-impl.md`](plans/worktree-dev-port-nao-honrado-impl.md).
**Recently resolved (2026-08-11):** **C124** (Issue #668) — tarefa de atividade fica só com **texto, responsável e concluída**: o campo `due` (Prazo, datetime) sai do modelo e da UI. Remoção em cadeia num único commit simétrico: campo do array `tasks` da `activity` (o guardrail de liderança perde a comparação de `due` no mesmo item — continua fail-closed sobre título/responsável/contagem), `taskSchema` zod, `parseTasksFormData`/`ParsedActivityTask` (formData), `ActivityFormTaskViewModel`/`ActivityTaskViewModel`, input `datetime-local` do `ActivityTaskFields` (grid `2fr_1fr`) e linha "Prazo: …" do `ActivityTaskChecklist` (subtítulo condicional + `break-words` no título — P2 dos reviewers). `doneAt` ("concluída em") permanece como derivado invisível; `taskTotal`/`taskDoneCount` intocados. **Migration** `20260811_183016_drop_activity_task_due` (gerada; diff do gerador tinha no-op de `google_calendar_sync` por snapshot stale — limpa à mão para só o `DROP COLUMN "due"` de `activity_tasks`; dados descartados conforme decidido no gate). Grep pós: zero `due` fora das migrations (histórico frozen); nenhum consumidor fora da vertical (notificações, Google sync, quick actions). Gates: unit+int 736/736, e2e atividade 9/9 (1 flake ECONNRESET no run paralelo — rerun limpo), build OK. Intenção [`atividades-tarefas-sem-prazo.md`](plans/atividades-tarefas-sem-prazo.md); impl [`atividades-tarefas-sem-prazo-impl.md`](plans/atividades-tarefas-sem-prazo-impl.md).
**Recently resolved (2026-08-11):** **C118** (Issue #657) — página de detalhe da pessoa `/campanha/pessoas/[id]` (staff), montada pelas **capacidades** da ficha: a mesma rota serve liderança, dobradinha, assessora, apoiador — e só mostra o que a pessoa tem. **Loader irmão** `loadPersonDetail` (`src/utilities/people/personDetail.ts`) reusa o merge da lista (`mergePeopleSources` + `scopePeopleRows`) com os mesmos selects por fonte — o **escopo do assessor é literalmente o da lista** (pessoa só aparece se alguma capacidade municipal estiver na carteira; `null` → `notFound()`), staff com bypass justificado (identity-gated, prova pelo merge); **seção Apoiador** (dado novo vs. view model da lista) lê `supporter` por contact com o access da própria collection (`overrideAccess: false`) — camada dupla, nunca alarga (int testado: apoiador de município fora da carteira é cortado). **`[id]` é `contactID`** (a chave de linha da C100 — e o link do Nome na lista, que a C116 nasceu apontando para `/campanha/liderancas/[id]`, agora aponta para cá). UI: `PersonDetailSections` em `components/campaign/people/` — Ficha (partido/telefone/e-mail/base + pills de capacidade), Liderança (status, acesso ao app, municípios, link "Abrir detalhe de liderança" — convivência v1 com `/campanha/liderancas/[id]`, votos continuam lá), Dobradinha, Assessora (por conta, `campaignRoleLabels`), Assessorado, Apoiador (fonte · município · intenção quando consentida · data), Ações reusando os botões da lista (WhatsApp, `LeadershipInviteRowAction` só com liderança, `DeletePersonButton` ganhou `deletedHref` opcional → volta à lista pós-delete). Sem migration/access novo/Consent; sem votos/KPI (intenção corta; assimetria intacta). Testes: int `personDetail` (matriz de escopo: unrestricted completo, assessor na carteira vê, fora → null, corte de supporter, só-staff só para unrestricted, conta leader não acende Assessora, contato solto/leader → null) e e2e novos em `campaignPeople` (coordinator vê seções por capacidade + link de liderança; dobradinha+apoiador sem bloco de liderança; leader → `/campanha/contatos`). Verificado ao vivo em dev (coordenador/assessor/leader, 404 de id inválido, mobile). Intenção [`pessoas-detalhe-por-capacidades.md`](plans/pessoas-detalhe-por-capacidades.md); impl [`pessoas-detalhe-por-capacidades-impl.md`](plans/pessoas-detalhe-por-capacidades-impl.md).
**Recently resolved (2026-08-11):** **B197+** (Issue #642) — o menu do picker de colunas de assessores nasce da **mesma fonte** que os `<th>` do `AdvisorsTable` (fim do espelho): o módulo client declara `advisorTableColumns` (id/label/mandatory + `headerClassName` + `visuallyHiddenLabel` para o `sr-only` de Ações) e exporta `advisorPickerColumns = toCampaignColumnPickerColumns(...)` (precedente `supporterPickerColumns`); o header renderiza por `resolveVisibleColumns(advisorTableColumns, hiddenColumnIds)` — mesmo helper B17 das outras listas — e `emailHidden` deriva do mesmo `visibleColumns`. **Descoberta do /simplify incorporada:** o corpo só condiciona a coluna `email` (células à mão + exceção do draft), então `phone`/`municipalities`/`actions` são `mandatory: true` — esconder um deles (cookie velho do toggle inerte do B197) derrubaria o `<th>` sobre um corpo de 5 células; os checkboxes viram "sempre visível" (honestos). `assessores/page.tsx` consome a fonte exportada e perdeu o array espelho + imports órfãos. Comportamento da tabela idêntico (ids/labels/ordem, exceção do draft intacta); e2e `campaignColumnPicker` ganhou pin dos mandatory. Sem migration/access/Consent/UI/cache. Observações de ambiente (não da mudança): o e2e do picker falha no harness dev sob carga desta máquina (painel do popover não monta; falha idêntica na base e em lideranças — intocado; conjunto falho deriva com a carga; verificado funcional em Chromium real); warning React de key pré-existente no assessores (`CampaignListOmnibox` — base também). Intenção [`escala-dry-pos-b197.md`](plans/escala-dry-pos-b197.md); impl [`escala-dry-pos-b197-impl.md`](plans/escala-dry-pos-b197-impl.md).
**Recently resolved (2026-08-11):** **C116** (Issue #655) — `/campanha/pessoas` desktop vira **edit-where-you-see**: toda célula (exceto Ações) é um input permanente sem destaque — salva no blur/Enter, Esc descarta, vazio mostra "—" apagado. **Nome** mantém o valor como link para a ficha (`/campanha/liderancas/[id]`) com o mecanismo travado no gate: input invisível (`text-transparent`, z-0) sobre o link (z-10, `pointer-events-auto`, `w-auto` — cobre só o texto; o resto da célula é a área clicável do input), draft renderizado **no texto do link** (o valor real do input nunca é o display), clique no link navega, teclado/área fora do texto edita. **Células de municípios** (Assessora, Lidera, Aliada em) e **Assessorado** usam chips internos com: grupo novo **"Salvador (19)"** (`buildMunicipalityPortfolioChips` ganha kind `city`, checado antes do território — senão as 19 ZEs colapsariam como "Metropolitano de Salvador"), **chip batch clicável expande in loco** (estado de apresentação do wrapper `PeopleMunicipalityCell` — clique expande, mouse-leave colapsa se todos os membros seguem atribuídos; X num membro remove e impede o recolapso), **"+X"** no toggle de overflow do B170, `quiet` (sem tint de hover/foco; indicador de foco discreto só no input), commit por **match exato** no Enter/espaço (digitar "salvador-ze-2"+Enter adiciona a ZE certa, não a pre-selecionada — regra da intenção). Escrita: `updatePersonContactRecord` (escopo "edita o que vê" — assessor só com entidade da pessoa na carteira; `contactFieldUpdateSchema` ganhou variante `city`, convivendo com `phones` do C112), `setPersonAssessoraMembershipRecord` (unrestricted-only, single-account — multi-conta nega com mensagem segura e célula read-only; toggle extraído para `toggleMunicipalityAdvisorMembership` em `municipality.ts`, reusado pelo record B27) e `setPersonAdvisorMembershipRecord` (union person-centric: delta de assessor nas DUAS entidades quando ambas existem). Matriz por papel na página: assessor edita Lidera/Aliada com `addableIds`=carteira e **read-only** em Assessora/Assessorado (regras das collections donas); tooltips de label (WhatsApp/Convidar/Apagar) via `CampaignHoverTooltip`; `CampaignListSheetProvider` na tabela (guard miss #52 — wrapper de composição registrado no `sharedSheetHostConventions`). Mobile intacto. Sem migration. Testes: unit (grupo Salvador + expand + match exato; guard do sheet host), int novo `campaignPersonCellEditing` (11 — escopo por papel, assessora single/multi/sem-conta, union nas duas entidades), e2e `campaignPeople` 4/4. Intenção [`pessoas-edicao-inplace-lista.md`](plans/pessoas-edicao-inplace-lista.md); impl [`pessoas-edicao-inplace-lista-impl.md`](plans/pessoas-edicao-inplace-lista-impl.md).
**Recently resolved (2026-08-11):** **B198** (Issue #660) — o drawer mobile do Sollinha fecha ao navegar por link interno: tocar num link `/campanha…` da resposta fecha o drawer **no toque** (antes de a página carregar) e a navegação segue no mesmo tab; desktop inalterado (painel fica aberto); link externo (nova aba) não fecha o drawer da aba de origem; reload não reabre (o close persiste `open: false`). **Engrenagem:** a decisão pura mora no owner `src/lib/ai/markdownLinks.ts` — regex `^\/campanha(?:\/|$)` promovido a `isCampaignInternalLink` (era `APP_INTERNAL_LINK` no renderer) + `shouldCloseDrawerOnLinkClick(href, isMobile, event)` (fecha ⇔ interno && mobile && clique simples — ignora ctrl/meta/shift/alt, middle-click e `defaultPrevented`, pois abrem nova aba e a origem mantém o chat); o `markdownComponents` estático do `CampaignAIChat` virou `useMemo` no corpo (acesso ao `ctx.isMobile`/`setOpen`; identidade estável, recomputa só no crossing de breakpoint). Sessão/restauração intactas (fechar com `requestOpen(false)` marca `openBy:'user'` com `open:false` — inerte, o restore só reabre com `open:true`). Testes: unit (12 — interno/externo, viewport, modifiers, middle, prevented) + e2e no `campaignSollinhaContext` (interno fecha+navega sem `load`+reload não reabre; externo mantém drawer e abre popup interceptado). Nota: dev-mode local flakeou no prewarm/compile cold — e2e rodado no modo CI (`E2E_PROD=1` com `NEXT_DIST_DIR=.next/e2e`, 7/7). **Débito registrado no gate (B199/#672, `depends` #660):** race pré-existente do persist effect (só grava com `status==='ready'`) — fechar o drawer mid-stream e recarregar antes do settle reabre o drawer "sozinho" (classe OPS22); o tap-no-link do B198 amplia a superfície. Intenção [`sollinha-drawer-fecha-link-mobile.md`](plans/sollinha-drawer-fecha-link-mobile.md); impl [`sollinha-drawer-fecha-link-mobile-impl.md`](plans/sollinha-drawer-fecha-link-mobile-impl.md).
**Recently resolved (2026-08-11):** **C114** (Issue #635) — Agenda da Campanha espelhada no Google Calendar com push Teqo→Google (API oficial, service account). **Motor:** reconciliação completa por passada (`runCampaignCalendarSync`) com **id determinístico de evento** (`teqo` + base32hex do id — charset do Google) → upsert idempotente sem estado por evento; janela 90d passado/365d futuro (precedente do feed iCal), espelho cheio do escopo do staff (sem intersecção de advisor — agenda oficial), cancelado/deletado no Teqo **apaga o evento no Google** (sem estado cancelado visível a quem segue); conteúdo comparado por instante absoluto (imune a fuso do calendário) → passadas repetidas não tocam o Google; eventos estrangeiros do calendário nunca são tocados (decode completo — um `teqo3` manual não é nosso). **Triggers (sem cron — Hobby):** hook `afterChange`/`afterDelete` na `activity` (só campos espelhados, com normalização de shapes populados vs depth-0; tarefas não disparam; **nunca lança** — Teqo independe do Google; timeout 15s por request), auto-retry na view da agenda quando `paused` (uma vez por carregamento), botão "Sincronizar agora", e **auto-resync por config** (D7): trocar `calendarId`/reativar na collection `googleCalendarSync` re-conciliar sozinho (guard por comparação de campo quebra o loop de re-entrada); scan de delete com lookback de 730d (cancelado antigo não vira fantasma; atividade viva fora da janela mantém o evento). **Estado:** collection `googleCalendarSync` (uma linha; `calendarId` admin-only, `disabledAt` staff, timestamps system-write; **status derivado** `not-configured | disabled | synced | paused` em lib pura) + **chave da service account só em env** (`GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`, base64 do JSON do GCP — sem credencial no DB); sem env → `not-configured` com runbook de ops (criar calendário na conta da campanha, público no link, SA editor, calendarId no Painel). **Transporte:** `jose` (novo dep) + fetch → Calendar API v3 REST (token JWT assertion, cache em módulo, 401 re-minta uma vez, 409 de insert idempotente; `end` obrigatório → atividade sem `endAt` vira 1h). **UI (Impeccable B, padrão C94):** pill no header da agenda (sincronizado/pausado/não configurado/desativado) + FAB mobile + diálogo com os 4 estados do canvas (link público de adição `calendar/r?cid=webcal…` + instruções + sync agora + desativar; falha de action visível). **Sem participantes no v1** (decisão de produto do gate: aviso segue as configs de quem segue; service account exigiria domain-wide delegation — segue para o C115/Google→Teqo). DRY com o feed: janela/where/nomes de município/description compartilhados (`calendarFeed.ts` como dono da janela). Migration hand-written (o gerador re-emitia no-ops pré-existentes de `campaign_user.contact_id`). Testes: unit (mapping/derivação/guards com normalização/client com fetch fake/hook wiring via `vi.mock`), int (engine com stub in-memory: cria/converge/atualiza só drift/cancela deleta/delete limpa fantasma/falha→pausado→recupera/troca de calendarId), e2e (pill não-configurado + runbook, sem dependência externa). Débito de cobertura (estados disabled/paused/synced + ação) registrado como #666 (depends #635). Intenção [`c114-google-calendar-push-agenda-oficial.md`](plans/c114-google-calendar-push-agenda-oficial.md); impl [`c114-google-calendar-push-agenda-oficial-impl.md`](plans/c114-google-calendar-push-agenda-oficial-impl.md).
**Recently resolved (2026-08-11):** **D10** (Issue #617) — auto-cura de leftovers de consent/supporter + fim do shared-write latente no lease de consent (pós-D9). **F1 (self-heal):** `purgeOrphanedConsentRenames` (3 nomes explícitos de renames temporários — `temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`) roda sob lease **exclusivo**, em **autocommit** (delete na transação do lease seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada — observado na primeira implementação), nos 3 pontos que tocam a key canônica (`withMutableConsentFixture` antes do snapshot, setup do `withMissingInviteConsentFixture`, `ensureLeasedConsent`) — uma run abortada no meio de um rename de teste não envenena mais o run seguinte (UNIQUE `consent_key_idx` auto-sustentado). **F1b:** `purgeMunicipalityResidue` também deleta os **contatos** que supporters residue órfãos deixaram (reference-check em leadership/signature/subscription/supporter pós-delete — mesmo critério do `removeSupporterData`, reimplementado no helper). **F2 (refutação por medição + design final):** a hipótese "shared-writer = ABBA latente" caiu sob carga — exclusivo-durante-a-operation → fila de 17 waiters e cascata de 15s (6 workers); operation sem lease → FK `consent_id` (cleanup de fixture apagava linha canônica adotada); o ABBA não é construtível hoje (escritas disjuntas por teste; Onda0 em ordem fixa de 3 keys). As flakes reais do baseline (3/4 runs) eram: consumidores observando a ausência commitada da janela missing-consent (janela D9 era **shared**), down-test da Onda0 deletando as 4 keys **não-atomicamente**, e provision re-textando no meio de asserções de preview. **Fix:** janela do `withMissingInviteConsentFixture` → **exclusiva** (bloqueia consumidores shared durante a ausência; fila limitada ~200–500ms — a medição D9 do "15s tight" era o regime ONDA0-exclusivo, inexistente hoje); down-test da Onda0 **atômico** (delete+count+restore numa transação — FK insert espera o commit via speculative insertion); provision da Onda0 com leases **exclusivos** aninhados (ordem fixa CI→SR→VI); consumidores continuam shared. **Review /simplify (2 reviewers):** P1-1 — os renames vivos de `campaignInvite`/`campaignFixtures` migraram para chaves privadas (`consent-vacated-<id>`/`owned-consent-vacated-<id>`) — nenhum teste vivo produz os 3 nomes do purge; comentários "Onda0 shared" corrigidos; `withExclusiveTestDatabaseLease` colapsado (era cópia do privado); rollback no down-test quando a sessão falta; cleanup no finally nos testes F1 (auto-curáveis — um run falho do próprio teste não polui). Testes novos: contrato da janela exclusiva (waiter SHARED bloqueado), purge em ensure/setup, self-heal do canônico (repro do run abortado, 2 fixtures), F1b. **Contraprova:** 7 arquivos da key × 6 workers → **12/12 verdes** (baseline 1/4; designs intermediários: cascata 15s / FK); suíte int completa 83 arquivos/701 testes; unit 1938; e2e paridade com baseline (falhas pré-existentes de chat IA/webauthn/mic reproduzem no código original). Sem migration/access/Consent/`src/`. Intenção [`escala-dry-pos-d9.md`](plans/escala-dry-pos-d9.md); impl [`escala-dry-pos-d9-impl.md`](plans/escala-dry-pos-d9-impl.md).
**Recently resolved (2026-08-11):** **C113** (Issue #631) — feed iCal da agenda **vivo**: eventos criados depois da criação do link aparecem no mesmo link. **Spike diagnóstico primeiro** (e2e novo com o aceite de dois `GET`s via fixture — roda em dev E no build de produção): o teste **passou no modo prod ANTES do fix**, provando que o Next 15 **não** congela route handlers GET (docs: "not cached by default" desde 15.0; build compila a rota como `route.js` dinâmica, sem `.body`) — **o freeze é o contrato de resposta**: `Cache-Control: public, max-age=3600` sem validadores permitia a caches compartilhados (CDN da Vercel, infra do Google, proxies) reter o snapshot do primeiro fetch. **Fix (mínimo):** `export const dynamic = 'force-dynamic'` na rota `ical/[secret]` (explícito, convenção do repo) + novo `buildICalFeedResponse` em `src/utilities/calendarFeed.ts` (helper puro): `Cache-Control: public, no-cache` (armazenar só com revalidação obrigatória — fecha também a janela de 1h servindo feed revogado), `ETag` forte (sha256 do corpo, hash sempre muda com conteúdo), `Last-Modified` informacional, `X-PUBLISHED-TTL: PT1H` (RFC 7986) e **304 só por `If-None-Match`** — nunca por `If-Modified-Since`, porque exclusão de atividade não move `Last-Modified` (linha some) e um 304 por data esconderia "evento fantasma". **Testes:** unit do helper (12/12 — headers, ETag estável/mutável, 304 sem corpo, `*`/lista/weak, ETag velho → 200, IMS nunca 304); int na rota real com payload (`route.dynamic === 'force-dynamic'` + A → GET → B → GET ambos → edição de horário → DTSTART novo → cancelamento → some → GET condicional → 304, 18/18); e2e do aceite completo no spec existente `campaignAgendaFeed` (dev 5/5, prod build 1/1). Aceite: edição/cancelamento refletem, revogação/escopo do criador fail-closed intactos, sem migration/schema/access/UI/Consent. Verificação pós-deploy pendente no elo CDN externo (link real de produção). Intenção [`c113-feed-ical-agenda-nao-atualiza.md`](plans/c113-feed-ical-agenda-nao-atualiza.md); impl [`c113-feed-ical-agenda-nao-atualiza-impl.md`](plans/c113-feed-ical-agenda-nao-atualiza-impl.md).
**Recently resolved (2026-08-10):** **OPS36** (Issue #601) — journeys com **sessão compartilhada** nos 3 arquivos e2e pesados (`campaignHomeActions` 17, `campaignMunicipalities` 20, `campaignBottomNav` 9 testes): o overhead repetido por teste **amortizado de verdade**, não o que a intenção supunha. **Premissa da intenção corrigida por medição:** `getPayload` NÃO é pago por teste — Payload 3.82 memoiza a instância em `global._payload` keyed por `options.key` (medido: 364 ms na 1ª chamada do worker, 0 ms nas seguintes). O custo real era **o login de browser + a criação de usuário por teste** (bcrypt): login isolado medido em **3.6 s (quente) / 30.3 s (1º hit)** vs **112–134 ms** do `payload.login` de seed — ~32×. **Mecanismo:** `beforeAll` por arquivo cria UM coordinator + UM leader (instância `CampaignE2EOwnership` própria, cleanup em `afterAll`) e minta o token via `payload.login` (a MESMA via do login real — session row + JWT que o app valida); `beforeEach` injeta o cookie `campaign-token` (path `/campanha`, httpOnly, sameSite lax) no context fresh de cada teste — helpers `mintCampaignSession`/`seedCampaignSession`/`createCampaignOwnership` no fixture. **Divergências declaradas da intenção:** (1) **sem `mode: 'serial'`** — serial existia para reusar o contexto do browser; com seed por cookie o reuso é desnecessário e o serial skipa N+1..k em falha (perde diagnóstico — anti-goal "mega-teste" da própria intenção); (2) storageState rejeitado (file race entre workers + `test.use` em tempo de definição); (3) **login UI sai dos 46 testes** — caminho permanece coberto por `campaignAuth.e2e.spec.ts` + journey mista t9 (advisor→leader, mantém browser login); advisors **nunca** compartilhados (OPS29 exige portfólio vazio; B176 atribui portfólio). **Flake classe exposta e endurecida:** sem o warm-up incidental do login, clicks de estado client-side (drawer/sheet/overlay) passaram a correr contra a hidratação — a classe B13/B17 da casa; 7 interações ganharam o padrão `toPass` (B70 add-liderança, Mais drawer, B42 trend/C109, B193 scenario, FAB×3) e 7 lookups do strip de ações/omnibox ganharam `.first()` (duplicado transitório RSC do shell — classe `.nth(0)` já documentada), sem enfraquecer asserção. **Medição antes/depois:** baseline 29/49 passou em 10.0 m a load ~55 (20 falhas = classe de carga/restart de Postgres por outro worktree — OPS37; A/B no código original: B97 falha identicamente na mesma carga, e o conjunto falhante casa o do baseline); com o seed, runs limpas: homeActions 18/19 (2.6 m), interações endurecidas 8/10 (falhas residuais = render lento sob load, classe pré-existente comprovada). Sem migration/access/Consent/UI. Intenção [`e2e-journeys-seriais-arquivos-pesados.md`](plans/e2e-journeys-seriais-arquivos-pesados.md); impl [`e2e-journeys-seriais-arquivos-pesados-impl.md`](plans/e2e-journeys-seriais-arquivos-pesados-impl.md).
**Recently resolved (2026-08-10):** **B197** (Issue #627) — coluna de e-mail oculta por padrão nas cinco listas de pessoas do `/campanha` (lideranças, dobradinhas, pessoas, apoiadores, assessores), religável pelo seletor de colunas B17 (cookie `campaign_columns`, default vale só para quem nunca tocou no seletor — escolha salva vence). Defaults novos em `DEFAULT_HIDDEN_COLUMN_IDS` (`email` nas 5) e `assessores` entrou em `CAMPAIGN_LIST_IDS` (primeira lista sem `CampaignTable` no mecanismo). **Pessoas (C100 reaberto):** o e-mail saiu da célula "Contato" (fica só telefone, `—` sem contato) e virou coluna própria oculta — cards mobile intocados (anti-goal). **Apoiadores:** coluna "E-mail" nova no `SupporterList` (dado já existia via `contact`; `SupporterListItemViewModel` ganhou `email`), nascendo oculta. **Assessores:** o encaixe — picker B17 na lista (trailing no omnibox, `advisorPickerColumns` espelhando os `<th>`) e `AdvisorsTable` (client) passou a receber `columnVisibility` com `<th>`/`<td>` condicionais; **exceção única: a linha-draft de criação mantém o input de e-mail sempre** (campo obrigatório no create). Sem migration/access/Consent/cache. Testes: unit do mecanismo (parser passa a guardar `assessores`; defaults + stored-wins) e e2e novo no `campaignColumnPicker` (default oculto, religar, persistência no reload, draft com input). Intenção [`ocultar-email-por-padrao-listas.md`](plans/ocultar-email-por-padrao-listas.md); impl [`ocultar-email-por-padrao-listas-impl.md`](plans/ocultar-email-por-padrao-listas-impl.md).
**Recently resolved (2026-08-10):** **Push /campanha — opt-in automático + fix do prompt nativo (ad-hoc).** O `requestPermission` do card de perfil rodava **dentro de `startTransition(async …)`** — qualquer fronteira assíncrona entre o clique e a chamada derruba a user activation e o Chrome **auto-nega silenciosamente** (resolve `'denied'` sem mostrar o prompt): o teste real em device deu exatamente "Permissão de notificação negada". Fix: os guards sync ficam no handler e **`Notification.requestPermission()` passa a ser chamado sincronamente no clique** (antes de qualquer `await`/transição); mensagens distintas para `denied` (permissão bloqueada — reativar nas configurações do site; um `denied` não pode ser re-perguntado por nenhuma API) vs `default` (prompt não exibido). **Opt-in automático** (pedido do usuário): novo `CampaignPushOptInToast` no shell `(app)` (padrão `InstallPwaToast`/`BiometricEnrollmentToast`), montado só para staff (leader não recebe push — sino idêntico), exibido ~2s após o login quando `Notification.permission === 'default'` (nunca `denied`), sem inscrição ativa no device, consentimento `campanha-notificacoes-push` publicado e VAPID configurado; iOS só com app instalado (standalone); "Aceitar e ativar" roda a mesma cerimônia com o requestPermission síncrono no clique do action; dismiss fica salvo em `localStorage` (chave `campaign-push-opt-in-dismissed`); ativação/desativação/leitura de inscrição extraídas para `src/utilities/campaignPushClient.ts` (compartilhado com o card — don't twin; registrado no pin de top-level com razão). **Diagnóstico de prod (read-only, `db:check:push-chain`):** consentimento `campanha-notificacoes-push` **já publicado** (PASS — nada a criar), envs VAPID presentes, SW versionado, 77 eventos no sino — **único elo quebrado: 0 inscrições push** (o erro de permissão impedia o cadastro). Sem migration/access/Consent/schema. Sem issue — entregue via ad-hoc no worktree `work/3`.
**Recently resolved (2026-08-10):** **OPS37** (Issue #605) — o container Postgres compartilhado **não é mais recriado** por um worktree vizinho: `pnpm db:start` virou `scripts/db-start.mjs` (CLI) sobre `scripts/lib/db-start.mjs` — preflight "container `teqo-postgres-1` Up + `healthy`/`starting` → **no-op** (compose nem roda); senão, `compose -p teqo up -d --wait --wait-timeout 120 postgres` como antes. **Causa raiz provada por medição:** o bind mount **relativo** `./docker/postgres/init` do compose resolve um path absoluto por worktree → `config_hash` diferente por worktree (OPS37 `39e89956…` / B195 `cf3cb522…` / C101 `917e11bb…`; diff do `config` = só o `source:` do bind) → qualquer `compose up` cru de outro worktree fazia `replace` e matava dev+e2e de todos (`terminating connection due to administrator command`) — o incidente se repetiu **ao vivo durante o gate**: o worktree `work/4` recriou o container no meio da suíte int (17 testes mortos; os 3 arquivos re-rodaram verdes e a suíte completa 691/691 passou em seguida). **Verificação do aceite:** `StartedAt` inalterado após `db:start` repetido, inclusive rodado de **outro worktree** (D9, hash diferente) — o caso que antes recriava. Recriação legítima (bump de imagem/config) segue por `pnpm db:start --force-recreate` (explícito; não testado live para não matar a run e2e do D9 em voo — flag é pass-through de 1 arg). `worktree.mjs` provisionamento delega para a mesma lib (mesmo risco no `worktree next`; `dockerComposeUp`/`SHARED_COMPOSE_PROJECT` órfãos removidos). Skip deliberado em `starting` (container que um peer acabou de criar): **espera** o boot ficar `healthy` (60s, polling 2s) em vez de recriar — e cai no compose up se travou; **não** skip em `unhealthy`/sem healthcheck (volta à config atual exatamente uma vez); `db:stop`/`db:doctor`/compose file/CI intactos. Unit novos (`dbStart.unit.spec.ts`, 17: matrix running×health + parse do inspect + wrapper mockado — skip não chama compose, argv do up/force-recreate, espera de `starting`). Sem migration/access/Consent/UI. Intenção [`container-postgres-compartilhado-compose-replace.md`](plans/container-postgres-compartilhado-compose-replace.md); impl [`container-postgres-compartilhado-compose-replace-impl.md`](plans/container-postgres-compartilhado-compose-replace-impl.md).
**Recently resolved (2026-08-10):** **D9** (Issue #596) — flake do consent restore do `testDatabaseLease` sob carga eliminado. **Caracterização com reprodução** (3 falhas em 8 runs da suíte int completa; depois isolada a 2 arquivos): o assertion "restores the exact configured consent after deletion and recreation" via leituras voláteis de uma linha canônica que escritores legítimos de outras specs deletavam/recriavam no meio do teste. **Causa raiz em três camadas:** (1) a janela de operação de `withMissingInviteConsentFixture` era a única transição de estado sem lease da suíte (outras specs criavam linha efêmera que o restore deletava sob elas); (2) `removeOnda0ConsentAndPrivacyDb` rodava sob leases **shared** e deletava as 4 keys estáveis no meio de testes alheios (o id restaurado sumia irreversivelmente); (3) asserções de restore comparavam contra leituras `before` que o provision Onda0 podia re-textar legitimamente. **Fix (só testes, zero `src/`):** janela de operação sob lease shared (`serializeWindow`, default true; o teste de ensures concorrentes migrou para key/lease privada — único caso que deliberadamente mantém janela sem lease, com re-verificação/re-delete do gap setup→janela; exclusivo descartado: alargava o ABBA com os escritores de invite sob carga); spec Onda0 com leases **shared** + down-migration **restaura as linhas com os ids originais** dentro da janela (`restoreConsentRows` compartilhado; no-op para o resto da suíte); asserções de restore referenciam o **snapshot do fixture** (contrato real), rollback asserta id+key; `ORDER BY id`/`sort: 'id'` nas leituras limit-1; core `withDatabaseLease`; `ensureInviteConsent` órfão removido (knip). Contraprova: suíte completa ×5 + par mínimo ×8 + 6 arquivos da key ×13/14 verdes (runs com restart externo do Postgres descartados por uptime). Intenção [`test-database-lease-consent-flake.md`](plans/test-database-lease-consent-flake.md); impl [`test-database-lease-consent-flake-impl.md`](plans/test-database-lease-consent-flake-impl.md).
(fix(C113): feed iCal da agenda vivo — contrato de frescor no lugar do snapshot congelado)
**Recently resolved (2026-08-10):** **B195** (Issue #591) — wizard "Registrar pedido" completo (A5, adiado no B85): o passo final saiu do stub e virou formulário funcional no shell do wizard — município no header do app (chrome `municipalityLabel`), **sem título grande de passo** (`stepTitle={null}` + `contentFocus="none"`), Tipo → Atividade (opcional) → **campo único** "O que você precisa?"; salvar ("Abrir demanda") → toast + retorno à origem (`returnPath`). **Mudança de modelo da demanda:** o cadastro (wizard e `/demandas/nova`, sem divergência) tem UM campo de texto (`description` required, min 2, max 4000 — constante `CAMPAIGN_DEMAND_BODY_MAX_LENGTH`); o **título virou derivado por IA** (`src/utilities/ai/campaignDemandTitle.ts` — `generateText` DeepSeek `deepseek-v4-flash` como o Sollinha, timeout 4s, sem key → `null`, prompt com rótulo do tipo) com **fallback = texto truncado** (`src/lib/demandTitle.ts`, puro: colapsa whitespace, ≤160) no create e **keep-previous na edição** (novo `updateCampaignDemandRecord` + editor inline no detalhe — recalc só quando a descrição muda; slug preservado pela invariante canônica); `campaignDemandCreateSchema` perdeu `title` (só os drafts de demanda da atividade mantêm título digitado — vertical fora de escopo, schema desacoplado). **Unicidade de slug defensiva só no create das forms** (`ensureUniqueDemandSlug`, opt-in `context.campaignDemandUniqueSlug` fail-closed em transação ativa — precedente `skipContactPhoneInvariant`): probe único da janela `-2…-20` + advisory lock `campaign-demand-slug:<base>` contra race; activity drafts/admin mantêm a constraint estrita (contrato de rollback C90 pinado em teste). Page: branch `register-demand` no router + **staff gate ANTES do load do escopo** (`gate: 'staff'` → líder vai a `/campanha/contatos`, B43 — o 403 do scope load pré-existente virava erro não determinístico; stub `WizardMunicipalitySelectedStub` removido, placeholder "em breve" apagado). **Bug latente corrigido no hook compartilhado** `useCampaignFormSuccessToast` (dependência `[state.message, state.status]` → `[state]`: segundo save com a mesma mensagem — caso do editor — não disparava toast/refresh). Copy compartilhada em `schemas/campaignDemand.ts`; parser único `parseCampaignDemandCreateFormData` (`utilities/demand/`). **Sem migration** (título continua required, preenchido pelo record). Testes: unit do fallback/limites exatos; int (create deriva, colisão `-2`/`-3`, min 2, update keep-previous/skip, leader edit bloqueado, pin "sem `DEEPSEEK_API_KEY`" no `beforeAll`); e2e novo `campaignRegisterDemand` (fluxo completo com título na lista, back-nav à busca, lockdown de líder). Intenção [`registrar-pedido-wizard.md`](plans/registrar-pedido-wizard.md); impl [`registrar-pedido-wizard-impl.md`](plans/registrar-pedido-wizard-impl.md).
**Recently resolved (2026-08-10):** **OPS33** (Issue #595) — `worktree next` **claima deterministicamente** antes de criar o worktree: mesma fila/ordem e lock otimista do `pnpm agent:claim`, com o claim extraído para `scripts/lib/agent-github.mjs` (`claimIssue` — lock + flip + comentário; `claimBriefLines` — brief compartilhado; `claimQueueEntry` — shape para o reopen; `claimTargetVerdict` — decisão pura testada) e o `agent-claim.mjs` virou CLI fino com a **mesma saída**; claim falhou (fila vazia/race/`--issue` inválido) → motivo e saída **sem worktree órfão** (branch validada antes do claim; hint de falha pós-claim). `--issue <N>` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress` — sem re-claim, idempotente; recupera também falha pós-claim); a saída imprime o brief e avisa "já claimada — NÃO rodar `pnpm agent:claim`". O **launch** do terminal agora envia `/work-issue --issue <N>` (prompt citado na diretiva) e a função `worktree()` de `.agents/shell/worktree.sh` passou a **tokenizar a diretiva via xargs** (quote-aware, nunca eval) — o valor do `--prompt` carrega espaço. `--stay` suprime cd+launch mas o claim acontece; o comando `/worktree` roda o mesmo script (claim incluso, sem launch). Guard `-B` contra reset de commits de sessão no reopen. Pool intacto (`claimIssueForPool` coordenado). Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue` (sai "read-only"), AGENT-OPS. Sem migration/dados/access/UI. Intenção [`worktree-next-claima-issue-deterministicamente.md`](plans/worktree-next-claima-issue-deterministicamente.md); impl [`worktree-next-claima-issue-deterministicamente-impl.md`](plans/worktree-next-claima-issue-deterministicamente-impl.md).
**Recently resolved (2026-08-10):** **OPS35** (Issue #600) — modo de spec e2e **full-stack sem browser** (paradigma HTTP): mesma suíte (`pnpm test:e2e`), mesmo job/CI, mesmo servidor + DB reais, sem lançar navegador. Base nova `tests/e2e/fixtures/campaignHttpTest.ts` estende o `test` **cru** de `@playwright/test` (nunca `e2eTest.ts` — o guard `e2eFailureGuard` é `auto` e puxaria `page`/`context`), reusa o fixture `campaign` do browser (exportado como `campaignFixture` — ownership/cleanup in-process, zero caminho de seed paralelo) e adiciona o fixture `campaignRequest(user, senha)`: login pelo **REST real do Payload** (`POST /api/campaignUser/login`, `loginWithUsername` aceita email/username) → `request.newContext` com o cookie canônico `campaign-token` (path `/campanha`, flags espelhando `setCampaignAuthCookie`; `secure` pelo scheme do baseURL, não pelo NODE_ENV do runner). **Nomenclatura ajustada contra a letra da intenção** (descoberta de execução): `*.http.e2e.spec.ts` não casa o `testMatch` `/campaign.*\.e2e\.spec\.ts/` do projeto → `campaign<Family>Http.e2e.spec.ts` no mesmo projeto (dependência `setup` de prewarm já coberta). **Primeira família migrada: `campaignTerritories`** (4 specs `campaignTerritoriesHttp.e2e.spec.ts`, browser removido; manifesto `e2e-affected` atualizado — unit-pinned). Equivalências: clicks viravam GETs de URL (`?sort=votes2022` → caption "Ordenado por 2022 (maior primeiro)"; `?region=Irecê` → "1 território encontrado"); DOM virou HTML renderizado — com duas realidades de render registradas no header do spec: separadores RSC `<!-- -->` (asserções rodam sobre `rendered()`, comentários são transporte, não conteúdo) e o label "Cobertura" presente no flight payload (menu do picker é client component serializado mesmo fechado) — o pin "oculta por default" é a ausência do controle sortable, que `resolveVisibleColumns` não renderiza. RBAC leader: o redirect `noLeader` é `redirect()` **pós-stream** → o documento responde 200 com `<meta http-equiv="refresh" content="1;url=/campanha/contatos">` (Next `next-route-redirect`), não 3xx — o meta é o contrato assertado (dev 307/prod 308 quando o redirect vence o stream; nunca pinar status numérico). **Medição (dev server, worktree 3135, load ~68, prewarm incluído nas duas versões):** família browser 56,0 s (22,9/17,5/7,9/7,7) vs HTTP 33,7 s (10,8/8,4/2,4/12,1) — **−40%**; o ganho é estrutural (sem login de browser, sem hidratação, sem waitForURL), a carga da máquina domina a variância. No prod build local (`E2E_PROD=1`, o modo do CI) a família HTTP fecha **4/4 em 3,1 s** (1,4/0,9/0,5/0,3). Guard de falhas client permanece exclusivo do browser (aceite). Sem migration/access/Consent/UI/schema. Intenção [`e2e-full-stack-sem-browser.md`](plans/e2e-full-stack-sem-browser.md); impl [`e2e-full-stack-sem-browser-impl.md`](plans/e2e-full-stack-sem-browser-impl.md).
**Recently resolved (2026-08-10):** **OPS28** (Issue #584) — `worktree next`/`plan` passam a aplicar o **seed mínimo** além das migrations em cada banco provisionado (`migrate` + `db:seed:minimal` por banco, dev e test) — paridade com a CI, que roda o mesmo par antes do e2e: o e2e de worktree deixa de divergir do de CI (specs dependentes dos pins `priority: 'alta'` de `salvador-ze-1`/`camacari` — FAB suggest e `campaignSavedFilters` — falhavam só localmente com banco migrado cru 435×normal, o padrão que o ledger TECH-DEBT row 19 ficha como "masks real failures"). `--no-migrate` pula migrate **e** seed (o seed depende do catálogo migrado); re-provisionamento continua idempotente (upserts). Fallback sem Docker (Cursor Cloud) também roda migrate+seed nos `teqo`/`teqo_test` compartilhados (decisão de produto do gate: paridade total). **Guard de regressão** `tests/e2e/seedParity.setup.e2e.spec.ts` no projeto `setup` do Playwright: falha cedo com mensagem clara ("rode `pnpm migrate && pnpm db:seed:minimal`") se o banco de teste não estiver seedado — dependentes do setup pulam, nada de spec vermelho confuso; a evidência checada são os **users do seed** (`MINIMAL_CAMPAIGN_USERS`), deliberadamente não os pins `priority: 'alta'` — o cleanup dos fixtures e2e reseta `priority` dos municípios que toca (round-robin por sequência persistente), então pins morrem legitimamente após runs repetidas no mesmo banco de worktree e só o `pnpm worktree next` re-aplica (guard nunca checaria pins); `scripts/worktree.mjs` entrou em `HIGH_RISK_EXACT` (mudança no provisionador roda a suíte cheia na CI). Sem migration/access/Consent/UI. Intenção [`worktree-seed-minimal-paridade-e2e-ci.md`](plans/worktree-seed-minimal-paridade-e2e-ci.md); impl [`worktree-seed-minimal-paridade-e2e-ci-impl.md`](plans/worktree-seed-minimal-paridade-e2e-ci-impl.md).
**Recently resolved (2026-08-10):** **OPS34** (Issue #599) — o job `e2e` do CI (o mais longo da cascata, 435 s medidos no run 31412425553: ~67 s fixos + build 172 s + testes 196 s) roda agora **em 2 shards paralelos** (`--shard=N/2`, matrix `fail-fast: false`, build por shard — opção A da intenção validada pela medição: por shard ≈ 5–5,5 min; artefato de build compartilhado rejeitado porque os shards só começam após o build → wall maior). **Cadeia de projetos do Playwright (`setup → campaign → frontend → admin`) + prewarm do `setup` viraram dev-only:** `playwright.config.ts` ganhou `isProdMode = CI || E2E_PROD=1` (mesma condição do `webServer.command`, agora reusada) — em prod o projeto `setup` sai do array (arquivo não casado = não coletado) e as `dependencies` zeram (famílias em paralelo contra o build de produção, onde não há compile a preaquecer); dev intocado (provado: `--list` mostra `[setup]` só sem CI; smoke e2e dev rodou setup→campaignAuth). `ci-pr.yml`: o job `scope` emite `e2e_matrix`/`e2e_shards` via novo `e2eShardConfig(mode)` em `scripts/lib/test-affected-core.mjs` (fonte única, unit-pinada: full → `[1,2]`/2, selected/none → `[1]`/1 — pagar 2 builds por 3 specs é desperdício); `ci.yml` hardcodeia `[1,2]`/2 com comentário apontando o single-source. `PLAYWRIGHT_WORKERS: 2` explícito no env dos jobs e2e (aceite: explícito, default local intocado). Armadilha registrada nos workflows: flags do playwright vão **sem** o separador `--` — pnpm repassa o `--` literal ao playwright e a flag vira file pattern ("No tests found"). Specs intactas (o `setup` não foi removido — virou dev-only, já documentado como prewarm dev-only); sem migration/access/Consent/UI. **Medição pós-merge (main, run 31418206512):** job e2e 435 s → **326 s (shard 1) / 369 s (shard 2)** — shard 2 = fixo 68 s + build 176 s + testes 124 s (a fase de testes caiu de 196 s seriais para 124 s — metade da suite por runner + cadeia removida; o shard 2 ainda pagou retry da flake conhecida de `campaignMunicipalities`; shard 1: testes ~82 s); gate completo do pipeline (checks) ~7,5 → ~6,4 min. Build passou a dominar o shard — gatilho da intenção (opção B, artefato compartilhado) reavaliável quando os testes encolherem mais. Intenção [`ci-e2e-paralelizar-job.md`](plans/ci-e2e-paralelizar-job.md); impl [`ci-e2e-paralelizar-job-impl.md`](plans/ci-e2e-paralelizar-job-impl.md).
**Recently resolved (2026-08-10):** **OPS22** (Issue #562) — CI de `main` vermelho determinístico desde o merge do B188 (05:54): os 3 e2e da agenda mobile (`campaignAgendaMobile` :96/:242/:295) falhavam com todas as retries → deploy bloqueado. **Root cause (reproduzida no prod build local + error-contexts com o drawer aberto):** o login e2e roda em **viewport desktop** → o settle de B167 abre o chat (`open=true`) → o **efeito persist do B188** (status `ready` já nasce) grava `{open:true}` no `sessionStorage` → o spec muda para 390px na **mesma aba** → o **restore do B188** reabre o **drawer mobile sozinho** → o modal Radix **aria-hide + inert** a página e cobre o viewport (combobox fora da a11y tree no :242, swipe cai no drawer no :96, clique do seletor bloqueado no :295; teclado/:173 passam por não usarem hit-testing). **Defeito real de produto** (aba desktop → mobile na mesma aba faz o chat "saltar"), não só timing de teste. **Fix na origem:** `SollinhaChatSession` ganhou **`openBy: 'user' | 'settle'`** (opcional — sessão legada sem o campo = `'settle'`, fail-closed; sem bump de versão) e o provider agora distingue **intenção do usuário** (`setOpen`/`toggle` do context value marcam `userToggledOpenRef`; FAB/header/drawer já passam por eles) de **verdade de layout** (settle B167 usa o setter cru): o **restore espera o `measured`** (viewport real) e honra `open:true` em mobile **só quando `openBy==='user'`**; o persist grava `openBy` conforme a intenção. Todos os contratos B188/resize mantidos (reload restaura conversa; drawer aberto por usuário volta aberto; visita mobile nova fechada; crossing ao vivo B167 intocado). Testes: unit (round-trip/invalidez/legado de `openBy`) + **e2e pin OPS22** no spec B188 (login desktop → poll do persist `open:true` → viewport 390 → `/campanha/agenda` → sem dialog + botão do Sollinha visível); spec da agenda intacto (4/4 no prod build). Sem migration/access/Consent/schema. Intenção [`ci-e2e-agenda-mobile-regressao-b188.md`](plans/ci-e2e-agenda-mobile-regressao-b188.md); impl [`ci-e2e-agenda-mobile-regressao-b188-impl.md`](plans/ci-e2e-agenda-mobile-regressao-b188-impl.md).
**Recently resolved (2026-08-10):** **OPS31** (Issue #593) — `worktree plan` no terminal agora abre o TUI do opencode com `/plan-issue` **já enviado** (a revisão da proposição do opencode mostrou que a necessidade real do fluxo era auto-envio, não pre-fill: `--prompt` sempre auto-submete, e o `next` já provava com `/work-issue` — o `plan` só ficou sem prompt por uma premissa equivocada da OPS26). Mudança de valor no mapa existente: `OPENCODE_SKILL_COMMAND_BY_PURPOSE.plan: null → '/plan-issue'` (`scripts/lib/worktree.mjs`); `next` (`/work-issue`) e `new` (prompt-less, "apenas conversar") intocados; o mecanismo OPS26 já injetava `--prompt` quando o valor existia — zero código novo. Textos correlatos atualizados (docblock/uso do script, `worktree.sh`, AGENTS.md, skill `worktree-next-issue`); o tom do gap mudou: prefill-without-submit segue como feature request upstream (proposição `~/Code/propositions/opencode/prefill-prompt-sem-submit.md` anotada com o desfecho — edição local, fora do repo), não mais fallback do Teqo; entradas OPS26/OPS27 do changelog permanecem como histórico. Unit: teste "plan launches with `/plan-issue` sent" + pin de constantes `{ next: '/work-issue', plan: '/plan-issue', new: null }`. Sem migration/dados/access/UI. Intenção [`worktree-plan-abre-opencode-com-plan-issue.md`](plans/worktree-plan-abre-opencode-com-plan-issue.md); impl [`worktree-plan-abre-opencode-com-plan-issue-impl.md`](plans/worktree-plan-abre-opencode-com-plan-issue-impl.md).
**Recently resolved (2026-08-10):** **B193** (Issue #542) — card de município mobile denso + edit-where-you-see + última atualização expandível. O card de `/campanha/municipios` no mobile deixou o grid de labels e virou composição do wireframe: cabeçalho nome+território com **posição de voto 2022 à direita** (percentual em cima; colocação · votos embaixo), **barra de cenários** (pessimista·central·otimista + marcador do ativo — `VoteEstimateScenarioStrip` como trigger da sheet de votos existente), **chips** classe/tendência/nível (tendência e nível com label à esquerda, dentro do trigger — tocar no label abre a sheet; classe com razão via tooltip que abre no toque), **3 grupos de avatares com label** (Assessores/Lideranças/Dobradinhas — um avatar por entidade, wrap, sem cap; leitura de assessores para não-coordenador), rodapé **"Última atualização há X dias" + chevron que expande o limite inferior** revelando o último card de atualização (badges + corpo + autor · data, sem moldura) + CTA "Registrar atualização" (sheet existente); **sem atualização → o rodapé vira CTA direto** (sem chevron/expansão); prioritário = **borda lateral direita 6px** `border-primary` no lugar do flag; Salvador read-only; "Cobertura" (conta da cadeira) sai do card (fica na tabela/detalhe — gate humano); rodapé mede a idade da **atualização** (não o frescor update∨pledge — coluna desktop intacta). **C109 corrigida no caminho (a "sheet quebra ao abrir" era real):** os callbacks dos controles (autosave `onOpenChange`, `handleOpenChange` do nível) são recriados por render — qualquer re-render com a sheet aberta (digitar no sheet!) fazia o host descartar a sessão e o corpo sumia; sessões do host agora são **keyed por id de sessão** (imune a identidade) e o **footer customizado** (submit + `DialogClose`) passou a ser **registrado e renderizado pelo host dentro da árvore do Drawer** (portal perdia o Dialog context — o e2e antigo só abria a sheet de tendência, sem footer custom, por isso a C109 não reproduzia). Controles ganharam prop `trigger`/`triggerClassName` (precedente `RelationChipCell.trigger`); `MunicipalityRelationAvatarStack` ganhou modo wrap. Dado: `MunicipalityListViewModel.lastUpdate` (batch por página via `loadMunicipalityLastUpdates`, scoped por portfólio). E2E novos (390×844): barra abre sheet e reflete valores salvos, chip Nível abre a sheet, borda de prioridade (6px + cor), expansão/CTA vazio. Sem migration/access/Consent. Intenção [`municipios-card-mobile-edit-where-you-see.md`](plans/municipios-card-mobile-edit-where-you-see.md); impl [`municipios-card-mobile-edit-where-you-see-impl.md`](plans/municipios-card-mobile-edit-where-you-see-impl.md).
**Recently resolved (2026-08-10):** **OPS26** (Issue #571) — `worktree next`/`plan` **no terminal** terminam com o TUI do opencode aberto no worktree: a função `worktree()` de `.agents/shell/worktree.sh` marca `TEQO_WORKTREE_TERMINAL=1` e o script imprime a diretiva `launch opencode <dir> --model deepseek/deepseek-v4-flash --auto [--prompt /work-issue]` **antes** do `cd` (que segue sendo a última linha — contrato OPS24 intacto); a função aplica o cd e executa a linha (sem `exec`: sair do TUI volta ao shell no worktree; sem auto-`kill`). `next` envia `/work-issue` (o comando OPS25 roda o ciclo completo); `plan` abre **sem prompt** — a CLI do opencode não tem pre-fill sem submit (`--prompt` sempre auto-envia; fallback acordado: `/plan-` + autocomplete; gap registrado para o repo do opencode). Presets são constantes em `scripts/lib/worktree.mjs` (`OPENCODE_PRESET_MODEL`, `OPENCODE_SKILL_COMMAND_BY_PURPOSE`, `opencodeLaunchDirective` — pura, unit-testada; trocar de modelo é editar a constante). **Sem o marcador (comando `/worktree` do opencode, automação) a diretiva não é impressa** — nunca abre TUI aninhado; `--stay` suprime cd e launch; `kill` intocado. Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue`. Sem migration/dados/access/UI. Intenção [`worktree-go-abre-opencode-com-presets.md`](plans/worktree-go-abre-opencode-com-presets.md); impl [`worktree-go-abre-opencode-com-presets-impl.md`](plans/worktree-go-abre-opencode-com-presets-impl.md).
**Recently resolved (2026-08-10):** **OPS24** (Issue #569) — `--go` virou o **padrão** do worktree CLI e `kill` volta ao main: `worktree next`/`plan` imprimem `cd <dir>` na última linha **por padrão** (antes exigiam `--go`); `--stay` suprime; `--go` explícito continua aceito como no-op (não quebra hábito/automação). `kill` troca a mensagem "Volte ao main: cd …" (que nunca casava com o sed `s/^cd //p` da função shell) pela **linha máquina `cd <main>` como última linha** — depois do drop dos DBs, que rodava antes — e **não aceita `--stay`** (ficar em cwd destruído é exatamente o bug corrigido; `--go` no kill é no-op). Vale nas duas superfícies que aplicam o cd: função `worktree()` de `.agents/shell/worktree.sh` (agora sempre captura/aplica, `--stay` retorna cedo) e comando `/worktree` do opencode (agente aplica o `cd` da última linha sempre, exceto `--stay`). Docs: header/uso do script, AGENTS.md ("Per-worktree environments"), skills `worktree-next-issue`/`local-database`. Sem migration/dados/access; derivação pura (`worktree-env.mjs`/`worktree.mjs` libs) intocada. Intenção [`worktree-go-default-e-kill-volta-ao-main.md`](plans/worktree-go-default-e-kill-volta-ao-main.md); impl [`worktree-go-default-e-kill-volta-ao-main-impl.md`](plans/worktree-go-default-e-kill-volta-ao-main-impl.md).
**Recently resolved (2026-08-10):** **OPS25** (Issue #570) — atalhos diretos no TUI do opencode: `.opencode/commands/work-issue.md` e `plan-issue.md` (mecanismo nativo de commands, mesmo padrão de `issue.md`/`worktree.md`). O frontmatter leva `description` pt-BR (autocomplete do TUI) + `model: deepseek/deepseek-v4-flash` (pin do modelo de preferência, alinhado ao launch da OPS26 com `--model`); o corpo instrui carregar a skill de **mesmo nome** pela ferramenta de skills e segui-la ponta a ponta, apontando a fonte canônica `.agents/skills/<nome>/SKILL.md` — **sem transcrever** (anti-goal da intenção) — com `$ARGUMENTS` repassado no fim. **Guard de acoplamento** `tests/unit/opencodeCommands.unit.spec.ts`: comando↔skill presos por nome exato (renome de skill → build falha em vez de comando quebrado silencioso). Sem migration/access/Consent/UI. Intenção [`opencode-comandos-work-issue-e-plan-issue.md`](plans/opencode-comandos-work-issue-e-plan-issue.md); impl [`opencode-comandos-work-issue-e-plan-issue-impl.md`](plans/opencode-comandos-work-issue-e-plan-issue-impl.md).
**Recently resolved (2026-08-10):** **B192** (Issue #533) — o Sollinha encerra respostas factuais com **follow-ups sugeridos como chips** (2–3, no mesmo slot dos chips de abertura do B191, acima do input; tocar envia como mensagem do usuário; substituídos a cada resposta; somem durante o streaming; mobile limita a 2). **Uma única chamada ao modelo:** o prompt (`systemPrompt.ts`) instrui encerrar com o bloco estável `**Sugestões de continuação:**` + lista markdown (só perguntas respondíveis pelas próprias tools, leader sem sugestão eleitoral — B180; bloco = instrução de interface, nunca citar em prosa) e o cliente extrai. **Contrato em fonte única:** `src/lib/sollinhaFollowUpSuggestions.ts` — `SOLLINHA_FOLLOW_UP_MARKER` (interpolado no prompt) + `splitSollinhaFollowUpBlock` (última ocorrência vence; body sempre mascarado do marcador em diante; itens `-/*/1./1)` aceitos, bold e duplicados limpos, teto 3; **fail-closed: <2 itens ou linha não-lista → sem chips**, resposta normal). UI: strip no render de todo text part assistant **antes** do markdown/links (bloco nunca pisca como prosa; mensagens antigas restauradas via B188 continuam mascaradas e os chips renascem por derivação); slot B191 estendido (catálogo vazio → follow-ups); tipo compartilhado renomeado `SollinhaOpeningQuestion → SollinhaChatChip`. **Strip defensivo no servidor:** `route.ts` remove o bloco das mensagens assistant ao reconverter para o modelo (próximo turno sem eco nem custo de contexto). Unit (16 casos do split) + e2e novo `campaignAiChatFollowUps.e2e.spec.ts` (wire format v7 como o mock do B187; bolha sem o bloco, chips, substituição, fail-closed, cap mobile). Sem migration/access/Consent/schema/endpoint novo. Intenção [`sollinha-follow-ups-respostas.md`](plans/sollinha-follow-ups-respostas.md); impl [`sollinha-follow-ups-respostas-impl.md`](plans/sollinha-follow-ups-respostas-impl.md).
**Recently resolved (2026-08-10):** **OPS23** (Issue #566) — a Action OPS18 (`plan-issue-ready-on-main-merge.yml`) falhava **sempre** no primeiro disparo real (PR #563, run 31381977772): o job roda `actions/setup-node@v5` sem `pnpm/action-setup` e o script é plain Node — com `package-manager-cache: true` (default do v5), o setup-node resolve o `packageManager` do package.json (`pnpm@10.11.0`) e procura o executável pnpm → `Unable to locate executable file: pnpm`, promote nunca rodava. Correção: `package-manager-cache: false` no setup-node (precedente exato dos jobs plain-Node: cooldown do `ci.yml`, `vercel-promote.yml`, `archive-cursor-agent.yml`; `pnpm/action-setup@v6` rejeitado — só existe onde há `pnpm install`). Varredura dos 21 usos de setup-node: `plan-issue-ready` era o único com o bug latente. Validação funcional = próximo merge de PR com `Related #N` (trigger `closed+merged→main` só dispara em merge real). Impl [`ops23-action-promote-related-setup-node-impl.md`](plans/ops23-action-promote-related-setup-node-impl.md).
**Recently resolved (2026-08-10):** **B189** (Issue #526) — o Sollinha ganhou a tool **`getMunicipalitiesWithoutUpdate`** (`src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts`): "quais municípios estão sem atualização há mais de X dias" (cobertura do acompanhamento) no escopo do usuário, com **contagem total exaustiva** (sem top N — cobertura é "tudo além do limiar"), **limiar declarado na resposta** (`limiarDias` + `criterio` interpolado; default 30, ajustável por pergunta via `days` 1–365, estritamente maior), lista ordenada do mais velho ao mais recente com "nunca atualizado" no topo (estagnação máxima, sempre incluídos), itens com `diasSemAtualizacao`/`cidade`/`regiao`/`tipo` (ZE vs município inteiro)/**assessores** (nomes em query própria `campaignUser` — `name` não é identity-gated; nunca `depth`). **Fonte de recência = `municipality.lastUpdateAt`** (campo derivado pelos hooks de `municipalityUpdate`, admin read-only — a semântica compartilhada da família B185 "última atualização de campo", nunca `updatedAt` do município; sanity local provou create→coluna→delete→null via hooks); filtro de recência em JS com `municipalitySignalAgeInDays` (helper E9 reusado — coerência rótulo↔limiar sem boundary de instante no `where`). **Gate staff inline** (leader → `{ error: 'Leitura de municípios negada.' }` antes de qualquer query, shape da B180); leituras `overrideAccess: false, user: ctx.user` (assessor = portfólio automático); **guard fail-closed de escopo vazio** (assessor fora do portfólio → shape vazio escopado sem leitura). **Resolver de escopo extraído** da B185 para `src/utilities/ai/tools/aiToolScope.ts` (`resolveAIToolScope` — Salvador primeiro, município por alias, território acento-tolerante; 2º call site da família; nomes de tipos alinhados à extração em voo da B186 para o merge dela vir trivial; contrato da B185 inalterado, spec existente cobre a regressão) e **loader de nomes compartilhado** `aiToolQueries.ts` (fundiu `loadNamesByIds` da B185 com o lookup local — don't twin). Seção nova no system prompt: declarar sempre limiar+critério, resumir Salvador ("7 das 19 zonas sem atualização há mais de 30 dias") e agrupar por assessor quando pedido (mesmo precedente "o modelo agrupa pela lista" da B185). Unit tests novos (`municipalitiesWithoutUpdateTool.unit.spec.ts`, 15): gate fail-closed com payload intocado, critério + borda estrita (31→incluído/30→excluído/`days:15`), ordem (nunca por nome, depois mais velho→recente), escopos acento/Salvador/município/erro, ids do escopo no where, escopo vazio fail-closed, nomes de assessores, tipo ZE. Sem migration/access/Consent/UI. Intenção [`sollinha-municipios-sem-atualizacao-recente.md`](plans/sollinha-municipios-sem-atualizacao-recente.md); impl [`sollinha-municipios-sem-atualizacao-recente-impl.md`](plans/sollinha-municipios-sem-atualizacao-recente-impl.md).
**Recently resolved (2026-08-10):** **OPS21** (Issue #538) — `REVALIDATE_SECRET` adicionada na **Vercel production** (projeto `solla/jorgesolla`): diagnóstico D6 confirmado (env ausente em `vercel env ls production`; `POST /api/revalidate` respondia `500` "not configured" em prod, quebrando o runbook pós-seed/mudança direta de DB). Segredo gerado com `openssl rand -hex 32` (valor via stdin, nunca argv), `vercel env add REVALIDATE_SECRET production`; **fallback necessário:** o deploy existente congelava as envs em runtime → `vercel redeploy` do último deploy de produção (2×, pois o primeiro `env add` via pipe herdou o `\n` do `openssl` e gravou valor de 66 chars — removido e re-adicionado limpo). Validação real em prod: sem secret → `401`; secret errado → `401`; secret certo → `200 { revalidated: true, tag: 'posts' }`; tag desconhecida → `400`. Preview não adicionada (projeto sem Git conectado — `vercel env add ... preview` exige branch, precedente D6). Sem código/migration/access/UI. Impl [`revalidate-secret-vercel-production-impl.md`](plans/revalidate-secret-vercel-production-impl.md).
**Recently resolved (2026-08-10):** **C108** (Issue #522) — sino de notificações: **abrir já lê** — `openCampaignNotifications` (action única) lista + marca tudo como lido em 1 round trip, substituindo `listCampaignNotifications` + `markAllCampaignNotificationsRead`; o badge zera na abertura sem clique e o botão "Marcar todas como lidas" deixa de existir. Painel limpo em qualquer tamanho: sem título "Notificações" visível (sr-only para a11y), sem contador, sem botão "Fechar"; desktop vira **modal central** (Dialog shadcn, X no canto superior direito, clique fora fecha — par responsivo Dialog/Drawer do C94 via `useIsMobile`); mobile segue bottom sheet com handle, safe-area e **skeleton de loading** (o painel não abre mais em branco durante o round trip). `markAllNotificationsRead` extraída para `src/utilities/notification/notificationList.ts` (owner-scoped com bypass documentado, idempotente, cap 200 mantido; int testado: marca só as próprias, deixa as do outro intactas); `readAt` saiu do contrato `NotificationListItem` (sem leitores após a remoção do destaque de não lida); copy de erro unificada em `CAMPAIGN_NOTIFICATION_LOAD_ERROR_MESSAGE`; `router.refresh()` re-sincroniza a instância irmã do outro header só quando algo foi marcado (race cross-tab documentado); foco visível nos itens e `role="alert"` no erro. P4-D atualizado no ledger — o batch sem transação agora dispara a cada abertura (self-healing). Testes: int (owner-scope + idempotência) e unit novos do bell (dialog limpo, refresh condicional, empty/error, sheet mobile). Sem migration, access, Consent ou schema. Intenção [`sino-notificacoes-leitura-auto-modal-desktop.md`](plans/sino-notificacoes-leitura-auto-modal-desktop.md); impl [`sino-notificacoes-leitura-auto-modal-desktop-impl.md`](plans/sino-notificacoes-leitura-auto-modal-desktop-impl.md).
**Recently resolved (2026-08-10):** **B186** (Issue #525) — o Sollinha ganhou a tool **`getMunicipalityPriorities`** (`src/utilities/ai/tools/getMunicipalityPriorities.ts`): responde "quais devem ser minhas prioridades neste momento?" com **top N municípios do escopo** (coordenador = estado; assessor = portfólio; recorte por território/cidade/município), cada item com **uma linha de evidência** e o **critério de ordenação declarado na resposta** (criterio + janelaDias). **Ranking derivado na hora, 3 baldes mutuamente exclusivos** por gravidade — (1) `sinal_desfavoravel`: última atualização na janela com `ruim`/urgente/alerta de adversário (trecho do body na evidência; "última palavra decide"); (2) `estagnacao`: sem sinal há 30+ dias (default, ajustável 7–90) ou nunca; (3) `potencial`: nível N0/N1/sem nível + **top 5 por potencial do escopo** (`expectedVotes.central` 2026 com fallback nos válidos 2022 do artefato commitado, fonte rotulada na evidência). **Exclusão literal do aceite:** atualização recente `boa` e sem sinais negativos tira o município de todos os baldes; `municipality.priority: 'alta'` é fator (menção "prioritário" + tiebreak), nunca o ranking inteiro; tendência política desfavorável entra como cláusula secundária. **Frescor = E9** (`max(lastUpdateAt, lastPledgeAt)`, `municipalitySignal.ts` — divergência da hipótese B185 de só-`municipalityUpdate`, aprovada no gate: a intenção lista recência de `votePledge` como fonte). Refinamentos: `reason` filtra por balde ("só as sem atualização"), `sortBy: 'potencial'` reordena, `days`/`limit` ajustam. **Arquitetura:** ranking em módulo **puro e client-safe** (`src/utilities/municipality/municipalityPriorities.ts`, 28 unit tests sem DB) + resolver de escopo da B185 **extraído** para `src/utilities/ai/tools/aiToolScope.ts` (2º consumidor; `getPendingLeaderships` refatorado — 18 testes verdes, sem mudança de comportamento); gate staff inline (leader → `{ error: 'Leitura de prioridades de municípios negada.' }` antes de qualquer query), leituras `overrideAccess: false, user: ctx.user`, escopo vazio (assessor fora do portfólio) short-circuita antes da leitura. Seção nova no system prompt; allowlist P3-D estendida (mesma forma distinta da B185). Tool tests novos (`prioritiesTool.unit.spec.ts`, 10) — gate fail-closed, where da janela, escopo região, fallback 2022 (slugs reais do artefato), truncamento. Sem migration/access change/Consent/UI. Intenção [`sollinha-prioridades-do-momento.md`](plans/sollinha-prioridades-do-momento.md); impl [`sollinha-prioridades-do-momento-impl.md`](plans/sollinha-prioridades-do-momento-impl.md).
**Recently resolved (2026-08-10):** **B184** (Issue #514) — no mobile (<768px), o filtro de listas `/campanha` virou o **padrão sem moldura do chassis `CampaignListOmnibox` (B127) — todas as listas**: label oculta (`input` carrega o nome via `aria-label`), campo sem borda/sombra (`md:` restaura o desktop), **form do filtro sticky sob a top bar** com linha separadora edge-to-edge, "Limpar" vira **X circular dentro do input** (aparece com chips OU busca digitada; slot sempre renderizado com `invisible` para a largura não pular; hit target 44px + círculo visual 28px; live region própria "Busca e filtros limpos." — limpar só busca não navega e o pending region ficaria mudo para AT), botão texto "Limpar" desktop-only. **Por que sticky no `<form>`:** sticky não escapa do containing block (o pai) — a coluna do input dentro do wrapper curto do omnibox "grudava" ~12px e rolava junto (medido); o form é o único wrapper filho do `CampaignPageShell` (altura total). **Exceção agenda (C101, merge anterior):** a `/campanha/agenda` já tinha o strip sticky próprio (`activity-agenda-filter-strip`) — mantido; o chassis ainda a cobre no campo/X/Limpar. Em municípios, adicionalmente: **"Salvar filtro" vira icon button no header mobile** (slot C94/C95, `SaveMunicipalityFilterControl presentation='icon'`; popover de nomear intacto; nome de rename é o contrato 2.5.3) e **cards edge-to-edge sem borda** com uma linha separando (`border-b last:border-b-0`, `-mx-4`, gate `md:`). "Nova atualização" (atualizações) — único trailing visível no mobile — foi para o header (mesmo padrão); `campaignMobileHeaderIconClassName` extraída (3º consumidor). E2E novo em `campaignMunicipalities.e2e.spec.ts` (390×844): label oculta, borda 0, sticky, X (limpa chips+busca, foco volta sem reabrir popover), salvar→renomear no header, cards com separador, desktop invertido (1280). Débito registrado no caminho: **D7** (sticky dos headers da tabela desktop morto — wrapper `overflow-x-auto` é o scroll container; medido). Sem migration, access, Consent. Intenção [`municipios-mobile-sem-moldura.md`](plans/municipios-mobile-sem-moldura.md); impl [`municipios-mobile-sem-moldura-impl.md`](plans/municipios-mobile-sem-moldura-impl.md).
**Recently resolved (2026-08-09):** **C103** (Issue #504) — o formulário de criação rápida da agenda ficou **usável de ponta a ponta no celular**: no narrow (mesmo breakpoint 640px do calendário), o sheet de criação abre **do topo** (`swipeDirection="up"` na primitiva `Drawer` — que já suportava top sheet; zero mudança em `Drawer.tsx`) preenchendo `100dvh-1rem`, o conteúdo **rola** num scroll container e o rodapé ("Mais detalhes" + "Salvar") é **fixo** — verificado em smoke com teclado aberto (viewport 280px: sheet encolhe via `dvh`, conteúdo rola 187/187px e o Salvar permanece visível). O form ganhou `variant: 'popover' | 'sheet'`: no sheet, **labels viram `sr-only`** (a11y e `getByLabelText` intactos), placeholders dão contexto ("Adicionar título _", "Município _", "Local (opcional)", "Adicionar responsáveis"), obrigatórios marcados com asterisco (título/município no placeholder, Início no trigger), campos empilhados **sem gaps com `divide-y`** e inputs/triggers sem borda (lista iOS do canvas). O **seletor de data/hora abre como bottom sheet aninhado** no mobile (`ActivityDateTimeField` ganhou `isNarrow`/`label`/`required`: conteúdo compartilhado Calendar+selects renderizado em `Drawer` com header "Início — <data>" + "Pronto"; desktop continua popover). Props backward-compatible: `placeholder`/`className` no `StrictCombobox`, `labelClassName`/`emptyText`/`triggerClassName` no `ResponsibleMultiSelect`. Decisão do smoke: swipe handle **removido** do top sheet (primitiva o posiciona no rodapé, contradizendo o gesto de swipe-up; continua nos bottom sheets). E2E mobile novo (viewport 390×844): sheet do topo (`y ≤ 1`), Salvar visível sem scroll, picker em bottom sheet com Pronto, evento aparece após salvar; unit narrow cobre placeholders/asterisco/Pronto. Desktop visualmente inalterado (B181 segue na fila para o clip do popover ≤720px). Sem migration/access/Consent/schema. Intenção [`agenda-mobile-form-criacao-usavel.md`](plans/agenda-mobile-form-criacao-usavel.md); impl [`agenda-mobile-form-criacao-usavel-impl.md`](plans/agenda-mobile-form-criacao-usavel-impl.md).
**Recently resolved (2026-08-09):** **D6** (Issue #521) — push no device fechado de ponta a ponta. **Diagnóstico com evidência por elo** (leitura read-only de prod): (1) **envs VAPID ausentes no Vercel production** (verificado com `vercel env ls production`) — elo raiz: sem elas o servidor nunca chama o web-push e o card de perfil desabilita "Ativar"; (2) consentimento `campanha-notificacoes-push` já publicado (Onda 0, 2026-07-19); (3) 0 inscrições `pushSubscription` (bloqueado pelo elo 1); (4) 74 `Notification` geradas no sino (eventos fluem) mas **zero envios**; (5) banner no OS = passo humano (Android). **Dois defeitos de código corrigidos com evidência:** (a) o SW `/campanha/sw.js` servia `CACHE_NAME "campanha-dev"` em prod — deploys são CLI `vercel build --prebuilt` (git builds ignorados), e a rota `force-static` congelava o buildId no build onde `VERCEL_GIT_COMMIT_SHA`/`VERCEL_DEPLOYMENT_ID` não existem; a rota virou **`force-dynamic`** (lê `VERCEL_DEPLOYMENT_ID`, garantido em runtime) → cache versionado por deploy (unit test pina `dynamic`); (b) **subscribe não idempotente** — segundo toque em "Ativar neste dispositivo" no mesmo device devolvia a mesma subscription do browser e estourava o UNIQUE do `endpoint` (erro falso com o device já ativo); `subscribeCampaignPush` agora faz find-before-create e responde sucesso. **Ferramenta nova:** `pnpm db:check:push-chain` (`scripts/check-push-chain.mjs`, read-only — `PROD_DATABASE_URL` obrigatória, conexões com `default_transaction_read_only`) fecha um veredito por elo (SW servido/versionado, envs, consentimento, inscrições por usuário, log de eventos) com exit code. **Ops:** par VAPID gerado e adicionado em **Production** (`VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY`/`VAPID_SUBJECT`/`NEXT_PUBLIC_VAPID_PUBLIC_KEY`); preview ficou de fora — o projeto não tem Git repo conectado (deploys CLI) e `vercel env add ... preview` exige branch (`branch_not_found`); `NEXT_PUBLIC_*` precisa do build do próximo deploy para inliner. **Pendência humana:** teste em device real (Android Chrome) após o deploy — ativar em `/campanha/perfil`, disparar evento, conferir banner + toque; `db:check:push-chain` antes/depois. Sem migration, access ou Consent (fail-closed intacto). Intenção [`push-notificacoes-dispositivo-producao.md`](plans/push-notificacoes-dispositivo-producao.md); impl [`push-notificacoes-dispositivo-producao-impl.md`](plans/push-notificacoes-dispositivo-producao-impl.md).
**Recently resolved (2026-08-10):** **C106** (Issue #517) — feed de atualizações `/campanha/atualizacoes` sem moldura no mobile: **cards edge-to-edge** (`-mx-4` no ul) sem moldura/arredondamento com uma linha separadora (`border-b last:border-b-0` — padrão exato dos cards do B184) + **empty state com copy única** "Ajuste o filtro ou registre um novo fato de campo." (descreve a ação, vale nas duas resoluções; o texto antigo citava o botão "+ Nova atualização"). O filtro sticky/sem label/moldura e o icon "Nova atualização" no header **foram entregues pelo irmão B184/C107 em `main`** (`campaignListOmniboxFormClassName` + chassis borderless < md + `campaignMobileHeaderIconClassName`, id `campaign-updates-new`) — no rebase, a versão do irmão venceu o conflito em `CampaignUpdatesFilters.tsx` (edit the owner, não twin; a hipótese original de CSS de página no padrão C101 foi descartada na execução). **Bug pré-existente corrigido no caminho:** o `@layer base` editorial aplica `my-6 ml-6 list-disc [&>li]:mt-2` a todo `ul` — o feed era a única lista campaign em `<ul>` (indent de 24px + 8px por li; precedente do patch: `[data-theme='campaign'] p { margin-top: 0 }`); a lista neutraliza na própria classe. **Achado registrado:** páginas dinâmicas (searchParams awaitado) têm um nó `#S:0` hidden **transitório** durante o streaming (cópia da página; some em ~1–5s; só no build de produção) — os e2e aguardam o stream assentar (`settleStream`). E2E novo `tests/e2e/campaignUpdatesMobile.e2e.spec.ts` (mobile: label oculta, form sticky + linha, icon abre o bottom sheet, cards edge-to-edge; empty state; regressão desktop). Sem migration/access/Consent. Intenção [`atualizacoes-mobile-sem-moldura.md`](plans/atualizacoes-mobile-sem-moldura.md); impl [`atualizacoes-mobile-sem-moldura-impl.md`](plans/atualizacoes-mobile-sem-moldura-impl.md).
**Recently resolved (2026-08-09):** **B183** (Issue #501) — o auto-zoom do Safari iOS em inputs do PWA `/campanha` (font < 16px ao focar, escala que não volta no standalone) foi eliminado pela **causa**: uma regra CSS única em `styles.css` — `@media (pointer: coarse) and (hover: none)` + `[data-theme='campaign'] :is(input, select, textarea, [contenteditable='true']) { font-size: 1rem }` — cobrindo omnibox B127, Command search, native-select e textareas em qualquer orientação do iPhone (landscape chega a 932px de largura, o que descartou escopo por `max-width`). Unlayered vence as utilities `text-sm` sem `!important`; desktop (pointer fine) fica intocado; `Input`/`Textarea` do ui já eram `text-base md:text-sm`. Sem migration, access, Consent ou mudança de componente. E2E novo `tests/e2e/campaignIosInputZoom.e2e.spec.ts` pina o contrato: emulação `devices['iPhone 13']` → omnibox de `/campanha/municipios` computa **16px**; control desktop → permanece **14px**. Intenção [`pwa-ios-autozoom-input-foco.md`](plans/pwa-ios-autozoom-input-foco.md); impl [`pwa-ios-autozoom-input-foco-impl.md`](plans/pwa-ios-autozoom-input-foco-impl.md).
**Recently resolved (2026-08-10):** **B185** (Issue #524) — o Sollinha ganhou a tool **`getPendingLeaderships`** (`src/utilities/ai/tools/getPendingLeaderships.ts`): "quais lideranças ainda precisam ser abordadas" por **território de identidade** (grafia tolerante a acento — "Vale do Jiquiriça" resolve para a região canônica), **cidade** ("Salvador" = as 19 ZE juntas) ou **município** (aliases canônicos), com o **critério de pendência sempre declarado na resposta** (`'A abordar'/'Em disputa', ou 'Engajado' sem compromisso de votos no escopo consultado` — eixo do compromisso = presença de `votePledge` liderança×município no escopo; `negativo` é excluído na query, fail-closed), lista com nome/municípios/status/última atualização/**assessores responsáveis** (nomes resolvidos em query própria — nunca `depth` populando `campaignUser`), top N (default 20, max 50) com `total` e `truncado`, escopo omitido = escopo de acesso do usuário. Filtro **C3** `sem_assessor` (`advisors: { exists: false }`, precedente B176) e modo **C4** `municipios_sem_lideranca` (municípios do escopo sem nenhuma liderança). **Gate staff inline** no topo do `execute` (leader → `{ error: 'Leitura de dados de lideranças negada.' }` antes de qualquer query, mesmo shape da B180; 1 call site — sem helper); leituras todas `overrideAccess: false, user: ctx.user` (RBAC atual). Links via `buildCampaignLinks` existente (nada mudou lá); seção nova no system prompt manda declarar o critério. Unit tests novos (`pendingLeadershipsTool.unit.spec.ts`, 14) — gate fail-closed com payload intocado, critério por status+pledge, pledge fora do escopo não satisfaz (where `municipality in`), escopos com acento/Salvador/município/erro, `sem_assessor` no where, C4, truncamento, nomes de assessores; allowlist na convenção P3-D justificada (filtro de domínio sobre set resolvido ≠ escopo de actor). Sem migration, access change ou Consent. Intenção [`sollinha-liderancas-pendentes-abordagem.md`](plans/sollinha-liderancas-pendentes-abordagem.md); impl [`sollinha-liderancas-pendentes-abordagem-impl.md`](plans/sollinha-liderancas-pendentes-abordagem-impl.md).
**Recently resolved (2026-08-09):** **C104** (Issue #505) — compromissos **"Todo o dia"** na agenda: o sheet de criação rápida (C91) e o formulário completo ganharam o toggle **Todo o dia** (checkbox; no sheet entre o título e Início/Término, no form no topo do card "Data e horário") — ligado, Início e Término viram **só datas** (término pode ser outro dia = faixa de vários dias inteiros; dia único = `startAt === endAt`). Representação: campo novo `allDay` (migration `20260810_010844_add_activity_all_day`) + convenção de instantes **fim inclusivo** (startAt = 1º dia 00:00 America/Bahia, endAt = último dia 00:00; Bahia sem DST); conversões exclusivo↔inclusivo (FullCalendar/iCal usam fim exclusivo) concentradas no módulo puro `lib/activityAllDay.ts` (unit-pinado: dia único, multi-dia, virada de mês/ano, rótulo "10/08/2026 a 12/08/2026"). Calendário: `allDaySlot` ligado (faixa no topo de semana/dia), `toEventInput` com `allDay`, reschedule converte os `startStr`/`endStr` exclusivos e envia `allDay`; **guard de drop**: arrastar entre a lane all-day e a grade de horário é rejeitado (revert + toast — converter por drag é feature fora do escopo). "Mais detalhes" preserva a escolha (`allDay=1` + datas civis nos params; form nasce com toggle ligado). Rótulos all-day em card, overview, dossiê do município, cabeçalho de detalhe e home search; feed iCal exporta `DTSTART;VALUE=DATE`/`DTEND;VALUE=DATE` com fim exclusivo. Liderança não consegue flipar `allDay` (snapshot staff). Sem access/Consent. Intenção [`agenda-toggle-todo-o-dia.md`](plans/agenda-toggle-todo-o-dia.md); impl [`agenda-toggle-todo-o-dia-impl.md`](plans/agenda-toggle-todo-o-dia-impl.md).
**Recently resolved (2026-08-10):** **C100** (Issue #495) — nova rota staff **`/campanha/pessoas`**: lista unificada de pessoas da campanha (lideranças, dobradinhas e staff) com **merge por `Contact`** — uma linha por pessoa física, capacidades como colunas (Nome com partido da dobradinha, Contato, Assessora, Lidera, Aliada em, Assessorado, Base, Ações). Merge em memória de 3 fontes com o where/access existente de cada domínio (`src/utilities/people/peopleData.ts` — fontes leadership/dobradinha/staff; staff source com `overrideAccess` justificado: campo `contact` identity-gated; escopo do assessor = linha com ≥1 município de capacidade na carteira; **todo staff com ficha entra, mesmo sem carteira** — gate humano 2026-08-09). Filtros na omnibox (capacidade multi OR, município, apoio, q) com URL canônica própria (`peopleListUrl.ts`, sem sort v1); **filtros salvos = 2º call site do B18** via factory compartilhada `createSavedFilterStore` (`municipalitySavedFilters` refatorado como delegado, API + storage key + eventos idênticos, suite unit existente guarda) + submenu no sidebar e no drawer "Mais". **Apagar pessoa** (`src/utilities/people/personDelete.ts` + `actions/person.ts`): manifest read-only → diálogo lista tudo (liderança c/ municípios, dobradinha c/ partido, votos, convites, apoiadores, atualizações de município, feeds de agenda, contas); cascata transacional com advisory lock `person-delete:<contact>` — ordem por FKs NOT NULL reais (pledges → invites → supporters → leadership → dobradinha → contas (hooks de passkeys/notificações) → ficha deletada ou anonimizada se signature/subscription a referenciarem); contas coordenação/candidato bloqueiam a pessoa inteira; advisor joins cascadeiam no delete da conta. UI: omnibox `edgeToEdge` (novo variant no chassis B127 — label sr-only, sem bordas, Limpar = círculo-X interno), cards mobile sem bordas com divisórias, "Salvar filtro" como icon button no header via `SetCampaignHeaderAction` (slot C94), colunas com column picker B17 (novo `CampaignListId 'pessoas'`), FABs globais intactos. Mobile: convite reusa `LeadershipInviteRowAction`; WhatsApp `whatsAppHrefForPhone`. Sem migration. Testes: unit (URL contract, merge/escopo/facets, storage factory), int (escopo assessor/unrestricted/leader, cascata completa + anonimização + conta protegida), e2e novo `campaignPeople` (smoke staff + leader redirect; pegou e corrigiu um `div` dentro de `p` no card mobile — erro de hidratação). Intenção [`pessoas-lista-unificada.md`](plans/pessoas-lista-unificada.md); impl [`pessoas-lista-unificada-impl.md`](plans/pessoas-lista-unificada-impl.md).
**Recently resolved (2026-08-09):** **C102** (Issue #498) — no mobile (<768px), nenhuma tela `/campanha` de staff mostra mais hamburger nem sheet de sidebar: a navegação staff mora na barra inferior + drawer "Mais". `CampaignMobileTopBar` esconde o `SidebarTrigger` via `useCampaignPageChromeRole` + `isStaffCampaignRole` (leader mantém — lockdown sem barra inferior); `CampaignSidebar` retorna `null` quando `isMobile && staff`, desmontando o Sheet; **B18 (filtros salvos de Municípios) migrou para o drawer "Mais"** — `MunicipalityNavSavedFilters` ganhou `variant: 'overflow'` (mesma store `useMunicipalitySavedFilters`, mesma lógica apagar/desfazer/sucessão de foco; no drawer o fallback de foco vira o link Perfil do footer) e `CampaignBottomNav` passou a renderizá-la entre destinos e footer (container com `overflow-y-auto`). **Bug pré-existente corrigido no caminho (D4):** o `<Toaster>` vivia dentro do wrapper `[contain:layout_paint]` do painel da AI — `contain: paint` vira containing block para `position: fixed` e cria stacking context próprio, prendendo o toast (z 999999999 do sonner) ABAIXO dos portais z-50 de drawer/sheet: qualquer toast com ação (ex. "Desfazer") era inalcançável com modal aberto; o Toaster foi movido para o root layout `(campaign)/layout.tsx` (irmão do `{children}`, direto no `<body>`). Testes: 2 unit novos na top bar (staff sem trigger / leader com trigger), 3 e2e novos (staff mobile sem trigger e sem dialog Sidebar; leader abre o sheet com "Meus contatos"; fluxo completo salvar→navegar→apagar→desfazer no drawer). Sem migration, access ou Consent. Intenção [`c102-mobile-sem-botao-sidebar.md`](plans/c102-mobile-sem-botao-sidebar.md); impl [`c102-mobile-sem-botao-sidebar-impl.md`](plans/c102-mobile-sem-botao-sidebar-impl.md).
**Recently resolved (2026-08-09):** **B179** (Issue #462) — o Sollinha passou a falar de si **no masculino**: o system prompt (`src/utilities/ai/systemPrompt.ts`) abre com "Você é **o** Sollinha" e ganhou regra explícita (auto-referência masculina em primeira pessoa, nunca flexões femininas sobre si) — o welcome da UI já era masculino; o placeholder do chat virou "Pergunte para o Sollinha..." (`CampaignAIChat.tsx`) com os seletores e2e acompanhando (2 specs). Sem migration, access, Consent ou guarda automatizada (decisão do gate: só édito de texto). Os planos entregues da família (B162/B166/B167/B173/B174/B177) permanecem frozen com "a Sollinha" histórico — o que vale é a voz no runtime e o que se escreve daqui pra frente. Intenção [`sollinha-genero-masculino.md`](plans/sollinha-genero-masculino.md); impl [`sollinha-genero-masculino-impl.md`](plans/sollinha-genero-masculino-impl.md).
**Recently resolved (2026-08-09):** **B181** (Issue #480) — a `/campanha/agenda` não exibe mais o bloco "Nenhum compromisso nesta janela e neste filtro" + botão "Criar atividade" acima do calendário em janela vazia (só ocupava espaço, redundante com slot-click C91, botão "Nova atividade" dos filtros e FAB C94). Remoção cirúrgica em `ActivityAgenda.tsx` (bloco condicional + órfãos `emptyHref`/imports `Link` e `buildActivityCreateHref`); `buildActivityCreateHref` permanece com 2 consumidores ("Mais detalhes" no inline create, "Nova atividade" nos filtros); loading/erro e `noEventsText` do FullCalendar (vista "Lista") intactos. Teste unitário de regressão: janela vazia não renderiza aviso nem link de criação. Sem migration, access, Consent ou schema. Intenção [`agenda-remover-aviso-vazio.md`](plans/agenda-remover-aviso-vazio.md); impl [`agenda-remover-aviso-vazio-impl.md`](plans/agenda-remover-aviso-vazio-impl.md).
**Recently resolved (2026-08-09):** **C98** (Issue #483) — link de import da agenda: **a geração em produção falhava por infra, não por código** — a env `NEXT_PUBLIC_SITE_URL` não existia na Vercel production (verificado com `vercel env ls production`; só o preview tinha a de stage), e `getCampaignInviteBaseURL()` **lança em `NODE_ENV=production` sem ela** (fail-closed intencional) → o catch da action devolvia "Não foi possível criar o feed de calendário." e nada era criado. Verificação ponta a ponta no fluxo real (browser local, dev 3198): unlock C93 + criação C92 funcionam — login → agenda sem filtros → ícone "Link de import" → nomear → gerar → link `/campanha/agenda/ical/<secret>` sem erro → `GET` responde `200 text/calendar` com a atividade. Correção: `NEXT_PUBLIC_SITE_URL=https://pt.jorgesolla.com.br` adicionada em **Production** (e conferida em Preview) + `vercel redeploy` do deploy de produção. **E2E novo** `tests/e2e/campaignAgendaFeed.e2e.spec.ts` pina o fluxo real: desktop (header → dialog → nomear → gerar → link responde iCal com a atividade) e mobile (FAB "Ações rápidas" → tile "Link de import" → sheet → gerar); a fixture e2e passou a conhecer `calendarFeed` no ownership/cleanup (antes deletava o `campaignUser` com feed `createdBy` NOT NULL → transação abortada). Sem migration, schema, access ou Consent. Intenção [`c98-corrigir-geracao-link-import-agenda.md`](plans/c98-corrigir-geracao-link-import-agenda.md); impl [`c98-corrigir-geracao-link-import-agenda-impl.md`](plans/c98-corrigir-geracao-link-import-agenda-impl.md).
**Recently resolved (2026-08-09):** **C93** (Issue #437) — botão "Link de import" da agenda destravado **sem filtros** (sucessor direto do C16): com zero filtros ativos, staff gera o link do feed **completo dentro do escopo de leitura do criador** — coordinator/candidate = tudo; advisor = portfólio dos municípios que administra — com fail-closed/revogação de C16 intactos. O servidor já cobria o caso (vazio = escopo do criador em `buildFeedWhere`/`resolveFeedCreatorAccess`; descrição do campo `filterMunicipality`); a entrega removeu o gate `hasFilters → disabled` de `CalendarFeedButton` (prop morto `state` incluído), manteve `hasFilters` só no "Limpar filtros" e neutralizou a copy do diálogo ("este recorte" → "a agenda"). Testes: 3 int de conteúdo sem filtro (coordinator = A+B; advisor = só o portfólio, nada fora vaza; advisor sem município → feed vazio fail-closed) + unit component (botão habilitado com zero filtros e fluxo nomear→criar). Sem migration, access, Consent ou schema. Intenção [`c93-gerar-link-import-sem-filtros.md`](plans/c93-gerar-link-import-sem-filtros.md); impl [`c93-gerar-link-import-sem-filtros-impl.md`](plans/c93-gerar-link-import-sem-filtros-impl.md).
**Recently resolved (2026-08-09):** **B180** (Issue #474) — as tools eleitorais legadas do Sollinha (`getTopDeputies`, `getMunicipalityVotes`) ganharam o **gate fail-closed de leader lockdown** que a B177 introduziu só na tool nova: `assertCanReadElectionData(ctx.user)` no topo do `execute` → `{ error: 'Leitura de dados eleitorais negada.' }` antes de qualquer query (o route `api/ai-chat` expõe todas as tools a todos os papéis). O padrão chegou a **3 call sites** → extraído um helper compartilhado `electionDataGate(ctx)` (`src/utilities/ai/tools/electionDataGate.ts`, `true | { error }`), e `getLeadingMunicipalities` refatorado para usá-lo (mensagem com fonte única). Unit tests novos (`electionToolsLockdown.unit.spec.ts`, 11): helper (leader nega/staff passa), as 3 tools com leader + payload stub intocado (fail-closed antes de query, inclusive no path SQL de terceiro candidato) e staff-proceeds por papel (coordinator/advisor/candidate). Sem migration/Consent/UI/prompt. Intenção [`sollinha-tools-eleitorais-leader-lockdown.md`](plans/sollinha-tools-eleitorais-leader-lockdown.md); impl [`sollinha-tools-eleitorais-leader-lockdown-impl.md`](plans/sollinha-tools-eleitorais-leader-lockdown-impl.md).
**Recently resolved (2026-08-09):** **C97** (Issue #482) — Início/Término do overlay de criação rápida da agenda (C91) trocaram o `datetime-local` nativo (que renderiza AM/PM no idioma do browser e só deixava escolher o dia de forma confiável) por um **seletor shadcn de dia+horário 24h por construção**: `ActivityDateTimeField` (trigger com rótulo civil `dd/mm/aaaa às hh:mm` + popover com `Calendar` react-day-picker 9.7 `timeZone="America/Bahia"` locale pt-BR e selects nativos Hora 00–23 / Minuto em passos de 15). Contrato de valor inalterado (`YYYY-MM-DDTHH:mm` civil → `parseBahiaDateTimeInput` no submit), prefill do slot mantido, validações intactas; dia trocado no calendário preserva hora e vice-versa; popover com `max-h-[22rem]` + scroll (clippava no topo em viewports baixos — nav do calendário ficava inalcançável). Componente shadcn canônico `src/components/ui/Calendar.tsx` (variante radix-nova, lucide icons; `--cell-size` via `.cn-calendar` no styles.css) + helpers puros em `campaignTime.ts` (`formatBahiaCivilDateTimeLabel`, `hourOptions`/`minuteOptionsForStep`/`timeStepMinutes`, `floorToMinuteStep` — prefill de slot é múltiplo de 30, floor é defensivo). Única dep nova: `react-day-picker@^9.7.0` (date-fns transitivo). E2E do fluxo inline agora muda o horário pelo seletor e prova o chip "15:00 – 16:30" (24h) na grade. Sem migration/access/Consent. **Fatia mínima aprovada no gate**: eixo/chips do FullCalendar continuam `meridiem: 'short'` ("2 PM") e `formatBahiaDateTimeLabel` sem `hourCycle` explícito — follow-ups registrados no impl plan. Intenção [`c97-agenda-popover-seletor-data-hora.md`](plans/c97-agenda-popover-seletor-data-hora.md); impl [`c97-agenda-popover-seletor-data-hora-impl.md`](plans/c97-agenda-popover-seletor-data-hora-impl.md).
**Recently resolved (2026-08-09):** **B176** (Issue #458) — `/campanha/municipios` ganhou três dimensões de filtro na omnibox — **Dobradinha** (nome do contato), **Liderança** (nome do contato) e **Partido** (sigla) — com recortes "Sem dobradinha"/"Sem liderança"/"Sem partido" (precedente "Sem nível"). Params aditivos `stateDeputy`/`leadership`/`party` na URL congelada (sentinelas `sem_dobradinha`/`sem_lideranca`, reuso de `sem_partido`); `buildMunicipalityListWhere` ganhou um catálogo request-scoped fail-closed (`municipalityRelationSets.ts`, lazy: metade liderança só com filtro ativo) que vira as leituras reversa (`leadership.municipalities`) e cruzada (`stateDeputy.party`) em `where` — sem migration, paginação nativa mantida; facets estendidas (`stateDeputyIDs`/`leadershipOptions`/`parties`) e mapa compartilha o catálogo. **Coluna Dobradinhas liberada ao Assessor** (staff-wide): servidor já estava scoped (B37/B157 `reloadStaffActor` + `canUpdateMunicipality`), a mudança foi só de gate de UI (lista/mobile/picker) + catálogo full-staff para a célula (decisão do gate humano 2026-08-09). Extrato do par "sentinela + `exists:false`" em `campaignListUrl` (`NO_PARTY_FILTER_VALUE`, `splitAbsenceFilterValues`, `collapseListWhereOrBranches`) com 5 call sites. E2E: assessor vê/edita a coluna no portfólio; filtros pelas três dimensões; teste de coluna-restrita invertido. Intenção [`filtros-municipios-dobradinha-lideranca.md`](plans/filtros-municipios-dobradinha-lideranca.md); impl [`filtros-municipios-dobradinha-lideranca-impl.md`](plans/filtros-municipios-dobradinha-lideranca-impl.md).
**Recently resolved (2026-08-09):** **B174 reverso** (sem Issue — pedido direto) — a Sollinha (`/campanha/api/ai-chat`) voltou do `@ai-sdk/deepinfra` para o **`@ai-sdk/deepseek`** (API oficial `api.deepseek.com`): o cache-hit do V4-Flash direto custa **$0.0028/M** contra **$0.018/M** na Deep Infra (~6,4× mais barato), e a Sollinha tem prefixo estável (system prompt + tools + histórico) — o hit ratio alto derruba o custo efetivo mesmo com miss ($0.14 vs $0.09) e output ($0.28 vs $0.18) mais caros. Mesmo modelo (`deepseek-v4-flash`), mesmas tools; `@ai-sdk/deepseek` **pinado em `3.0.19`** (não `^3.0.19`, que flutuaria a 3.0.26 e puxaria a ferroviária `provider@4.0.7` duplicada — o lock ficou na `4.0.4/5.0.18` do `ai@7.0.47`, verificado no lockfile). **STT fica na Deep Infra** (B173): `whisper-large-v3` a $0.00045/min de áudio é o mais barato verificado (Groq $0.0019, OpenAI $0.006); `.env.example` agora documenta **as duas chaves** — `DEEPSEEK_API_KEY` (LLM) e `DEEPINFRA_API_KEY` (STT). Aviso da DeepSeek: aumento de preço anunciado ("em breve"). Sem migration, access, UI ou teste novo; smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar.
**Recently resolved (2026-08-09):** **OPS20** (Issue #464) — o e2e de cada worktree agora usa a porta do slot próprio (`3100+slot`), nunca o fallback `3000`: o `.env.test.local` (que o `playwright.config.ts` lê em `playwright.config.ts:14`) passou a carregar `PLAYWRIGHT_BASE_URL` — antes ele só ia para o `.env.local` (que o config **não** lê), então dois `pnpm test:e2e` em worktrees paralelos subiam dois dev servers na porta `3000`. Causa-raiz tratada além do sintoma: os dois blocos env inline do `provision` (que já tinham derivado uma vez) viraram um builder puro único `worktreeEnvFileContents` em `scripts/lib/worktree-env.mjs` que deriva `NEXT_PUBLIC_SITE_URL` + `PLAYWRIGHT_BASE_URL` do mesmo `env.devPort` para **ambos** `.env.local`/`.env.test.local` (paridade estrutural, drift impossível) + unit tests de paridade em `tests/unit/worktree.unit.spec.ts` (falham se o drift voltar). `playwright.config.ts` intocado (a variável certa já existia; só faltava chegar). Sem Docker (fallback) continua degradando ao compartilhado — fora de escopo. Sem migration. Intenção [`porta-e2e-isolada-por-worktree.md`](plans/porta-e2e-isolada-por-worktree.md); impl [`porta-e2e-isolada-por-worktree-impl.md`](plans/porta-e2e-isolada-por-worktree-impl.md).
**Recently resolved (2026-08-08):** **B170** (Issue #441, follow-up B169) — o **contrato do colapso de chips virou spec unitário** (`tests/unit/relationChipCellCollapse.unit.spec.tsx`): `RelationChipCell` renderiza 16 chips com `getBoundingClientRect` stubado (4/linha, 20px, gap 6 — a matemática real de `COLLAPSED_CHIP_ROWS`/`CHIP_GAP_PX`) e o spec pina que o slice fica em 12 (3 linhas), "Ver mais…" expande para 16 e "Ver menos" recolhe para 12; casos tudo-cabe (8) e exatamente-cheio (12) não mostram toggle. Antes, os specs das células usavam `measureOverflow: false` ou colapsavam para 1 chip — a mudança de timing do B169 (medição em `useLayoutEffect`) não tinha regressão do comportamento de colapso. **F2 medido e deferido em vez de eliminado**: `PerformanceObserver('layout-shift')` no worktree confirma o resíduo pré-hidratação exatamente como registrado no B169 — load SSR→hidratação com shifts únicos `0.139/0.043/0.257` (fontes: shell — sidebar/Drawer/main/col-resize — nenhuma célula de chips) e a linha da célula indo `97px/42chips → 57px/0chips` numa única transição; **ordenação client-side (RSC) → zero shifts novos**. Eliminar a paint pré-hidratação exigiria estimativa de largura por label (rejeitada pelo dono, que mede layout real de propósito) ou mudança de layout/regra de colapso (anti-goal) — o shift único do primeiro load é aceitável num painel interno staff. Sem migration, dados, access ou mudança visual. Intenção [`estabilizar-lista-dobradinhas-followup.md`](docs/plans/estabilizar-lista-dobradinhas-followup.md); impl [`estabilizar-lista-dobradinhas-followup-impl.md`](docs/plans/estabilizar-lista-dobradinhas-followup-impl.md).
**Recently resolved (2026-08-09):** **C94** (Issue #438) — barra da agenda consolidada: os 3 seletores (Município/Tag/Deputado presente) viraram **um combobox omnibox** (`activityAgendaOmnibox.ts` + chassis B127) com chips removíveis/“Limpar” e sugestões agrupadas; desktop toolbar `[combobox][carrinho giro][+ nova]` (ícones; mobile sem botão de criação no corpo — tudo no FAB); “Link de import” **saiu do card de filtros** para o **header do app** (`CampaignDesktopHeader`, **agenda-contextual**, ordem `[link][sino][IA]` do gate) via registro chaveado `SetCampaignHeaderAction` no `CampaignPageChromeContext` (slot compartilhado com C95), e virou ação de drawer no FAB mobile (`CampaignQuickAction.onAction` + `openCalendarFeed` no `CampaignQuickActionContext`) abrindo o **mesmo diálogo como sheet** responsivo (`CalendarFeedDialog` Dialog/Drawer; sem recorte mostra aviso “Aplique filtros…”, C93 cobre gerar sem filtro). O overlay do FAB fecha ao disparar ação de dialog. Sem migration/access/Consent. Intenção [`c94-agenda-combobox-header-acoes-rapidas.md`](docs/plans/c94-agenda-combobox-header-acoes-rapidas.md); impl [`c94-agenda-combobox-header-acoes-rapidas-impl.md`](docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md).
**Recently resolved (2026-08-08):** **B172** (Issue #446) — na lista staff `/campanha/municipios`, abaixo da faixa C2 (< 60rem do container) a coluna **Tendência deixa de colapsar para ícone no próprio lugar** (inverte a decisão B158 "container query nunca duplica" **só para Tendência**): a coluna inteira some (`hidden @min-60:table-cell` no th+td) e o **mesmo** editor de tendência (status+justificativa, autosave) passa a morar como ícone compacto na coluna **Ações**, ao lado do de atualização; acima de 60rem volta o badge completo e Ações é gated fechado (`@min-60:hidden`) quando nada está oculto manualmente. `Ações` tornou-se **sempre** montada para staff (antes só por ocultação manual) e pinada quando `trend`/`lastSignal` está oculto no B17. Uma instância **visível** do editor por linha por faixa (dual-render CSS-gated, sem JS/resize — a leitura "uma instância" do B158 virou "uma visível por faixa"). Atualização/Sinal fica de fora (opção A). Side effect documentado: sort/filtro de tendência via header some abaixo de 60rem (seguem no omnibox). Sem migration/dados/Consent. **Bug de produção corrigido no caminho (React 19.2.4 flight):** o `className` novo na coluna destravou um bug do serializer do RSC — com `votos` oculta o thead virou uma tarefa de ~4,7 KB (acima do `MAX_ROW_SIZE=3200`) e o span do cabeçalho `Dobradinha` (com `description`) foi deferido para chunk posterior; no SSR o `CampaignHoverTooltip` recebia o placeholder não-resolvido como `children` → `cloneElement(undefined)` → React #130 derrubando o painel (e2e `campaignColumnPicker` vermelho 3/3 em prod, verde em dev). Correção: `CampaignTableHead` passou a enviar o rótulo como prop de dados (`explanationLabel`) em vez de elemento `children` — string nunca é deferida; o tooltip reconstrói o trigger. Sem mudança visual; os demais call sites do tooltip inalterados. Intenção [`tendencia-icone-coluna-acoes.md`](docs/plans/tendencia-icone-coluna-acoes.md); impl [`tendencia-icone-coluna-acoes-impl.md`](docs/plans/tendencia-icone-coluna-acoes-impl.md).
**Recently resolved (2026-08-08):** **`pnpm worktree plan [bag] [--go] [--no-migrate]`** — worktree de **planejamento** para rodar a skill `/plan-issue` sem ocupar o main: **cada invocação cria UM DIFERENTE**, para sessões de planejamento paralelas — com `bag`, branch `plans/plan-issue-<bag>` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag`, o próximo sequencial `plans/plan-issue-<n>` livre (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`). Prefixo minúsculo `plans/…` nunca colide com um `next` posterior em branch nem em slot (slot do plano = hash do branch, registrado via marcador e bumpado pelos vivos). Mesmo provisionamento isolado do `next` (porta `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test`, migrations; sem Docker degrada ao compartilhado). `kill` cobre; comment das envs distingue `gerado por pnpm worktree plan`. Sem migration. Naming unit-testada.
**Recently resolved (2026-08-08):** **B168** (Issue #422) — desfeito o **encadeamento automático** das ações rápidas do wizard (B98 era o precedente que este item inverte; evidência de campo: o coordenador rejeitou a tela avançando sozinha). Ao salvar **qualquer** ação de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem (`from` allowlist B110 mantido; Início sem `from`) com toast — nunca avança para outro wizard. Removidos a fila/pipeline `wizardActionChain.ts` (fundido `wizardReturnHref` + `wizardPreviousHref` só com passos internos em `campaignActionRoutes.ts`), o param `entry`, os skip resolvers/`Pular` (`WizardSkipTrailing`/`WizardTrendSkipTrailing` deletados, chrome mobile sempre “X”) e o prefill de nota sem produtor (`notePrefill`/`voteFrom`/`voteTo`). Tendência: pós-save o RSC refresha com status já aplicado e o **client** é o único dono da navegação (toast + retorno) — o redirect servidor engolia o toast (B97 corrigido no caminho). Liderança: botão flutuante pós-save passou de “Continuar” (avançava) para **“Concluir”** (retorna à origem). Sem migration. Intenção [`desfazer-encadeamento-acoes-wizard.md`](plans/desfazer-encadeamento-acoes-wizard.md); impl [`desfazer-encadeamento-acoes-wizard-impl.md`](plans/desfazer-encadeamento-acoes-wizard-impl.md).
**Recently resolved (2026-08-08):** **C90** (Issue #426) — os três campos de responsável da Atividade (`responsible` Contact, `advisors` staff, `leadership`) viraram **um campo `responsible` polimórfico multi-valor** (`campaignUser` staff · `leadership` · `stateDeputy`), removendo os dois antigos do modelo/UI; assessor continua vendo/curando o compromisso onde está listado como responsável via notação-objeto `equals {relationTo,value}` no access (provado por int test com `payload.find` real); seletor `ResponsibleMultiSelect` (chips tipados) + busca única `searchActivityResponsibleOptions` (escopo de leitura do ator, liderança engajada) — reusado pelo C91 na agenda; migração esquema+dados (advisors/leadership → `responsible`, Contact antigo descartado, contagem via NOTICE; SQL de reconciliação ensaiado contra cenário misto); busca `q` da lista virou título-only (regressão assumida no gate); notificação de atenção notifica só staff em `responsible`. Intenção [`remodelar-responsaveis-atividade.md`](docs/plans/remodelar-responsaveis-atividade.md); impl [`remodelar-responsaveis-atividade-impl.md`](docs/plans/remodelar-responsaveis-atividade-impl.md).
**Recently resolved (2026-08-08):** **B169** (Issue #425) — fim do flicker de linhas/colunas na lista de Dobradinhas (e nas demais listas com células de chips: Municípios, Lideranças, Assessores): o ciclo "medir → recolher" de `RelationChipCell` passou de `useEffect` (pós-pintura) para `useLayoutEffect` (antes da paint). Antes, cada remount (navegação RSC, paginação, ordenação, filtro) pintava o clamp `max-h-18` (72px) e só então colapsava para o slice de 3 linhas — a tabela ia 1122→682px numa onda de LayoutShift por célula (medido `0.021×6` por navegação). Agora a primeira pintura já é a altura final; medido durante ordenação: tabela estável em 682px, zero LayoutShift das células. Resíduo documentado: o load SSR inicial ainda pinta o estado medindo (pré-hidratação) — `useLayoutEffect` não alcança essa paint; estimar largura no servidor foi rejeitado (reintroduziria a estimativa por label que o código evita). Sem migration, dados, access ou mudança visual. Impl [`estabilizar-lista-dobradinhas-flicker-impl.md`](docs/plans/estabilizar-lista-dobradinhas-flicker-impl.md).
**Recently resolved (2026-08-08):** **worktree next provisiona ambiente isolado por worktree** — agentes em paralelo não disputam mais porta 3000, o `teqo_test` compartilhado nem um segundo Postgres no 5432: `pnpm worktree next [--go] [--no-migrate]` deriva deterministicamente slot do código da Issue (C15→15; hash sem dígitos; bump em colisão entre worktrees vivos) → dev server `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test` criados no container compartilhado com migrations aplicadas; escreve `.env.local` + `.env.test.local` (gitignored, marcador gerado; envs manuais nunca sobrescritos) e copia PAYLOAD*SECRET/Blob/VAPID do main. `kill` dropa os bancos gerados (best-effort, WITH FORCE). Suites leem `.env.test.local` depois de `.env.test` (vitest.setup/playwright/db-doctor/gate-ci espelham o layering); `assertTestDatabase` aceita `teqo_test` ou `teqo*<worktree>\_test`em localhost só (fail-closed preservado).`db:start`/`db:stop`fixam`-p teqo`(um container, qualquer diretório); max_connections 200→400. Sem Docker (Cursor Cloud): degrada ao compartilhado com aviso. Derivação pura`scripts/lib/worktree-env.mjs` (unit-tested).
**Recently resolved (2026-08-08):** **B164** (Issue #400) — barra de navegação inferior fixa no mobile para staff, com 5 itens (Início, Municípios, Atualizações, Agenda, Mais) e drawer overflow "Mais" para destinos secundários (Quadro, Territórios, Organizações, Líderanças, etc.) + rodapé Perfil/Sair. Leader e desktop mantêm sidebar-only (lockdown intacto). `CampaignContentScroll` e `CampaignQuickActionsHost` ajustados para safe-area e z-index do FAB sobre a barra. `/campanha/atualizacoes` é placeholder honesto (âncora C89). Impl [`barra-navegacao-inferior-mobile-impl.md`](docs/plans/barra-navegacao-inferior-mobile-impl.md).
**Recently resolved (2026-08-07):** **C15** (Issue #390) — `/campanha/agenda` virou a entrada operacional staff com FullCalendar v7 route-local (semana/mês/lista), filtros compartilháveis por município/deputado/tag, criação pelo slot, detalhe por URL e remarcação drag/resize sob RBAC, transação e lock por atividade; `/campanha/atividades` e giros permanecem como lista/fluxos legados. Sem migration. Impl [`fullcalendar-agenda-campanha-impl.md`](plans/fullcalendar-agenda-campanha-impl.md).
**Recently resolved (2026-08-06):** **B163** (Issue #391) — `/campanha/dobradinhas` agora edita Nome, Partido, E-mail e Telefone inline; `Contact` virou a fonte de identidade, dobradinha nova cria o Contact na mesma transação, e a ficha usa ID canônico com fallback/redirect de slugs antigos. A migration faz backfill idempotente, preserva nomes legados com preflight de limite, mantém a invariável de nome/telefone e o importador da projeção também reencontra dobradinhas pelo nome atual do Contact quando o alias legado diverge; os links de relações/busca apontam para ID. Intenção [`editar-campos-lista-dobradinhas.md`](plans/editar-campos-lista-dobradinhas.md); impl [`editar-campos-lista-dobradinhas-impl.md`](plans/editar-campos-lista-dobradinhas-impl.md).
**Recently resolved (2026-08-05):** **B158** (Issue #364) — `/campanha/municipios` troca cards↔tabela e revela colunas pela largura do painel de conteúdo com named container queries (48–84rem), sem rolagem horizontal; Território virou sublinha de Município, Tendência/Sinal preservam um único editor na tabela quando ocultos manualmente, e `campaign_columns=municipios:__none__` diferencia “todas visíveis” dos defaults. E2E mede cada limiar em Chromium e mantém um caso iPad-sized no projeto WebKit focado. Sem migration ou mudança de RBAC. Intenção [`colunas-responsivas-municipios.md`](docs/plans/colunas-responsivas-municipios.md); impl [`colunas-responsivas-municipios-impl.md`](docs/plans/colunas-responsivas-municipios-impl.md).
**Recently resolved (2026-08-05):** **B159** (Issue #374) — as colunas Assessores, Lideranças e Dobradinhas de `/campanha/municipios` agora compartilham `MunicipalityRelationEditor` (`CampaignCellEditOverlay` + Command + chips + optimistic reconcile) e `MunicipalityRelationAvatarStack`; Dobradinhas deixa `RelationChipCell` só nesta superfície, preserva busca por partido e links para a ficha, e Lideranças cria inline somente com nome (`Contact.phone = null`, sem stub; telefone real depois na ficha). Sem migration, rota nova ou mudança de access. Intenção [`generalizar-colunas-relacao-municipios.md`](docs/plans/generalizar-colunas-relacao-municipios.md); impl [`generalizar-colunas-relacao-municipios-impl.md`](docs/plans/generalizar-colunas-relacao-municipios-impl.md).
**Recently resolved (2026-08-04):** **B155** (Issue #359) — coluna de lideranças na lista de municípios (espelho de Assessores): coluna "Lideranças" após "Assessores" (staff; leader fora), chips com nome do contato, popover/sheet com Command + delta otimista (seq/pending/rollback) e criação inline (mini-form Nome + Celular). **Sem migration**: leitura por query reversa de `leadership.municipalities` (batch no bundle, `loadMunicipalityLeadershipSummaries`) e escrita reusando B34 (`setLeadershipMunicipalitiesMembershipRecord`) + wizard create (`createLeadershipWizardRecord`) — fonte única de verdade, consistência automática com `/campanha/liderancas`. Rota nova `POST /campanha/municipios/leaderships` (`campaignJsonMutationRoute`, union toggle|create, safe-messages allowlist); provider bridge espelho do B154. Regra travada com o humano: **célula → rota, form → server action** (débito de migração das 3 anomalias — sinal da lista e colunas B31/B34 de `/campanha/liderancas`). Intenção [`liderancas-coluna-municipios.md`](docs/plans/liderancas-coluna-municipios.md); impl [`liderancas-coluna-municipios-impl.md`](docs/plans/liderancas-coluna-municipios-impl.md).
**Recently resolved (2026-08-03):** **B154** (Issue #352) — criar assessor inline no popover da lista de municípios: opção "+ Criar assessor '<nome>'" quando a busca não casa ninguém; cria `campaignUser` advisor (e-mail stub `<slug>@criado.invalid`, senha aleatória) e atribui ao município na MESMA transação (cap 10 com rollback, lock `municipality-advisors:{id}`); provider client compartilha o assessor criado entre linhas; chip otimista com id temporário negativo; `advisorNameSchema` unificado em `primitives.ts`; predicado de placeholder estende a `@criado.invalid`. Bônus pré-existentes destravando o e2e do spec municipios: key warning do fragment `trailing` de `MunicipalityFilters` (array com keys) e settle-wait da animação do Drawer no teste B136. Intenção [`criar-assessor-inline-popover-municipios.md`](docs/plans/criar-assessor-inline-popover-municipios.md); impl [`criar-assessor-inline-popover-municipios-impl.md`](docs/plans/criar-assessor-inline-popover-municipios-impl.md).
**Recently resolved (2026-08-03):** **B86** (Issue #22) — ações rápidas de apoiadores (staff), último catálogo do lote B80–B90: módulo `supporterQuickActions.ts` (espelha B87) com `register-supporter` → `/apoiadores/novo` (todo staff) e `import-supporters` → `/apoiadores/importar` (coordinator-only, espelha `isCampaignCoordinator`); superfície lista+detalhe numérico; `novo`/`importar` só-busca; leader fora. Sem migration/Consent. Impl [`acoes-rapidas-apoiadores-impl.md`](docs/plans/acoes-rapidas-apoiadores-impl.md); débito S1 (normalização de pathname de lista, 6+ call sites) → #353 [`escala-dry-pos-acoes-rapidas.md`](docs/plans/escala-dry-pos-acoes-rapidas.md).
**Recently resolved (2026-08-03):** **OPS18** (Issue #296) — promote dual plan-issue: Action `plan-issue-ready-on-main-merge.yml` no merge lê `Related #N` e promove Issues ainda aguardando plano (`canPromotePlanIssue`); idempotente com `pnpm agent:ready`. Planos: [`plan-issue-promote-dual.md`](docs/plans/plan-issue-promote-dual.md), impl [`plan-issue-promote-dual-impl.md`](docs/plans/plan-issue-promote-dual-impl.md).
**Recently resolved (2026-08-02):** **OPS17** (Issue #292) — ciclo plan-issue: gate pós-overview antes de Issue/PR; `agent:register --plan` nasce `blocked`; após plano em `main`, `pnpm agent:ready` promove a `ready`. Planos: [`plan-issue-lifecycle-ready.md`](docs/plans/plan-issue-lifecycle-ready.md), impl [`plan-issue-lifecycle-ready-impl.md`](docs/plans/plan-issue-lifecycle-ready-impl.md). Action dual no merge = OPS18 (#296).
**Recently resolved (2026-08-02):** **B147** (Issue #330) — rota paralela `/campanha/municipio/<slug>/v2` com faixa de status (nível · tendência · sinal · classe · frescor) e modal de motivo opcional; soft-dep B134 no write de nível (motivo opcional, sem “voltar atrás”). Intenção [`municipio-v2-shell-status.md`](docs/plans/municipio-v2-shell-status.md); impl [`municipio-v2-shell-status-impl.md`](docs/plans/municipio-v2-shell-status-impl.md). Filhos B148–B152.
**Recently resolved (2026-08-02):** **B127** (Issue #264) — barra omnibox de filtros no piloto Municípios + chassis `CampaignListOmnibox` (chips + sugestões; URL como fonte de verdade; cenário/sort como chips de apresentação). Planos: [`barra-filtros-omnibox-listas.md`](docs/plans/barra-filtros-omnibox-listas.md), impl [`barra-filtros-omnibox-listas-impl.md`](docs/plans/barra-filtros-omnibox-listas-impl.md). Adoção nas demais listas = B128.
**Recently resolved (2026-08-02):** **Skills plan/work reescritas** — `plan-issue` agora só gera planos de **intenção** (persona/fluxo/aceite + direção suave no código; sem decisões duras de engenharia). Execução: `agent-work-issue` (pool — Plan mode → `*-impl.md` → executa → `/simplify` → `capture-review-debts` autônomo → PR Ready + auto-merge) e `work-issue` (humano — claim + **pausa** após o impl plan). Prompt do pool aponta para `agent-work-issue`.
**Recently resolved (2026-08-02):** **WhatsApp interno D3–D5 descartado** — Issues #30–#32 `wontfix` (bridge Baileys/Evolution/WAHA / linked device): risco de ban do número pessoal do staff. Canal no produto = só `wa.me`. Planos `whatsapp-interno-campanha.md`, `whatsapp-canal-fundacao.md`, `whatsapp-envio-liderancas.md`, `whatsapp-sugestao-atualizacoes.md` removidos; refs em `notifications.md` / convite·email listas / fluxos-ação / regra `nucleos-eleitorais` atualizadas.
**Recently resolved (2026-08-02):** **Agent pool hard max → 12** — `POOL_HARD_MAX_SLOTS` de 8 → 12 (`scripts/lib/agent-pool-state.mjs`); default continua 5. Skill `agent-pool` alinhada.
**Recently resolved (2026-08-01):** **B102** (Issue #129) — crash na busca do bottom drawer (focus/digitação): `TooltipProvider` do layout `(app)` envolvia só `{children}` e o drawer (irmão do scrollport) renderizava hits `priority:alta` via `CampaignHoverTooltip` sem provider. Fix: provider envolve `CampaignAppScrollChrome`; pins unit focus→suggest e type→search. Plano: [`crash-busca-bottom-drawer.md`](docs/plans/crash-busca-bottom-drawer.md).
**Recently resolved (2026-08-01):** **C8 F1–F2** (Issue #37) — freshness audit confirmou entrega em código desde 2026-07-19: locks advisory em batch (`acquireTextAdvisoryLocks`), bulk import com `.returning()` + leituras drizzle, overview derivando `total` de `totalDocs`, migration `pg_trgm`, guard `contactSearchQuery` (mín. 2 chars). Plano [`escala-dry-pos-c6.md`](docs/plans/escala-dry-pos-c6.md) fechado; follow-ups de filtros/forms permanecem no C9.
**Recently resolved (2026-08-01):** **Cutover residue cleanup** — removed dead `pnpm db:refresh:stage` / `scripts/refresh-stage.mjs` and `pnpm agent:promote` / `scripts/agent-promote.mjs`; closed `docs/CUTOVER-MAIN-ONLY.md`; scrubbed PR template `STAGE_DATABASE_URL` checklist item. Branch + Environment `stage` already deleted.
**Recently resolved (2026-08-01):** **PRs de agente = Ready + auto-merge (audit incluso)** — removida a exceção do `engineering-audit` (artefatos também armam `gh pr merge --auto --merge`). Safety net GHA `agent-pr-ready-automerge.yml`: PRs `cursor/*` → `main` Draft→Ready + auto-merge. Rule always-on `agent-pr-workflow.mdc`.
**Recently resolved (2026-07-31):** **CI build skip para diffs fora da árvore buildável** — `scripts/build-affected.mjs` + `classifyBuildScope` (núcleo em `test-affected-core.mjs`) decidem `run|none`; job `build-scope` barato no `ci-pr.yml` e o job `build` (Postgres + `pnpm build`) só roda em `mode=run`. Docs/tests/tooling/workflows pulam o build; `src/`, `public/` e configs do Next (`next.config`/`tsconfig`/`tailwind`/`postcss`/`components.json`/lockfile) forçam. `gate:ci` espelha. Push em `main` (`ci.yml`) continua sempre full.
## 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-31):** **A6** Insight de dobradinhas 2026 (Issue #27) — card "Dobradinhas potenciais para 2026" na aba Eleições do detalhe do município: candidatos proporcionais com `runningAgain2026 = sim` ranqueados por score `0,6 alinhamento + 0,4 força local` (tiers compostos de `campoParties` 2026 + `electionPartySpectrum` — sem terceiro mapa; votos 2022 normalizados pelo máximo da geografia). Leitura derivada, sem migration/Consent; loader `municipalityTicketPartnerData` cacheado sob `election-tse` com a política dos irmãos; estado `pending2026` ("indisponível até as candidaturas de 2026") até a Fase 5 reconciliar pós-15/08 — o runbook da conciliação termina com `POST /api/revalidate?tag=election-tse`. Solla excluído (referência); link para `/campanha/dobradinhas` como caminho de registro (não cria registro paralelo). Int specs eleitorais agora se serializam pelo lease `election-collections` (`testDatabaseLease.ts`). Overview da lista ficou fora de escopo (strip de métricas + varredura estadual por request — follow-up possível pós-Fase 5). Plano: [`docs/plans/insight-dobradinha-2026.md`](docs/plans/insight-dobradinha-2026.md).
**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-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: `.agents/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
**Recently resolved (2026-07-30): Unificação das skills do fluxo (OPS1/OPS2)** — as 7 skills do ciclo roadmap foram fundidas em três: **`plan-issue`** (fundiu `roadmap-item`/`roadmap-items` + profundidade de `implement-roadmap-item`; GATE de confirmação com overview + wireframes ASCII antes de criar qualquer Issue; sugestão de modelo vira propriedade `model:` no frontmatter), **`work-issue`** (claim → verificação assimétrica de modelo → freshness audit → execução → /simplify → PR `--base stage` com CI acompanhado até o merge; promote continua humano) e **`project-status`** (`pnpm agent:status` — overview/fila com `model:`/mermaid/bloqueios/sugestões de consolidação; read-only, nunca toca `in-progress`). Suporte: `agent:register --model`; brief do `agent:claim` imprime `model:`; workflow `issue-done-on-stage-merge.yml` faz o flip `in-progress→done` deterministicamente no merge em stage. Conversão: as 38 Issues do seed ganharam `model:` (tabela ID→modelo aprovada pelo usuário); 6 itens abertos do roadmap legacy viraram Issues (C11 #44, PUB1–PUB4 #45–#48, ADM1 #49); guarda-chuvas ("UX-1", "Higiene PascalCase", smoke/onboarding, white-label, ledger TECH-DEBT) citados, não registrados; `docs/roadmap.md` virou **stub legado congelado**. Deletadas: `roadmap-item`, `roadmap-items`, `implement-roadmap-item`, `suggest-next-roadmap-items`, `ship-to-main`, `close-delivery`, `compile-roadmap`; referências vivas varridas (`model-selection`, `rebase-on-main`, notebooks de projeto, IMPROVE-CODE-QUALITY-PLAN). `capture-review-debts` registra via `agent:register`/`agent:file-miss` e **nunca** edita Issue `in-progress` (débito do mesmo pai = Issue nova com `depends: [pai]`); `engineering-audit` declarado **solitário** (pausa o paralelismo; executa remediações P0/P1 na sessão) com harvest `kind:agent-miss` → `docs/GUARDRAILS.md`. Docs: `docs/AGENT-OPS.md`, `README.md`.
**Recently resolved (2026-07-30):** **B77** Simplificar wizard ajuste de votos — `WizardExpectedVotesStep` reescrito: três cenários numa tela (`VoteEstimateScenarioInputs` compact + strip + atalhos no foco); validação `pessimistic ≤ central ≤ optimistic` só no confirm com `Alert` + highlight do par conflitante (`getWizardVoteViolationHighlights`); POST batch inalterado (`MUNICIPALITY_EXPECTED_VOTES_ENDPOINT`); `?cenario=` legado redireciona para URL canônica sem param (`hasWizardScenarioParam` + `wizardActionHref` sem 3º arg); órfãos do ritual B61 removidos de `wizardVoteEstimate`/`campaignWizardCopy`. `VoteEstimateScenarioInputs` compact ganhou `activeScenario`/`onFocusScenario`/`errorScenarios`/`disabled` (lista inalterada). Sem migration. Plano: [`docs/plans/simplificar-ajuste-votos-wizard.md`](docs/plans/simplificar-ajuste-votos-wizard.md).
**Recently resolved (2026-07-30):** **B70** Wizard atualizar liderança — `/campanha/acoes/atualizar-lideranca` substitui o stub B60 por `WizardLeadershipStep` (grid 2/3/4 + tile Adicionar + info Drawer + Tooltip de `notes` no hover fine) e `WizardLeadershipForm` (6 campos incl. `exclusive` B69); `loadWizardLeadershipTiles` no RSC; writes `updateLeadershipWizard`/`createLeadershipWizard` em transação Contact+Leadership (não `updateLeadershipInternal`); `WIZARD_ENTRY_ACTION_QUERY_KEY` + skip via `trailingAction` quando `entryAction !== 'update-leadership'`; Continuar após Salvar dirty → Início + toast; `truncateNameAtWordBoundary` em `lib/`. Sem migration. Plano: [`docs/plans/wizard-atualizar-lideranca.md`](docs/plans/wizard-atualizar-lideranca.md). Desbloqueia wiring A1 embutido.
**Recently resolved (2026-07-30):** **B71** Polimento visual dos resultados de busca no Início — `src/lib/homeSearchUi.ts` centraliza bleed (`-mx-4 px-4 md:-mx-6 md:px-6`), heading discreto (`text-xs` sentence-case) e `ul list-none`; `HomeSearchHitRow` pinta hover/focus na faixa full-bleed; todos os `HomeSearch*Group` alinhados. Sem migration/action/contrato JSON. Plano: [`docs/plans/polimento-resultados-busca-inicio.md`](docs/plans/polimento-resultados-busca-inicio.md).
**Recently resolved (2026-07-30):** **B69** Simplificar modelo de liderança — `leadership.exclusive` boolean NOT NULL DEFAULT true ("Apoio exclusivo") via migration `20260730_043306_simplify_leadership_fields` + DROP `sector`/`sectorNotes`/`consentNote` (collection, zod, forms, filtros/sort B29 da lista, invite autofill/redemption, dossiê; `?sector=` legado canonicaliza fora). **Decisão de produto 2026-07-30 reverteu o rascunho: `organization` FICA** — mapeia ramificações políticas (SindMed com várias lideranças); vertical `/campanha/organizacoes` e todos os vínculos intactos. Lista ganha coluna "Apoio exclusivo" (badge outline "Não exclusivo" só quando false); forms com checkbox default-on + hidden "false" na criação (último valor vence; ausência cai no default do zod). Nota operacional: o prompt interativo de rename do `migrate:create` trava sem TTY — gerar drops e adds em passadas separadas (`CI=1`) e fundir à mão foi o caminho; o `NOT NULL` foi emendado sobre o SQL gerado. Gate: tsc/lint/format/cycles/build; int 518/518; unit com as mesmas 16 falhas pré-existentes do clean tree (drift B66/B71 na base); knip idêntico à base (P3 + drift); Aikido 0 findings. Plano: [`docs/plans/simplificar-modelo-lideranca.md`](docs/plans/simplificar-modelo-lideranca.md). Desbloqueia **B70**.
**Recently resolved (2026-07-29):** **B62** Simplificar modelo de sinal — `municipalityUpdate` kind `sinal` is now `signalType` + `body` only: migration `20260730_010601_simplify_municipality_signal_fields` drops `signal_source` and `triangulated` (the generator re-emitted `ADD VALUE 'adiada'` from snapshot drift — removed by hand; this migration's committed `.json` heals the chain), and the five signal-type labels/descriptions were rewritten for the field team (`Rede esfriou`, `Adversário apareceu`, `Alguém pediu algo` — no "broker" in the signal model; `activity.origin`'s `Pedido de broker` and suggestionCatalog copy were explicitly left out by session decision). `MunicipalitySignalFields` (B26 list control) and the inline block in `MunicipalityUpdateForm` lost Fonte/Triangulado; `municipalityUpdatePageData` + `MunicipalityUpdateFeed` (detail + dossier) render only the type badge. **E11 changed semantics: any adversary signal in the window reads as confirmed — P1 triage 1** (session decision, replacing the triangulated/single-source split), `MunicipalityTriggerInput.adversarySignal: { present: true } | null`, single factor copy, E18 glossary aligned. Bonus gate fixes: the `campaignMunicipalities` "advisor scopes…" e2e was stale since B43 (leader home → `/campanha/contatos`) and was updated, and the `municipalityElectoralBaseline` bypass pin dropped 9→8 (pre-existing main drift). Gate: 876 unit / 489 int / targeted e2e green / build; Aikido 0 findings. Plano: [`docs/plans/simplificar-modelo-sinal.md`](docs/plans/simplificar-modelo-sinal.md).
**Recently resolved (2026-07-29):** **B59** Chassis do wizard de campanha — rotas staff em `/campanha/acoes/<slug>` (`campaignActionRoutes.ts`, `campaignActionEntryHref`), layout `acoes/layout.tsx` (gate staff, coluna `max-w-lg`), `CampaignWizardShell` (voltar mobile, caption de município sticky, `trailingAction`, foco no `h1` ao trocar passo), cinco páginas-ponte até B60 e strip do Início com `href` nos fluxos wizard. Sem migration. Plano: [`docs/plans/chassis-wizard-campanha.md`](docs/plans/chassis-wizard-campanha.md).
**Recently resolved (2026-07-29):** **B49** Busca global — resultados Lideranças no Início staff: o mesmo `POST /campanha/home-search` agora inclui `searchHomeLeaderships` em paralelo com municípios e assessores; word-start no `contact.name` com pré-filtro `buildLeadershipListWhere` (`contains`) refinado em memória, cap 25, `municipalitiesSummary` via `truncatedNamesLabel`, desempate por `updatedAt`; assessor vê só a carteira (`overrideAccess: false`). Client: `HomeSearchLeadershipGroup` entre Municípios e Assessores, `HomeSearchHitRow` compartilhado, empty state via `homeSearchHasAnyHits`; linha → `/campanha/liderancas/[id]`. Sem migration. Plano: [`docs/plans/busca-global-resultados-liderancas.md`](docs/plans/busca-global-resultados-liderancas.md).
**Recently resolved (2026-07-29):** **B48** Busca global — resultados Municípios (+ TIs) no Início staff: `POST /campanha/home-search` (`campaignJsonMutationRoute` + `homeSearchBodySchema`) chama `searchHomeMunicipalities` — municípios via `loadMunicipalityScope` + `matchesAtWordStart` + readout A11; territórios no mesmo grupo via `loadTerritoryOverview` (contexto regional E17, não carteira do assessor). Client: `HomeSearchResultsContext` (fetch único, extensível B49+), `HomeSearchMunicipalityGroup`, empty “Nenhum resultado.”; `MunicipalityVotePositionReadout` extraído de `MunicipalityList`. Sem migration. Plano: [`docs/plans/busca-global-resultados-municipios.md`](docs/plans/busca-global-resultados-municipios.md).
**Recently resolved (2026-07-28):** **E11** Motor de sugestões v1 — the 8 highest-confidence data→decision patterns of the research report §6.1 (P1 reduto dormente/ameaçado, P2 ataque, P3 correligionário na frente, P5 expansão, P6 grande sem rede, P7 pledges estagnados, K-A não responde, K-B perdida vestida) now run as a human-in-the-loop decision queue: a "Sugestões" panel on the staff dashboard (top-5 by triage + "pauta do silêncio") and a card on the municipality Overview, with aceitar/adiar/descartar writing `allocationDecision`. Three layers: **`src/lib/suggestionCatalog.ts`** (client-safe — pure predicates over `MunicipalityTriggerInput`, a numbers-only input type, plus the exact card copy: probable reading, the "descartes" that become the dismiss form's quick picks, cheap-before-expensive menu, contraindication, statute; anchors are illustrative and private, calibration = E15); **`src/utilities/municipalityTriggers.ts`** (`server-only`, on the E13 planner's mold — own municipality read because the shared scope select lacks `engagementLevel`/`lastUpdateAt`, six reads in one `Promise.all`, and per-year LQ using E10's exact formula rather than a third LQ writing); **`resolveSuggestionRecord`** which RE-EVALUATES the pattern server-side before writing — the snapshot records decision-time factors, never render-time ones, and a pattern that left the queue refuses with the safe `SUGGESTION_STALE_MESSAGE`; advisory lock `municipality-suggestion:{id}:{patternId}` is taken before re-reading the latest decision. **The audit killed the plan's "sem migration":** the real outcome enum was `aceita|descarta|movimento` (the plan's `descartada|adiada` values never existed), so "Adiar" cost migration `20260728_041547_add_allocation_decision_adiada_outcome` (E14's `movimento` precedent), with `postponeDays` derived from the recomputed triage (7d; 14d on levels 4–5) and `suppressUntil` in the snapshot instead of a column. Suppression is pure and unit-pinned (`isSuggestionSuppressedByDecision`): aceita 14d, descarta 30d, adiada until snooze, malformed snapshot falls back to the shortest postpone — and **"nível 1 fura a fila"**: a trigger returning as confirmed risk pierces a suppression recorded at lower urgency. Structural differential is code (§6.3): P2 yields to P3/K-B, P7 yields to K-A when effort was recorded. P3 fires from the artifact alone (`runningAgain2026` is 100% `desconhecido` with no read path — "concorre de novo?" is the menu's first item, not a trigger). Silence review = alta/N2+ with no FIRED pattern (a suppressed one was answered, not silence) and no signal ≥30d; includes `priority: alta` because E14 shipped without backfill. UI: explicit submit on all three outcomes (the `campanha-edit-where-you-see` exception for note/confirmation flows), native radios (`accent-primary` — first radio precedent in the repo), managed focus across the trigger↔form swap, level 1 is the only red. **Two facts worth keeping:** the public-site base `ul { my-6 ml-6 list-disc }` leaks into campaign surfaces (the factors list needed B20's reset), and artifact-anchored patterns cannot be staged from DB rows — int tests that need an artifact property claim municípios through the fixture allocator until one qualifies (measured: 109/435 top-quartile, 205/435 quiet, and reduto+coreBlock is only 13/435, which moved P1's trigger coverage to the 45-case unit spec; explicit slugs would race the allocator's wrapping sequence). E18 concepts `triagem-de-sugestoes` + `pauta-do-silencio`; `/campanha` First Load JS 268 → 277 kB. Adjacent follow-ups live in the plan: wiring E13's "não vá" override to `resolveSuggestion`; catalog fase 2 (T/I/J, P4/P8–P12, K-C) after 2 weeks of real use + 20 decisions; a dedicated route if >15 recurring active suggestions. Plano: [`docs/plans/motor-de-sugestoes.md`](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 + `.agents/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-29):** **B51** Busca global — grupo **Atividades** no Início staff: `searchHomeActivities` (staff-only; word-start no `title`; access `activity` com `overrideAccess: false`; rascunhos incluídos; cap 25; desempate `startAt` desc); campo `activities` no `POST /campanha/home-search`; `HomeSearchActivityGroup` após Assessores; secundário `"{município} · {quando}"`; empty via `homeSearchHasAnyHits`. Int `homeSearchActivities.int.spec.ts`. WA = **B55**. Plano: [`docs/plans/busca-global-resultados-atividades.md`](docs/plans/busca-global-resultados-atividades.md).
**Recently resolved (2026-07-29):** **B50** Busca global — grupo **Assessores** no Início staff: `searchHomeAdvisors` (`isUnrestrictedCampaignRole` — assessor recebe `[]`; word-start no nome; `municipalityIdsByAdvisorIds` exportado de `advisorData`); campo `advisors` no `POST /campanha/home-search`; `HomeSearchAdvisorGroup` + `HomeSearchHitRow` compartilhado; empty via `homeSearchHasAnyHits`; secundário = contagem de municípios (sem e-mail). Int `homeSearchAdvisors.int.spec.ts`. WA = **B55**. Plano: [`docs/plans/busca-global-resultados-assessores.md`](docs/plans/busca-global-resultados-assessores.md).
**Recently resolved (2026-07-28):** **B17** Seletor de colunas — a "Colunas" picker shipped as a capability of `CampaignTable`, so it landed on **all seven** list surfaces (municípios, lideranças, dobradinhas, organizações, demandas, apoiadores, territórios) rather than only on `/campanha/municipios`: pass `columnVisibility` (one value, from `readCampaignColumnVisibility(listId)`) and the table mounts `CampaignColumnPicker` and filters through `resolveVisibleColumns`. `/campanha/assessores` stays out — `AdvisorsTable` is still a raw `ui/Table` with `table-fixed`, and migrating it to the list system is separate work. **Two premises of the 2026-07-24 plan died, and the first one died on a type, not a preference:** `CampaignTableColumn.cell` is a **function**, so the column array never crosses the server/client boundary and the client cannot rebuild the table without it — hiding client-side would mean tagging every cell with `data-column` and injecting a `<style>` per list. Persistence is therefore the cookie `campaign_columns` (path `/campanha`, `SameSite=Lax`, one year, client-readable because the client writes it), carrying a `listId → hidden ids` map in a compact form (`municipios:kind~trend|demandas:requester`) with **no `JSON` and no percent-encoding** — `document.cookie` and Next's `cookies()` disagree about decoding, and every character used is legal in a cookie-value under RFC 6265. The server filters, so a hidden column **does not reach the RSC payload either**; the price is one `router.refresh()` per editing session inside the shared transition, dimming the results like every other filter on those screens. **Closing the menu is the commit**, not a debounce: the first cut used 400 ms, which batches a burst rather than a reading pace, so unchecking four columns while reading their labels was four full-route refreshes — and on `/campanha/liderancas` each one re-pays the ~229 ms / 371-statement render already ledgered P1. The timer survives at 3 s, but a `/simplify` pass split what it does: it writes the **cookie only, without refreshing**, because any timer that also refreshed just recreates the per-toggle cost at a slower pace — three seconds is an ordinary gap between clicks when the labels are being read, which is the same failure the 400 ms cut was rejected for. Durability and repaint are separate questions, so they get separate triggers, and the accepted cost is stated because it is real: the table behind the menu no longer updates per checkbox. Two guards came with the split — a session that ends where it started refreshes nothing (`sameIdSet` against the rendered set), and a menu still open when the route unmounts writes the cookie from the cleanup, since Radix reports no close event for a sidebar navigation and that choice used to vanish silently. The second casualty is the `defaultVisible` seam, which is **gone**: it was `true` on 10 columns and `false` on none, and storing only the **hidden** set is what makes a newly shipped column visible to everyone who never touched the picker — the contract E9 and E14 already assumed. In exchange, `label: string` is now **required** on `CampaignTableColumn` (the picker needs a serializable label), filled in across ~55 column definitions — and since 24 of those columns then had a `head` that only restated the new `label`, `head` became **optional**, defaulting to `<CampaignTableHead>{label}</CampaignTableHead>`; declare it only for an alignment, a B22 `description`, or a head island of its own. Each surface declares one `mandatory` column, listed in the menu disabled with its reason and **re-filtered on the server**, so a stale cookie cannot leave a row without its only linked cell. **The real bug review caught is this codebase's recurring one in new clothing:** the RSC payload of toggle A arrives while B is still inside the debounce window, and adopting the server there undid B **and then committed the set without it**, losing it silently — reconciliation now runs only when no write is pending, pinned in `tests/unit/campaignColumnPicker.unit.spec.ts` (verified red against the previous code). **Worth knowing operationally:** two e2e rounds during the session suggested "`router.refresh()` does not apply the cookie". That was the machine at load average 40 with another worktree compiling — the picker's own spec passes at one and at two workers, and the lesson is that `expect`'s 5 s default does not cover a dev-mode RSC round-trip on a heavy list route with two workers, so the refresh assertions carry their own budget. **The P1 red-e2e ledger row also got wider and the way to read it changed:** `campaignMunicipalities` fails **five** tests at one worker on a stashed clean tree (`:56`, `:125`, `:292`, `:394`, `:446`) against four with this delivery applied, and the failing set differs between runs — so "check those two by name" no longer settles anything; compare the count against a stashed tree. No migration, collection, server action or `Consent`. Plano: [`docs/plans/seletor-colunas-lista-municipios.md`](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**.