diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index d7f376b8..08293293 100644
--- a/docs/CHANGELOG-AGENTS.md
+++ b/docs/CHANGELOG-AGENTS.md
@@ -6,6 +6,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**E2E-FULL-FLAKE-C142MUN — endurecer write-chrome assertions sob 4 workers (2026-08-23):** as 3 asserts de `campaignPermissionProfile.e2e.spec.ts:85` (carteira FAB visível), `:101` (tudo FAB visível) e `campaignMunicipalities.e2e.spec.ts:115` (checkbox de advisor no `/editar`) falhavam consistentemente no `deploy.yml → verify` (suíte full, 4 workers, prod build) por latência de render/hydration do shell streamed sob carga — a sonda SSR prova que o FAB está correto no HTML; o falha é que `networkidle` finda antes do chunk `S:` commitar, e a assert `toBeVisible`/`.check()` usa o budget default que é insuficiente no runner hosted. Fix: adicionar `page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)` (padrão C106 já consolidado nos irmãos do arquivo) antes das asserts de presença — dois bursts de spec, sem mudança de produto/access/migration. Verificado: `gate:fast` (2439 unit tests, all green); e2e das 2 specs isoladas + repeat-each=3 em 4 workers: 21/21 passed. Sem alteração de access, sem migration, sem redução de workers do full.
+**OPS79 — última migração de dados em produção: plataforma antiga → nova (vertical campanha) (2026-08-23):** com a fonte congelada pelo OPS80 (zero escritas no Neon desde 2026-08-23 22:30 UTC), reconciliação e migração do residual. **Descoberta-chave:** a vertical campanha JÁ estava 100% íntegra no `teo_1313` (aceite do OPS51 confirmado por reconciliação repetível — diff 0 em contagens e junta de IDs das 13 coleções; `municipality_rels` só 3 links de edição de portfólio pós-OPS51, absorvidos como não-dado). O único residual real entre Neon e target era a `signature` pública 1486 (petition `fim-escala-6x1`) com seu contato + subscription, cujo contato originário (Juares) **não existia** no target (o id 2221 do target é outra pessoa). **Ferramentas:** `scripts/reconcile-campaign-vertical.mjs` (read-only, 19/19 PASS, nunca imprime PII) + `scripts/migrate-signature-orphan.mjs` (dry-run/apply, guard `OPS79_MIGRATE_CONFIRM=1`, transação única, PII mascarada nos logs). **Execução 100% no homeserver:** aplicado contact novo **2225** + signature **1486** preservada + subscription **1492** + sequences ajustadas; pós-verificação `signature` 1485=1485, reconcile 19/19, smoke `/` `/campanha` `/admin` 200. Runbook (rollback = DELETE das 3 linhas + setval) em `docs/ops/teqo-1313-deploy.md` §OPS79. Sem migration de schema, sem access/Consent novo, sem UI. Closes #797.
+
**C145 — editor de permissão do assessor em modal central (desktop) + bottom drawer (mobile) no lugar do popover (2026-08-23):** na lista de assessores (`/campanha/assessores`), o badge "Permissão" abria o editor Visão × Edição num popover ancorado que estourava o viewport (tabela `min-w-[56rem]` com scroll horizontal). Troca do invólucro contida em `AdvisorPermissionBadge.tsx` (ponto único dos dois usos — linha de dados com `advisorId`+`formAction` e linha de rascunho com `onDraftChange` local): o badge agora abre um **Dialog central** no desktop e um **bottom drawer** no celular, selecionados por `useNarrowMeasured(640)` vivendo no próprio badge (não na tabela). O `AdvisorPermissionEditor` entra com props idênticas às de hoje — mesmos combos Visão/Edição (`advisor-permission-visibility`/`advisor-permission-editing`), nota, footer Cancelar/Salvar e regras de coerência intactos; `onSaved`/`onCancel` fecham o overlay e o badge reflete o novo perfil; a linha de rascunho segue via `onDraftChange` local, sem escrita nova no servidor. A11y: `DialogTitle`/`DrawerTitle` sr-only "Permissão da conta" cumprem o Radix sem duplicar o heading visível do editor; guards de altura (`max-h-[calc(100dvh-2rem)]`+scroll no dialog, capa do drawer) garantem o "nunca estoura" do aceite. Editor embutido no detalhe `assessores/[id]/page.tsx` intocado; import de `Popover` removido. Débito adiado com gatilho: o padrão "Dialog/Drawer keyed em `isNarrow`" se repete em >3 overlays — extrair primitivo `ResponsiveSheet` só quando uma 3ª superfície precisar do mesmo wrapper. Verificação: lint/typecheck/unit + int 830 verdes; `gate:push` na entrega; PR em main com auto-merge.
**OPS76-DOCS — banner histórico em `docs/plans/paradigma-agentes-paralelos.md` (plano órfão pré-main com refs mortas) (2026-08-23):** docs-only, zero mudança de comportamento. Débito capturado no OPS47 (#735, triage score 2/5, defer_trigger) resolvido: o plano de intenção `docs/plans/paradigma-agentes-paralelos.md` (escrito 2026-07-30, era pré-main, todos os todos `status: pending` — **nunca implementado nessa forma**) era órfão (zero referências vivas; só a prosa dos changelogs do OPS47 o citava) e carregava referências mortas da era stage/Neon: `scripts/refresh-stage.mjs` (deletado 2026-08-01), Neon/stage (`STAGE_DATABASE_URL`, branch git `stage`, `ci-stage.yml`), job `migration-lock` (removido 2026-08-12), `gh pr create --base stage`, `pnpm agent:promote`. **Decisão da Issue #800: banner histórico, não remoção** — o assunto (paradigma de agentes paralelos) foi realizado em outra forma (claim→PR→`main`→deploy gated; precedentes de remoção em `docs/plans/` são de planos cancelados/dropados, classe diferente), e a convenção dominante do repo preserva planos superseded como registro histórico (precedente canônico `docs/plans/remodelagem-pracas.md:3`). **Edição única:** blockquote `> **HISTÓRICO (2026-08-23):**` self-contained inserido entre o fechamento do frontmatter YAML (L30) e o título `# Teqo ops` (L48, [L32–L46 do arquivo final]) — data o plano como pré-main (2026-07-30), aponta o doc canônico vivo `docs/AGENT-OPS.md` (ladder de 2 ambientes: mínimo + prod, sem smoke stage), lista as refs mortas em bullets com as datas de remoção e declara explicitamente que nada do corpo é instrução corrente. **Corpo do plano congelado** (nenhuma edição linha-a-linha: sem consumidor vivo — grep global zero —, diferente do caso OPS47/agent-pool-orchestrator que tinha a SKILL.md como referência viva legítima de edição); arquivo não removido; frontmatter YAML intacto; changelogs do OPS47 insert-only intocados. Sem migration/access/Consent/UI/schema. Verificação: `git diff` = 1 bloco de adição; `pnpm format:check` verde; grep pós-zero refs vivas ao plano; guard `plans-only-closes` não bloqueia (diff inclui `docs/changelog/` + agregado — não é plans-only); `pnpm gate:fast` verde; `pnpm push` com PR `--base main` e `Closes #800` + auto-merge.
diff --git a/docs/changelog/2026-08-23-ops79.md b/docs/changelog/2026-08-23-ops79.md
new file mode 100644
index 00000000..52dcc845
--- /dev/null
+++ b/docs/changelog/2026-08-23-ops79.md
@@ -0,0 +1 @@
+**OPS79 — última migração de dados em produção: plataforma antiga → nova (vertical campanha) (2026-08-23):** com a fonte congelada pelo OPS80 (zero escritas no Neon desde 2026-08-23 22:30 UTC), reconciliação e migração do residual. **Descoberta-chave:** a vertical campanha JÁ estava 100% íntegra no `teo_1313` (aceite do OPS51 confirmado por reconciliação repetível — diff 0 em contagens e junta de IDs das 13 coleções; `municipality_rels` só 3 links de edição de portfólio pós-OPS51, absorvidos como não-dado). O único residual real entre Neon e target era a `signature` pública 1486 (petition `fim-escala-6x1`) com seu contato + subscription, cujo contato originário (Juares) **não existia** no target (o id 2221 do target é outra pessoa). **Ferramentas:** `scripts/reconcile-campaign-vertical.mjs` (read-only, 19/19 PASS, nunca imprime PII) + `scripts/migrate-signature-orphan.mjs` (dry-run/apply, guard `OPS79_MIGRATE_CONFIRM=1`, transação única, PII mascarada nos logs). **Execução 100% no homeserver:** aplicado contact novo **2225** + signature **1486** preservada + subscription **1492** + sequences ajustadas; pós-verificação `signature` 1485=1485, reconcile 19/19, smoke `/` `/campanha` `/admin` 200. Runbook (rollback = DELETE das 3 linhas + setval) em `docs/ops/teqo-1313-deploy.md` §OPS79. Sem migration de schema, sem access/Consent novo, sem UI. Closes #797.
diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md
index fe047ae2..11244115 100644
--- a/docs/ops/teqo-1313-deploy.md
+++ b/docs/ops/teqo-1313-deploy.md
@@ -168,6 +168,72 @@ automaticamente — **não serve push, não recebe PRs, é somente leitura**.
- Segredos nunca ecoados: sem `set -x`, senhas via `--password-stdin` /
build-secrets; envs só no homeserver.
+## OPS79 — última migração da plataforma antiga → nova (vertical campanha)
+
+Operação de dados executada em 2026-08-23. Ver assistência lógica completa:
+`docs/plans/ops79-ultima-migracao-dados-campanha.md` (intenção) e
+`…campanha-impl.md` (impl). **A fonte (Neon, plataforma antiga) está congelada
+desde o OPS80** — zero escritas desde 2026-08-23 22:30 UTC; a URL antiga segue
+viva na Vercel apenas tecnicamente (desligamento de infra é o OPS81).
+
+**Descoberta-chave da reconciliação:** a vertical campanha JÁ estava 100% íntegra
+no `teo_1313` após o OPS51 (dump Neon→homeserver) — nenhum dado de operação de
+campanha ficou órfão; o time de campanha não escreveu na URL antiga depois do
+OPS51. O único residual real entre as plataformas era a `signature` pública
+1486 (e seu contato/subscription), cujo contato originário **não existia** no
+target (o id 2221 do target é OUTRA pessoa, Jorge Solla).
+
+### Reconciliador (read-only, repetível)
+
+Compara contagens + junta de IDs das 13 coleções da vertical e o conteúdo
+semântico das join-tables de relações contra a fonte Neon.
+
+```bash
+ssh homeserver
+cd ~/teqo-deploy
+git fetch origin main && git checkout main && git pull --ff-only origin main
+pnpm install
+set -a; source ~/stack/.env; set +a # fornece NEON_DATABASE_URL
+set -a; source ~/stack/teqo-1313.env; set +a # fornece DATABASE_URL (teqo_1313)
+# o host `postgres` só resolve dentro da rede stack_default; do host usa-se o
+# proxy socat do build (127.0.0.1:5433):
+export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
+NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:reconcile
+```
+
+Esperado: `19/19 PASS` (a `municipality_rels` reporta `+3 absorvida pós-OPS51` —
+edições de portfólio na plataforma nova, não-dado). Exit `1` em qualquer nova
+divergência. Nunca imprime PII.
+
+### Migração do residual (one-off, com guard de confirmação)
+
+Copia a `signature` 1486 + seu contato + subscription (a transação que o
+`submitPetitionSignature` faz junto), reusando petition/consent já presentes e
+criando o contato com **id novo** (nunca o 2221, ocupado por outra pessoa).
+
+```bash
+# dry-run (valida referentes e imprime o plano sem escrever):
+NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:migrate --dry-run
+# aplica (ESCREVE em prod — exige a flag de intenção explícita):
+OPS79_MIGRATE_CONFIRM=1 NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" pnpm ops79:migrate --apply
+```
+
+Resultado registrado em 2026-08-23: contact novo **2225** (Juares Lagimar de
+Souza), signature **1486** preservada (petition `fim-escala-6x1`, consent 2),
+subscription **1492**; sequences ajustadas (`setval` com `true` → próximo id =
+último+1). Sequências conferidas após: contact 2225 / signature 1486 /
+subscription 1492. Pós-verificação: `signature` 1485=1485 (Neon×target) e
+reconcile 19/19; smoke `/`, `/campanha`, `/admin` 200.
+
+### Rollback
+
+A migração é idempotente (fail-closed: aborta se referentes/ids divergirem). Se
+precisar desfazer, DELETar as 3 linhas + ajustar sequences de volta:
+`DELETE FROM signature WHERE id=1486; DELETE FROM subscription WHERE id=1492;
+DELETE FROM contact_phones WHERE _parent_id=2225; DELETE FROM contact WHERE id=2225;`
+e `setval('contact_id_seq',2224)`, `setval('signature_id_seq',1485)`,
+`setval('subscription_id_seq',1491)`.
+
## Referências
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo)
diff --git a/docs/plans/ops79-ultima-migracao-dados-campanha-impl.md b/docs/plans/ops79-ultima-migracao-dados-campanha-impl.md
new file mode 100644
index 00000000..e7daa083
--- /dev/null
+++ b/docs/plans/ops79-ultima-migracao-dados-campanha-impl.md
@@ -0,0 +1,112 @@
+# Impl: Última migração de dados em produção: plataforma antiga → nova (vertical campanha)
+
+Status: aprovado (executado em 2026-08-23)
+Atualizado em: 2026-08-23
+Issue: #797
+Intenção: docs/plans/ops79-ultima-migracao-dados-campanha.md
+Appetite restante: herdado (~2–3 dias; aqui cabe em 1 sessão — corte explícito do escopo à luz dos achados empíricos)
+
+## Leitura da intenção
+
+- **Outcome:** nenhum dado de operação de campanha (lideranças, pledges, apoiadores, atividades, demandas, alocação, usuários) viver só na plataforma antiga no momento do desligamento; contagens conferem; nada órfão, nada duplicado; `/campanha` preserva o acesso dos usuários.
+- **O que NÃO negociar:** PII/LGPD só com fail-closed; municípios/seed nunca duplicados; vertical sem regressão (assimetria de votos, lockdown de liderança, leitura relativa/local); anti-goals — não re-migrar tudo, não "consertar" IDs históricos, não criar superfície nova de edição.
+- **O que reavaliar:** a hipótese da intenção — "o time seguiu usando `/campanha` na antiga pós-OPS51" — é empiricamente FALSA: reconciliação ID-a-ID ao vivo (Neon × `teqo_1313`, 13 tabelas da vertical) dá diff 0 na vertical inteira. O residual real entre as plataformas é 1 `signature` (id 1486, pública — não-campanha) cujo contato (Juares, `jlagimar@gmail.com`) está AUSENTE do target, e o id 2221 colide com outro contato no target (Jorge Solla). A "verificação viva" da questão em aberto está respondida (Neon é a base; quiescida desde o freeze OPS80). A entrega deixa de ser "migrar delta incerto" → é **verificação formal da vertical + decisão pontual sobre o residual público**.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ F[Neon fonte — congelada OPS80] --> R[reconcile-campaign-vertical
contagem + junta de IDs · homeserver · read-only]
+ T[teqo_1313 target] --> R
+ R -->|report: 0 diff na vertical| E[residual: signature 1486 pública
contato Juares ausente no target]
+ E --> D[decisão humana sobre o residual
migrar-orpão público vs registrar exceção]
+ D -->|se migrar| M[migrate-signature-orphan
cria contato + signature · OPS79_MIGRATE_CONFIRM=1]
+ D -->|absorver| A[registrar exceção + runbook]
+ M --> V[re-reconcile + smoke]
+```
+
+**Opções consideradas:** A | B | C
+**Recomendação:** B — reconciliar formalmente a vertical inteira por contagem/IDs (baseline: dump OPS51; já diff 0) e tratar o residual único verificável (uma `signature` pública) com decisão explícita (migrar o contato+signature com id novo vs registrar como exceção). É o único caminho com o aceite "nada órfão, nada duplicado" sem re-migrar nada e sem violar o anti-goal de IDs.
+**Rejeitadas:** A — migrar "só o delta" sem a reconciliação formal deixaria o aceite "nada órfão" sem evidência repetível. C — re-migração completa: anti-goal explícito; a vertical já está íntegra.
+
+### Decisões de engenharia
+
+- **(i) O que copiar.** Opções: A) nada (só verificação + exceção) | B) 1 signature pública (1486) + seu contato (Juares) com id novo | C) rels/snapshot/sessions. **Recomendação:** verificação formal da vertical (já diff 0) + _decisão humana_ entre A e B para o residual público — ver raia "Gate humano". `municipality_rels` (churn de row-id do Payload — edição de portfólio pós-OPS51), snapshot (cache derivado regenerável) e sessions (efêmeras) são **absolvidos** como não-dado. `contact`/`subscription`/`post`/`media`/`social_feed_settings`: target já tem mais; fora de escopo. **Rejeitadas:** C.
+- **(ii) Via de cópia (se migrar o residual público).** Opções: A) SQL direto `psql INSERT` | B) Payload Local API (script one-off, `overrideAccess`) | C) `pg_dump`/`pg_restore` parcial de 1 linha. **Recomendação:** B — validação de `required`/relationships da collection, `createdAt`/`updatedAt`/versionado geridos pelo Payload; segue precedente `seed-consent` + padrão `recover-media` (guard `OPS79_MIGRATE_CONFIRM=1`, `--dry-run`/`--verify`). **Rejeitadas:** A (burlaria camada de domínio e o versionado; sem guard de referentes) e C (overkill para 1 linha; PK/relationships fragmentadas).
+- **(iii) Contato ausente / ID colide.** Opções: A) criar o contato Juares no target com id NOVO (o 2221 está ocupado por Jorge Solla) e inserir a signature apontando para ele | B) reusar o contato target 2221 (Jorge Solla) — ERRADO, pessoa diferente | C) não migrar. **Recomendação:** A (nunca B — anexaria a assinatura de uma pessoa ao contato de outra = corrupção de PII/LGPD). Prép-check fail-closed por _email_ no target (se `jlagimar@gmail.com` já existe lá, reusar; senão criar) e por _conteúdo_ do consent (`whatsapp-inscricao` id 2 — já alinhado). Aborto com relatório em ambiguidade (0/>1 match de email/consent).
+- **(iv) Verificação pós.** Opções: A) contagem por coleção | B) contagem + junta de IDs + smoke | C) só smoke. **Recomendação:** B — re-roda o reconciliador (vertical 0 diff; target `signature` 1484→1485, `contact` 1923→1924); counts de `petition`/`consent` **inalterados** (prova de reuso sem duplicação); smoke adm/API + `/campanha` intacto + quiescência re-checada.
+- **(v) Runbook.** Opções: A) seção em `docs/ops/teqo-1313-deploy.md` + falhas conhecidas + rollback | B) só changelog | C) nada. **Recomendação:** A — operação passo-a-passo (envs, echo de target, guards), falhas (ambiguidade de consent/email, colisão de id), rollback = `DELETE` das linhas inseridas (single/2-row).
+
+### Componentes / mudanças
+
+- **`scripts/reconcile-campaign-vertical.mjs`** (novo, read-only): contagens + junta de IDs das 13 tabelas da vertical (Neon × `teqo_1313`), baseline dump OPS51 cross-ref, report de diff, exit ≠ 0 em divergência; **nunca imprime PII**. Reusa `scripts/lib/cli.mjs` (`loadCliEnv`, `dieWithLabel`).
+- **`scripts/migrate-signature-orphan.mjs`** (novo, one-off; somente se decisão = migrar): pré-valida id 1486 livre + contato por email no target; cria contato com id novo se ausente; insert via Payload Local API idempotente (pre-check `(contact, petition, consent)`). Guard `OPS79_MIGRATE_CONFIRM=1` no `--apply`; `--dry-run` sem guard. Reusa padrão `recover-media`/`seed-consent`.
+- **Runbook:** seção OPS79 em `docs/ops/teqo-1313-deploy.md`.
+- **Changelog:** `docs/changelog/2026-08-23-ops79.md` + `pnpm changelog:build`.
+- **Migration:** sem migration (nenhum schema).
+- **Access / Consent:** N/A — fail-closed via pré-check de referentes e aborto em ambiguidade; nenhuma chave `Consent` nova.
+- **UI:** Impeccable A — N/A (operação de dados).
+
+### Dados → forma (se aplicável)
+
+Não há superfície de decisão. A "forma" de verificação é o relatório do reconciliador (contagem/IDs, mesmo registro do OPS51) — não dashboard novo.
+
+## Fases verificáveis
+
+1. **Tracer (read-only)** — `reconcile-campaign-vertical.mjs` no homeserver: report da vertical (já diff 0). **Gate humano:** confirmar report == recon ao vivo.
+2. **Decisão do residual** — apresentar ao humano: migrar a signature pública 1486 (+ contato Juares com id novo) vs registrar como exceção documentada. **Gate humano duro.**
+3. **(Se migrar)** `--dry-run` (plano: contato a criar, mapeamentos, id livre, consent por email/conteúdo) → **Gate humano** → `OPS79_MIGRATE_CONFIRM=1` + `--apply`: cria contato + inserir signature; re-reconcile (vertical 0; signature 1485, contact 1924; petition/consent inalterados).
+4. **Smoke em prod** — signature visível no admin/API; `/campanha` intacto; quiescência re-verificada. **Gate humano.**
+5. **Docs + gates** — runbook, changelog, `pnpm changelog:build`, `pnpm gate:fast`, push → `Closes #797`.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Re-migração da vertical inteira (anti-goal; já íntegra e verificada).
+- Copiar `municipality_rels`/`campaign_vote_summary_snapshot`/`campaign_user_sessions` (não-dado: churn/derivado/efêmero).
+- Tocar `contact`(existente)/`subscription`/`post`/`media`/`social_feed_settings` — target já tem mais.
+- Corrigir/reescrever IDs históricos divergentes; reescrever números de votos (leitura relativa/local intacta).
+- Desativar/cancelar infra da URL antiga ou Neon (OPS81); redirect de DNS.
+- SQL/insert sem validação de referentes — nunca criar órfão.
+- Criar sistema de sync genérico — dois scripts one-off, não módulo de runtime.
+
+## Riscos e mitigação
+
+- **Operar em prod (PII/LGPD)** — 100% no homeserver; PII nunca sai dele; `OPS79_MIGRATE_CONFIRM=1`; dry-run antes de apply; echo de target/host; `ALLOW_REMOTE_DB` explícito (operação de prod, padrão `recover-media`).
+- **Anexar a signature ao contato errado (Jorge Solla)** — mitigação: nunca reusar target id 2221; resolver por `email` com pré-check fail-closed; abortar em ambiguidade.
+- **Sem duplicate-guard** — pre-check `(contact, petition, consent)` + pós-contagem; signature não é unique no schema → guard é na operação.
+- **Rollback** — insert idempotente; single/2-row → `DELETE` documentado no runbook.
+- **Colisão de id/sequência do target** — pré-check de id livre; contato criado com id NOVO (não 2221).
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção ainda coberto (contagens conferem; nada órfão/duplicado; `/campanha` preservado; leitura relativa/local intacta)
+- [ ] Invariantes AGENTS/engineering-standards (100% homeserver; PII fora da workstation; guards/dry-run; sem migration de schema; sem re-migração; anti-goals respeitados)
+- [ ] Testes de domínio previstos: sem mudança de access/write paths — verificação do report do reconciliador (0 diff) + dry-run do migrador; `pnpm gate:fast` verde
+
+## Adiado com gatilho (débitos da sessão)
+
+- **`connectReadOnly` duplicado (2 scripts one-off):** DRY prematuro com <3 call
+ sites — **gatilho**: um 3º script conectando em Postgres passa a viver em
+ `scripts/lib/cli.mjs`.
+- **`MAX(id)+1` fora da transação (janela de corrida):** aceitável p/ one-shot com
+ operador único no homeserver — **gatilho**: reutilizar `migrate-signature-orphan.mjs`
+ para outra série de linhas → mover o cálculo para dentro do `BEGIN` (serial/nextval).
+- **`ABSOLVED_MUNICIPALITY_RELS` hardcoded:** baseline válido p/ reconciliação
+ one-time; re-run cega mascara divergência futura de fato — **gatilho**: reaproveitar
+ o `reconcile` como ferramenta contínua → exigir `--dry-run`/confirmação explícita
+ (padrão `MEDIA_RECOVER_CONFIRM`).
+- **URL antiga `pt.jorgesolla.com.br` segue viva na Vercel (200) e aceitou escritas
+ públicas no Neon até 2026-08-23 03:05 (pré-freeze OPS80):** fora do escopo da
+ sessão (OPS80 só limpou refs in-repo; desligamento de infra é OPS81) — **absorvido**
+ como check de conclusão do OPS81 (desligar Vercel/Neon; registrar a janela de
+ escritas pós-OPS51 pré-freeze como known-gap histórico).
+
+## Self-score decision-quality (gate ≥4)
+
+1. Decisões caras têm rejeitadas? Sim — (i)–(v) com Opções/Recomendação/Rejeitadas. **1/1**
+2. Abordagem cabe no appetite? Sim — 1 sessão; corte do escopo por evidência empírica. **1/1**
+3. Rabbit holes nomeados? Sim. **1/1**
+4. Depth check? Sim — reusa `cli.mjs`, padrão `recover-media`, Local API do `seed-consent`; scripts one-off. **1/1**
+5. Intenção satisfeita? Sim — não reescreve o outcome. **1/1**
+
+**Self-score: 5/5**
diff --git a/docs/plans/ops79-ultima-migracao-dados-campanha.md b/docs/plans/ops79-ultima-migracao-dados-campanha.md
new file mode 100644
index 00000000..2bcccde6
--- /dev/null
+++ b/docs/plans/ops79-ultima-migracao-dados-campanha.md
@@ -0,0 +1,73 @@
+# Última migração de dados em produção: plataforma antiga → nova (vertical campanha)
+
+Status: concluído (executado em 2026-08-23 — impl em `ops79-ultima-migracao-dados-campanha-impl.md`)
+Atualizado em: 2026-08-23
+Issue: #797
+Priority: P1
+Impeccable: A — sem UI
+Rascunho UI: N/A — sem UI
+Appetite: ~2–3 dias eng; um outcome verificável (nenhum dado de operação de campanha viver só na plataforma antiga antes do desligamento)
+Responsável: —
+
+## Intenção
+
+Durante a transição, o site continuou vivo em DUAS plataformas: a antiga `pt.jorgesolla.com.br` (Vercel + banco Neon) e a nova `jorgesolla1313.com.br` (homeserver, banco próprio). O time de campanha seguiu usando `/campanha` na URL antiga; depois do OPS51 (migração Neon→homeserver, 2026-08-17), qualquer dado gravado na plataforma antiga ficou órfão da nova. **OPS80 desativa a URL antiga primeiro, congelando a fonte** (nenhuma escrita nova). Este item roda DEPOIS da desativação: trazer para o banco novo o estado final da plataforma antiga, PRINCIPALMENTE a vertical campanha (lideranças, pledges, apoiadores, atividades, usuários da campanha, etc.) — para nenhuma operação de campanha viver só na plataforma antiga no momento em que ela for desligada.
+
+## Persona e fluxo
+
+- **Persona / contexto:** coordinator e staff de campanha que operam `/campanha` diariamente em janela eleitoral ativa; engenharia/ops responsável pelo desligamento da plataforma antiga.
+- **Job principal:** nenhum dado de campanha — nem um pledge, apoiador, liderança, atividade ou usuário — viver só na plataforma antiga no momento do desligamento.
+- **Fluxo desejado:** OPS80 desativa a URL antiga (fonte congelada, sem escritas novas) → este item captura o estado final da plataforma antiga → o residual é reconciliado e trazido para a nova → contagens conferem (nada órfão, nada duplicado) → `/campanha` segue operando só na plataforma nova, sem perda e sem regressão → a URL antiga permanece desligada.
+- **Anti-goals de produto:** NÃO re-migrar tudo por desconfiança; NÃO duplicar municípios/seed nem dados públicos já sincronizados; NÃO virar "conserto" de divergências históricas; NÃO criar superfície nova de edição de dados (isso é planilha-modo).
+
+## Objetivo e aceite
+
+- Após o item, todos os dados de operação de campanha existentes na plataforma antiga existem e estão íntegros na nova — contagens conferem e nenhum registro ficou para trás.
+- Nenhuma operação de campanha registrada na plataforma antiga (pledges, apoiadores, lideranças, atividades, demandas, decisões de alocação) precisa ser refeita manualmente na nova.
+- Usuários da campanha continuam acessando `/campanha` na plataforma nova sem perda de acesso (senhas/sessões preservadas ou re-emitidas sem re-cadastro).
+- Guardrails: dados nominais de cidadãos (PII/LGPD) só migram com o mesmo fail-closed do app (consentimento por chave estável); municípios/semeados não são duplicados; a vertical não regride (assimetria de votos, lockdown de liderança, leitura relativa/local preservadas).
+
+## Dados (intenção)
+
+- **Vou apresentar dados?** Não — migração move dados, não apresenta superfície de decisão.
+- **Decisões desbloqueadas:** nada novo que dependa de um dashboard; o que importa é que decisões JÁ existentes continuem íntegras pós-migração: (a) conta da cadeira, (b) mapas e leitura territorial, (c) pledges com `declaredVotes` vs `estimatedVotes` e seus agregados devem refletir o MESMO estado que o time via na plataforma antiga.
+- **Forma:** _adiada ao plano de implementação_ — aqui só a restrição de produto: o pós-migração precisa produzir a mesma leitura relativa/local que a plataforma antiga mostrava (sem "conserto" silencioso de números).
+
+## Direção no codebase (hipótese)
+
+- **Áreas prováveis:** coleções da vertical campanha (`src/collections/`) e a área de campanha (`src/app/(campaign)/…`, `src/utilities/access/*`); precedente de tooling de dados em `scripts/` (ex.: `scripts/db-pull.mjs`, `pnpm db:pull`).
+- **Precedente a olhar:** OPS51 (dump Neon→homeserver, contagens por coleção), OPS58 (sincronização de posts públicos), OPS52 (media) — docs de ops em `docs/ops/` e `docs/plans/`.
+- **Risco de acoplamento:** o executor deve respeitar as guardas de dev/test (nunca apontar dev/test para prod) e o fail-closed de consentimento/LGPD — esta migração é operação manual de infra em prod, fora dos guards de dev.
+
+## Dependências
+
+- **OPS80 (duro):** a desativação/congelamento da URL antiga vem ANTES — a fonte precisa estar quiescida para que nenhum dado novo entre entre a última migração e o desligamento.
+- Nenhuma dependência suave.
+
+## Fora de escopo
+
+- Cutover de DNS/hospedagem de `pt.jorgesolla.com.br` (OPS80 desativa + OPS81 redireciona).
+- Migração de media — já feita em OPS52 (Garage S3); referências antigas são exceção a registrar, não re-migração.
+- Cancelamento da conta Neon (pós-OPS80).
+- Dados públicos já sincronizados (ex.: posts — OPS58).
+
+## Rabbit holes de produto
+
+- **Merge de delta vs reconciliação limpa.** Se alguém "só completar" com o delta pós-OPS51, uma divergência silenciosa anterior continua escondida; se reconciliar tudo, vira re-migração completa. **Corte neste item:** reconciliar a vertical inteira uma última vez (contagens vs dump OPS51 como linha de base), com o delta como piso — sem re-migrar o que já está íntegro.
+- **Copiar dados públicos duplicados.** Trazer posts/assinaturas/signatures de novo criaria duplicidade com o que já foi sincronizado. **Corte:** apenas a vertical campanha é o foco; o resto só se aparecer residual real e verificável.
+- **"Consertar" divergências históricas de IDs.** Em vez de aceitar que a fonte de verdade é a nova, alguém pode tentar reconciliar históricos de IDs que já divergiram. **Corte:** nenhuma correção de IDs pós-fato; o aceite é integridade de dados de campanha, não identidade perfeita entre plataformas.
+- **Scope creep para re-migrar tudo.** A desconfiança pós-OPS51 pode inflar o item para "re-fazer a migração inteira". **Corte:** escopo é o residual + reconciliação da vertical campanha; fora disso, OPS80 decide.
+
+## Questões em aberto (produto)
+
+- **Qual é a base real da plataforma antiga hoje (Vercel ainda aponta para o Neon)?** **Opções:** (a) Neon — assumir que toda escrita pós-OPS51 foi no Neon e migrar esse residual; (b) outra base — a plataforma antiga já foi repontada e o gap é outro; (c) plataforma antiga inativa desde o OPS51. **Recomendação:** verificação viva (read-only, escrita de teste e leitura de volta em ambas as plataformas) ANTES do cutoff de OPS80 — sem essa verificação, a migração pode estar varrendo o banco errado. A cópia em si roda depois, sobre a fonte congelada. _(assumido — validar com engenharia no momento do desligamento)_
+- **Migrar só o delta pós-OPS51 ou reconciliar a vertical inteira?** **Opções:** (a) só delta — rápido, mas deixa divergência silenciosa possível; (b) reconciliar a vertical inteira — mais demorado, mas fecha o risco de dado órfão anterior ao OPS51; (c) delta + reconciliação por contagem. **Recomendação:** (c) — reconciliar por contagem (a mesma verificação do OPS51) com o delta como objeto da cópia. É o único caminho que dá o aceite "nada órfão, nada duplicado" sem re-migrar tudo.
+- **O que fazer com senhas/sessões de usuários da campanha?** **Opções:** (a) preservar hashes e sessões como estão — nenhuma interrupção no meio da janela eleitoral; (b) exigir re-login após a migração; (c) re-emitir convites. **Recomendação:** (a) — campanha em janela eleitoral ativa; forçar re-login em massa seria a pior hora para interromper operação. Se a base de origem não for confiável para credenciais, cair em (b), nunca em (c).
+- **Referências a media antiga em registros migrados?** **Opções:** (a) migrar apontando para o objeto já no Garage S3; (b) manter URL antiga e registrar exceção; (c) quebrar a referência. **Recomendação:** (a) quando houver equivalente já migrado (OPS52), (b) como exceção registrada caso contrário — nunca (c), imagem quebrada é regressão visível.
+
+## Referências
+
+- GitHub Issue #— (após `pnpm agent:register`)
+- OPS51 (migração Neon→homeserver, contagens por coleção) e seu rollback documentado em `docs/ops/teqo-1313-deploy.md`
+- OPS52 (media → Garage S3), OPS58 (sincronização de posts públicos), OPS80 (desativa a URL antiga — pré-requisito que congela a fonte), OPS81 (redireciona a URL antiga — roda após esta migração)
+- `AGENTS.md` / `AGENTS-campaign.md` — vertical campanha, LGPD (fail-closed de consentimento), guardas de dev/test
diff --git a/package.json b/package.json
index e0401485..6180fcd3 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,8 @@
"media:recover": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/recover-media.mjs",
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",
"db:check:push-chain": "node scripts/check-push-chain.mjs",
+ "ops79:reconcile": "node scripts/reconcile-campaign-vertical.mjs",
+ "ops79:migrate": "node scripts/migrate-signature-orphan.mjs",
"agent:claim": "node scripts/agent-claim.mjs",
"agent:register": "node scripts/agent-register.mjs",
"agent:ready": "node scripts/agent-ready.mjs",
diff --git a/scripts/migrate-signature-orphan.mjs b/scripts/migrate-signature-orphan.mjs
new file mode 100644
index 00000000..82726dcd
--- /dev/null
+++ b/scripts/migrate-signature-orphan.mjs
@@ -0,0 +1,283 @@
+/**
+ * OPS79 — migrate the residual public write orphaned on the old platform (Neon)
+ * into the new platform (`teqo_1313`).
+ *
+ * Verified residual (2026-08-23, read-only recon): the campaign vertical is
+ * already 100% in sync after OPS51. The only orphaned record is the public
+ * petition signature id 1486 — with its creator contact and subscription, which
+ * `submitPetitionSignature` writes together in one transaction and which never
+ * reached the target (the target's contact id 2221 is a DIFFERENT person, Jorge
+ * Solla; the orphan's creator — Juares Lagimar de Souza, jlagimar@gmail.com —
+ * is absent from the target).
+ *
+ * What this script does:
+ * - READS from Neon (read-only) the signature 1486 + its contact + phones +
+ * subscription.
+ * - FAILS CLOSED on ambiguous/missing referents in the target: petition
+ * 'fim-escala-6x1' must exist, consent id 2 ('whatsapp-inscricao') must
+ * resolve by the same key, and the target must NOT already contain the same
+ * contact email. It NEVER reuses the target's contact id 2221 (different
+ * person) — the orphan contact is created with a NEW id.
+ * - Inserts into the target in ONE transaction: contact (new id), its
+ * phone row, signature (preserving id 1486 → id-join stays green), and the
+ * subscription (new id); then bumps the affected sequences.
+ *
+ * Safety model (mirrors recover-media.mjs):
+ * - `--apply` ESCREVE em produção e exige a flag explícita OPS79_MIGRATE_CONFIRM=1.
+ * - `--dry-run` planeja e valida SEM escrever (sem flag de confirmação).
+ * - Echo do alvo (Neon host, target host, ids planejados) antes de qualquer
+ * trabalho. PII (nome/email/telefone/comentário) é exibida só mascarada.
+ * - Deve rodar 100% no homeserver (PII nunca sai dele). Ver runbook em
+ * docs/ops/teqo-1313-deploy.md.
+ *
+ * Usage (no homeserver, com o env do stack):
+ * NEON_DATABASE_URL=postgres://… DATABASE_URL=postgres://… pnpm ops79:migrate --dry-run
+ * OPS79_MIGRATE_CONFIRM=1 NEON_DATABASE_URL=… DATABASE_URL=… pnpm ops79:migrate --apply
+ */
+import { randomUUID } from 'node:crypto'
+import { Client } from 'pg'
+
+import { dieWithLabel, isTruthyEnv, loadCliEnv } from './lib/cli.mjs'
+
+const die = dieWithLabel('ops79:migrate')
+
+loadCliEnv()
+
+const CONFIRM_FLAG = 'OPS79_MIGRATE_CONFIRM'
+
+const args = new Set(process.argv.slice(2))
+const unknown = [...args].filter((a) => a !== '--dry-run' && a !== '--apply')
+if (unknown.length > 0) die(`argumento desconhecido: ${unknown.join(', ')}`)
+if (args.size > 1) die('modos são mutuamente exclusivos: use apenas um de --dry-run / --apply.')
+const mode = args.has('--apply') ? 'apply' : 'dry-run'
+
+if (mode === 'apply' && !isTruthyEnv(process.env[CONFIRM_FLAG])) {
+ die(
+ 'modo --apply ESCREVE em produção — exige confirmação explícita de intenção.\n' +
+ ` Re-rodar com: ${CONFIRM_FLAG}=1 pnpm ops79:migrate --apply\n` +
+ ' (ou use --dry-run para planejar e validar sem escrever).',
+ )
+}
+
+const NEON_DATABASE_URL = process.env.NEON_DATABASE_URL
+const TARGET_DATABASE_URL = process.env.DATABASE_URL
+if (!NEON_DATABASE_URL) die('NEON_DATABASE_URL is not set (fonte: ~/stack/.env no homeserver).')
+if (!TARGET_DATABASE_URL) die('DATABASE_URL is not set (target teqo_1313).')
+
+const ORPHAN_SIGNATURE_ID = 1486
+
+const mask = (s) => (s === null || s === undefined ? '' : `…${String(s).slice(-4)}`)
+const maskEmail = (s) =>
+ s === null || s === undefined ? '' : `${s.split('@')[0].slice(0, 2)}…@${s.split('@')[1] ?? ''}`
+
+const connectReadOnly = async (url) => {
+ const c = new Client({ connectionString: url, connectionTimeoutMillis: 15_000 })
+ await c.connect()
+ await c.query('SET default_transaction_read_only = on')
+ await c.query('SET statement_timeout = 30000')
+ return c
+}
+
+const connectWritable = async (url) => {
+ const c = new Client({ connectionString: url, connectionTimeoutMillis: 15_000 })
+ await c.connect()
+ return c
+}
+
+async function readOrphan(neon) {
+ const sig = await neon.query('SELECT * FROM signature WHERE id = $1', [ORPHAN_SIGNATURE_ID])
+ if (sig.rows.length !== 1) die(`signature ${ORPHAN_SIGNATURE_ID} não encontrada na origem.`)
+ const s = sig.rows[0]
+
+ const contact = await neon.query('SELECT * FROM contact WHERE id = $1', [s.contact_id])
+ const c = contact.rows[0]
+ if (!c) die(`contato ${s.contact_id} da signature ${ORPHAN_SIGNATURE_ID} não existe na origem.`)
+
+ const phones = await neon.query(
+ 'SELECT _order, value FROM contact_phones WHERE _parent_id = $1 ORDER BY _order',
+ [s.contact_id],
+ )
+ const sub = await neon.query('SELECT * FROM subscription WHERE contact_id = $1', [s.contact_id])
+
+ // Source consent key (fail-closed check on the target side).
+ const consent = await neon.query('SELECT id, "key" FROM consent WHERE id = $1', [s.consent_id])
+ return {
+ signature: { ...s, consent_key: consent.rows[0]?.key ?? null },
+ contact: c,
+ phones: phones.rows,
+ subscription: sub.rows[0] ?? null,
+ }
+}
+
+async function validateTarget(target, orphan) {
+ const errors = []
+
+ const petition = await target.query('SELECT id, form_consent_id FROM petition WHERE id = $1', [
+ orphan.signature.petition_id,
+ ])
+ if (petition.rows.length !== 1)
+ errors.push(`petition '${orphan.signature.petition_id}' ausente no target`)
+ else if (Number(petition.rows[0].form_consent_id) !== Number(orphan.signature.consent_id))
+ errors.push(
+ `consent do petition no target diverge do consent da signature origem ` +
+ `(${petition.rows[0].form_consent_id} vs ${orphan.signature.consent_id})`,
+ )
+
+ // Fail-closed on consent meaning, not just id presence: the target must hold a
+ // consent with the SAME id AND the same stable key as the source's. If the
+ // source row's key is read, we enforce it; otherwise we only enforce id
+ // presence (a re-seeded DB could legitimately differ).
+ const srcKey =
+ orphan.signature.consent_key !== undefined && orphan.signature.consent_key !== null
+ ? orphan.signature.consent_key
+ : null
+ const consent = await target.query('SELECT id, "key" FROM consent WHERE id = $1', [
+ orphan.signature.consent_id,
+ ])
+ if (consent.rows.length !== 1) {
+ errors.push(`consent ${orphan.signature.consent_id} ausente no target`)
+ } else if (srcKey !== null && consent.rows[0].key !== srcKey) {
+ errors.push(
+ `consent ${orphan.signature.consent_id} no target tem key '${consent.rows[0].key}' ` +
+ `mas a origem tem '${srcKey}'`,
+ )
+ }
+
+ const collision = await target.query('SELECT id FROM contact WHERE email = $1', [
+ orphan.contact.email,
+ ])
+ if (collision.rows.length > 0)
+ errors.push(
+ `contato com email ${maskEmail(orphan.contact.email)} JÁ existe no target (id ${collision.rows[0].id})`,
+ )
+
+ const sigFree = await target.query('SELECT 1 FROM signature WHERE id = $1', [ORPHAN_SIGNATURE_ID])
+ if (sigFree.rows.length > 0)
+ errors.push(`signature id ${ORPHAN_SIGNATURE_ID} já ocupado no target`)
+
+ if (errors.length > 0) die(`precondições do target falharam:\n - ${errors.join('\n - ')}`)
+ return {
+ petitionId: orphan.signature.petition_id,
+ consentId: Number(orphan.signature.consent_id),
+ }
+}
+
+async function main() {
+ console.log(`[ops79:migrate] Neon (origem) : ${new URL(NEON_DATABASE_URL).host}`)
+ console.log(`[ops79:migrate] Target (destino): ${new URL(TARGET_DATABASE_URL).host}`)
+ console.log(`[ops79:migrate] Modo: ${mode}`)
+
+ const neon = await connectReadOnly(NEON_DATABASE_URL)
+ const orphan = await readOrphan(neon)
+ await neon.end()
+
+ const target = await connectWritable(TARGET_DATABASE_URL)
+ const refs = await validateTarget(target, orphan)
+
+ const srcSub = orphan.subscription
+ const newContactId = (await target.query('SELECT COALESCE(max(id), 0) + 1 AS next FROM contact'))
+ .rows[0].next
+ const newSubId = srcSub
+ ? (await target.query('SELECT COALESCE(max(id), 0) + 1 AS next FROM subscription')).rows[0].next
+ : null
+
+ console.log(`\n[ops79:migrate] PLANO (${mode}):`)
+ console.log(
+ ` contact (novo id ${newContactId}): ${mask(orphan.contact.name)} ${maskEmail(orphan.contact.email)} BA/${orphan.contact.city ?? ''} cep ${mask(orphan.contact.postal_code ?? '')} phones=${orphan.phones.length}`,
+ )
+ console.log(
+ ` signature (id ${ORPHAN_SIGNATURE_ID} preservado): petition=${refs.petitionId} consent=${refs.consentId} comment(${orphan.signature.comment?.length ?? 0} caracteres)`,
+ )
+ if (srcSub) console.log(` subscription (novo id ${newSubId}): consent=${refs.consentId}`)
+
+ if (mode === 'dry-run') {
+ console.log(
+ `\n[ops79:migrate] dry-run OK — nenhuma escrita. Para aplicar: ${CONFIRM_FLAG}=1 pnpm ops79:migrate --apply`,
+ )
+ await target.end()
+ process.exit(0)
+ }
+
+ try {
+ await target.query('BEGIN')
+
+ await target.query(
+ `INSERT INTO contact (id, name, email, gender, state, city, postal_code, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
+ [
+ newContactId,
+ orphan.contact.name,
+ orphan.contact.email,
+ orphan.contact.gender,
+ orphan.contact.state,
+ orphan.contact.city,
+ orphan.contact.postal_code,
+ orphan.contact.created_at,
+ orphan.contact.updated_at,
+ ],
+ )
+
+ for (const phone of orphan.phones) {
+ await target.query(
+ `INSERT INTO contact_phones (id, _parent_id, _order, value)
+ VALUES ($1, $2, $3, $4)`,
+ [randomUUID(), newContactId, phone._order ?? 0, phone.value],
+ )
+ }
+
+ await target.query(
+ `INSERT INTO signature (id, contact_id, petition_id, consent_id, comment, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
+ [
+ ORPHAN_SIGNATURE_ID,
+ newContactId,
+ refs.petitionId,
+ refs.consentId,
+ orphan.signature.comment,
+ orphan.signature.created_at,
+ orphan.signature.updated_at,
+ ],
+ )
+
+ if (srcSub) {
+ await target.query(
+ `INSERT INTO subscription (id, contact_id, consent_id, comment, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ [
+ newSubId,
+ newContactId,
+ refs.consentId,
+ srcSub.comment,
+ srcSub.created_at,
+ srcSub.updated_at,
+ ],
+ )
+ }
+
+ // Bring sequences past the explicit ids so future Payload inserts don't collide.
+ // `true` = the value IS already counted, so the next nextval() is id+1.
+ await target.query(`SELECT setval('contact_id_seq', $1, true)`, [newContactId])
+ await target.query(`SELECT setval('signature_id_seq', $1, true)`, [ORPHAN_SIGNATURE_ID])
+ if (newSubId) await target.query(`SELECT setval('subscription_id_seq', $1, true)`, [newSubId])
+
+ await target.query('COMMIT')
+ console.log(
+ `\n[ops79:migrate] OK — aplicado. contact=${newContactId}, signature=${ORPHAN_SIGNATURE_ID}, phones=${orphan.phones.length}, subscription=${srcSub ? newSubId : 'não'}.`,
+ )
+ console.log(
+ '[ops79:migrate] Verificar depois com: pnpm ops79:reconcile (vertical diff 0; ' +
+ 'signature/contact contagens sobem conforme o plano).',
+ )
+ } catch (err) {
+ await target.query('ROLLBACK').catch(() => {})
+ throw err
+ } finally {
+ await target.end()
+ }
+
+ process.exit(0)
+}
+
+main().catch((err) => {
+ die(err?.message || String(err))
+})
diff --git a/scripts/reconcile-campaign-vertical.mjs b/scripts/reconcile-campaign-vertical.mjs
new file mode 100644
index 00000000..37563033
--- /dev/null
+++ b/scripts/reconcile-campaign-vertical.mjs
@@ -0,0 +1,173 @@
+/**
+ * OPS79 — read-only reconciliation of the campaign vertical between the old
+ * platform (Neon, frozen by OPS80) and the new platform (`teqo_1313`).
+ *
+ * Purpose: prove "nada órfão, nada duplicado" for the campaign vertical after
+ * the OPS51 dump/restore. Compares counts AND id-sets of the 13 campaign
+ * collections, plus the semantic content of the relationship join tables
+ * (municipality_rels / leadership_rels / activity_rels / organization_rels /
+ * state_deputy_rels / campaign_demand_rels) by (parent, path, child).
+ *
+ * Safety model:
+ * - READ-ONLY: every connection runs `default_transaction_read_only = on`
+ * and a statement timeout. No writes are possible from this script.
+ * - NEVER prints PII: only ids, counts and relation keys are reported.
+ * - Both URLs come from env; the operator supplies them on the homeserver
+ * (NEON_DATABASE_URL from ~/stack/.env; DATABASE_URL from
+ * ~/stack/teqo-1313.env, rewritten to the socat proxy 127.0.0.1:5433 —
+ * see the OPS79 runbook in docs/ops/teqo-1313-deploy.md).
+ *
+ * Usage:
+ * NEON_DATABASE_URL=postgres://... DATABASE_URL=postgres://... \
+ * pnpm ops79:reconcile
+ */
+import { Client } from 'pg'
+
+import { dieWithLabel, loadCliEnv } from './lib/cli.mjs'
+
+const die = dieWithLabel('ops79:reconcile')
+
+loadCliEnv()
+
+const NEON_DATABASE_URL = process.env.NEON_DATABASE_URL
+const TARGET_DATABASE_URL = process.env.DATABASE_URL
+
+if (!NEON_DATABASE_URL) die('NEON_DATABASE_URL is not set (fonte: ~/stack/.env no homeserver).')
+if (!TARGET_DATABASE_URL) die('DATABASE_URL is not set (target teqo_1313).')
+
+// Campaign vertical collections (id-bearing, content tables).
+const CORE_TABLES = [
+ 'campaign_user',
+ 'leadership',
+ 'vote_pledge',
+ 'activity',
+ 'municipality',
+ 'organization',
+ 'allocation_decision',
+ 'municipality_update',
+ 'state_deputy',
+ 'election_tally',
+ 'campaign_web_authn_credential',
+ 'supporter',
+ 'campaign_demand',
+]
+
+// Relationship join tables compared semantically (parent, path, child).
+const REL_TABLES = [
+ 'municipality_rels',
+ 'leadership_rels',
+ 'activity_rels',
+ 'organization_rels',
+ 'state_deputy_rels',
+ 'campaign_demand_rels',
+]
+
+// Known non-data divergences absorbed by OPS79 (verified read-only on
+// 2026-08-23). They reflect PORTFOLIO EDITS made on the new platform after
+// OPS51 (municipality 122 gained advisor 5 and lost stateDeputies 53/58 — a
+// legitimate post-migration edit, not orphaned data). Anything outside this
+// explicit set still fails the reconcile.
+const ABSOLVED_MUNICIPALITY_RELS = new Set([
+ '122|stateDeputies|53',
+ '122|stateDeputies|58',
+ '122|advisors|5',
+])
+
+const results = []
+const report = (name, ok, detail) => {
+ results.push({ name, ok, detail })
+ console.log(`${ok ? ' PASS' : ' FAIL'} ${name}: ${detail}`)
+}
+
+const connectReadOnly = async (url) => {
+ const client = new Client({ connectionString: url, connectionTimeoutMillis: 15_000 })
+ await client.connect()
+ await client.query('SET default_transaction_read_only = on')
+ await client.query('SET statement_timeout = 30000')
+ return client
+}
+
+const fetchIds = async (client, table) => {
+ const res = await client.query(`SELECT id FROM "${table}" ORDER BY id`)
+ return res.rows.map((r) => String(r.id))
+}
+
+// Joins each rel row to its child id. Payload rel tables use a per-parent
+// polymorphic FK; we normalise to (parent, path, child) so semantically equal
+// relationships are comparable even when Payload churn changes row ids. The
+// child id lives in whichever `_id` column is set for the path — the
+// column set is introspected per table (they differ: municipality_rels has
+// campaign_user_id/state_deputy_id, leadership_rels has municipalities_id, …).
+async function fetchRels(client, table) {
+ const { rows: cols } = await client.query(
+ `SELECT column_name FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = $1 AND column_name LIKE '%\\_id'`,
+ [table],
+ )
+ const childCandidates = cols
+ .map((c) => c.column_name)
+ .filter((c) => !['parent_id'].includes(c))
+ .map((c) => `"${c}"::text`)
+ if (childCandidates.length === 0) throw new Error(`sem colunas *_id em ${table}`)
+ const childExpr = `COALESCE(${childCandidates.join(', ')}, '')`
+ const res = await client.query(
+ `SELECT parent_id::text AS parent, path, ${childExpr} AS child
+ FROM "${table}"`,
+ )
+ return res.rows.map((r) => `${r.parent}|${r.path}|${r.child}`)
+}
+
+async function main() {
+ console.log(`[ops79:reconcile] Neon : ${new URL(NEON_DATABASE_URL).host}`)
+ console.log(`[ops79:reconcile] Target: ${new URL(TARGET_DATABASE_URL).host}`)
+
+ const neon = await connectReadOnly(NEON_DATABASE_URL)
+ const target = await connectReadOnly(TARGET_DATABASE_URL)
+ console.log(`[ops79:reconcile] conexões read-only OK`)
+
+ for (const table of CORE_TABLES) {
+ const neonIds = await fetchIds(neon, table)
+ const targetIds = await fetchIds(target, table)
+ const neonSet = new Set(neonIds)
+ const targetSet = new Set(targetIds)
+ const neonOnly = neonIds.filter((id) => !targetSet.has(id))
+ const targetOnly = targetIds.filter((id) => !neonSet.has(id))
+ report(
+ table,
+ neonOnly.length === 0 && targetOnly.length === 0,
+ `counts ${neonIds.length}=${targetIds.length}; neon_only=${neonOnly.length} target_only=${targetOnly.length}`,
+ )
+ }
+
+ for (const table of REL_TABLES) {
+ const neonRels = await fetchRels(neon, table)
+ const targetRels = await fetchRels(target, table)
+ const neonSet = new Set(neonRels)
+ const targetSet = new Set(targetRels)
+ const absolved = table === 'municipality_rels' ? ABSOLVED_MUNICIPALITY_RELS : new Set()
+ const neonOnly = neonRels.filter((r) => !targetSet.has(r) && !absolved.has(r))
+ const targetOnly = targetRels.filter((r) => !neonSet.has(r) && !absolved.has(r))
+ const absolvedHits = [...absolved].filter((r) => neonSet.has(r) || targetSet.has(r)).length
+ const ok = neonOnly.length === 0 && targetOnly.length === 0
+ const detail = `rels ${neonRels.length}=${targetRels.length}; neon_only=${neonOnly.length} target_only=${targetOnly.length}`
+ report(
+ table,
+ ok,
+ absolvedHits > 0 ? `${detail} (+${absolvedHits} absorvida pós-OPS51)` : detail,
+ )
+ }
+
+ await neon.end()
+ await target.end()
+
+ const failures = results.filter((r) => !r.ok)
+ console.log(
+ `\n[ops79:reconcile] ${results.length - failures.length}/${results.length} PASS` +
+ (failures.length > 0 ? `; ${failures.length} FAIL` : ''),
+ )
+ process.exit(failures.length > 0 ? 1 : 0)
+}
+
+main().catch((err) => {
+ die(err?.message || String(err))
+})