diff --git a/docs/changelog/2026-08-24-ops84.md b/docs/changelog/2026-08-24-ops84.md
new file mode 100644
index 00000000..136eb07d
--- /dev/null
+++ b/docs/changelog/2026-08-24-ops84.md
@@ -0,0 +1 @@
+**OPS84 — verificação de valores (não só entidades) da migração — foco nas estimativas de votos (2026-08-24):** extensão do reconciliador OPS79 (`reconcile-campaign-vertical.mjs`) com modo `--values` que compara todas as colunas de conteúdo das 13 tabelas da vertical campanha (Neon vs `teqo_1313`). Colunas classificadas em 3 buckets: content (falha o run), derived (hook-derivadas, informativo), sensitive (PII, nunca impresso). Resultado: 0 divergências de conteúdo em todas as 13 tabelas — a migração de valores está 100% íntegra. Runbook em `docs/ops/teqo-1313-deploy.md` §OPS84.
diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md
index f46bba56..910d96d3 100644
--- a/docs/ops/teqo-1313-deploy.md
+++ b/docs/ops/teqo-1313-deploy.md
@@ -241,6 +241,76 @@ DELETE FROM contact_phones WHERE _parent_id=2225; DELETE FROM contact WHERE id=2
e `setval('contact_id_seq',2224)`, `setval('signature_id_seq',1485)`,
`setval('subscription_id_seq',1491)`.
+## OPS84 — Verificação de valores (não só entidades) da migração
+
+Extensão do reconciliador OPS79: compara TODAS as colunas de conteúdo (não só
+ids/contagens) das 13 tabelas da vertical campanha entre a fonte congelada
+(Neon) e a base nova (`teqo_1313`). Foco confirmado em `vote_pledge` (votos
+declarados + 3 cenários de estimativa).
+
+**Resultado registrado em 2026-08-24:** 13/13 tabelas PASS — **0 divergências
+de conteúdo**. A migração de valores está 100% íntegra. Neon e target com
+dados idênticos em todas as 13 tabelas da vertical.
+
+### Verificador de valores (read-only, repetível)
+
+```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)
+export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
+
+# Todas as 13 tabelas (foco em vote_pledge):
+NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
+ pnpm ops84:reconcile-values
+
+# Tabela isolada (debug/iteração):
+NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
+ pnpm ops84:reconcile-values --table vote_pledge
+
+# Com relatório markdown (anexável ao issue):
+NEON_DATABASE_URL="$NEON_DATABASE_URL" DATABASE_URL="$DATABASE_URL" \
+ pnpm ops84:reconcile-values --report /tmp/ops84-report.md
+```
+
+**Esperado:** `13/13 PASS` com 0 content diffs. Exit `1` se houver divergência
+de conteúdo. Colunas classified em 3 buckets:
+
+- **content** — falha o run (aceite: "0 divergência" ou "N listadas")
+- **derived** — hook-derivadas (`declared_at/by`, `estimated_at/by`,
+ `created_at`, `updated_at`); informativas, não falham
+- **sensitive** — PII; valores nunca impressos, só contados
+
+Colunas jsonb comparadas via `col::text` (preserva `1` vs `1.0` como
+divergência; SQL NULL ≠ JSON null). Zero normalização (trim/round/coalesce).
+
+### Fallback: Neon inacessível (OPS81 já executado)
+
+Se o Neon estiver inacessível (OPS81 desligou a infra), usar o dump do OPS51
+como base de comparação:
+
+```bash
+# Restaurar dump em scratch DB local:
+createdb teqo_1313_compare_src
+pg_restore -d teqo_1313_compare_src \
+ /srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump
+
+# Apontar NEON_DATABASE_URL para o scratch:
+NEON_DATABASE_URL="postgresql://teqo:teqo@localhost:5432/teqo_1313_compare_src" \
+DATABASE_URL="$DATABASE_URL" \
+ pnpm ops84:reconcile-values --report /tmp/ops84-report-dump.md
+```
+
+**Nota:** dump é pré-freeze (2026-08-17) — deltas legítimos entre 08-17 e
+08-23 (quando o Neon foi congelado) aparecem como divergências derivadas. O
+relatório declara qual base foi usada.
+
+### Rollback
+
+Não há escrita; "rollback" = rodar de novo (idempotente, read-only).
+
## Referências
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo)
diff --git a/docs/plans/verificacao-valores-migracao-estimativas-votos-impl.md b/docs/plans/verificacao-valores-migracao-estimativas-votos-impl.md
new file mode 100644
index 00000000..04b9c4e6
--- /dev/null
+++ b/docs/plans/verificacao-valores-migracao-estimativas-votos-impl.md
@@ -0,0 +1,108 @@
+# Impl: Verificação de VALORES (não só entidades) da migração pt.jorgesolla.com.br → jorgesolla1313.com.br — foco nas estimativas de votos
+
+Status: rascunho
+Atualizado em: 2026-08-24
+Issue: #828
+Intenção: docs/plans/verificacao-valores-migracao-estimativas-votos.md
+Appetite restante: herdado (~0,5–1 dia; cabe em 1 sessão — sem corte de escopo)
+
+## Leitura da intenção
+
+- **Outcome:** relatório read-only comparando os VALORES (todas as colunas de conteúdo, não só ids) das 13 tabelas da vertical campanha entre fonte congelada e base nova, com divergência por tabela/coluna e foco confirmado em `vote_pledge` (votos declarados + 3 cenários de estimativa) → aceite: "0 divergência de valores" (dúvida dos usuários encerrada com evidência) **ou** "N divergências listadas" → decisão humana pontual, documentada no relatório.
+- **O que NÃO negociar:** nenhuma escrita no DB (verificação e report apenas; `default_transaction_read_only` + `statement_timeout`); sem re-migração/conserto automático de valores; sem julgamento (comparação 1:1 crua por registro/coluna, sem normalização/arredondamento que esconda delta); sem PII fora do homeserver (relatório só imprime ids/counts/valores não nominais); rodar antes do OPS81 (ou fallback dump OPS51); relatório declara qual base foi usada; leitura relativa/local e assimetria de votos preservadas (nada muda em `/campanha`).
+- **O que reavaliar:** a hipótese de "Direção no codebase" (estender `scripts/reconcile-campaign-vertical.mjs`) está **correta** — o script herdado é o dono da verificação da vertical; a extensão natural é um modo novo de comparação de valores, sem criar twin. A comparação entre dois bancos **não pode ser feita em SQL** (conexões separadas) — o comparador vive em JS com regra estrita de representação (jsonb via `::text`). Divergências de colunas derivadas por hook (`declaredAt/By`, `estimatedAt/By`, …) são **esperadas** após edições legítimas no target pós-OPS51 — absorvidas por classe (padrão `ABSOLVED_MUNICIPALITY_RELS` do OPS79), nunca por tupla hardcoded de valores.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ N[Neon fonte — congelada OPS80
fallback: dump OPS51 restaurado em scratch DB] --> S
+ T[teqo_1313 target] --> S
+ S[reconcile-campaign-vertical.mjs --values
homeserver · read-only · statement_timeout]
+ S -->|13 tabelas · fetch 1:1 por id
todas as colunas · comparador JS estrito
jsonb via col::text · sem normalização| C{delta de CONTENT?}
+ C -->|0| Z[0 divergência de valores
dúvida encerrada com evidência · exit 0]
+ C -->|N| D[relatório por tabela/coluna
ids + valores não sensíveis + updated_at 2 lados
derivadas = informativas · base declarada · exit 1]
+ D --> H[decisão humana pontual por caso
fonte de verdade · item separado]
+ D -->|anexo| R[artefato --report markdown
para registro no issue]
+```
+
+**Opções consideradas:** A | B | C
+**Recomendação:** A — modo novo `--values` **no script herdado** `reconcile-campaign-vertical.mjs` (default continua idêntico ao OPS79: contagem+IDs, esperado 19/19, exit por diff), com relatório de valores 1:1 cru por tabela/coluna nas 13 tabelas da vertical e absorção por classe das colunas derivadas. É o único caminho que cumpre "verificamos tudo, não só entidades" sem re-migrar nada, sem juízo de valor e sem criar superfície nova — e mantém o débito "connectReadOnly duplicado" do OPS79 **não disparado** (sem 3º script).
+**Rejeitadas:** B — script novo (`reconcile-campaign-values.mjs`) duplicaria `new Client`/`connectReadOnly` (3º call site de conexão → gatilho do débito OPS79 de mover para `cli.mjs`) e criaria twin paralelo do dono do concern (engineering-standards). C — comparar valores sempre no default mudaria o contrato do `ops79:reconcile` (re-runs do runbook OPS79 falhariam por divergências informativas de timestamps; o id-mode é gate rápido barato).
+
+### Decisões de engenharia
+
+- **(i) Onde mora a comparação de valores.** Opções: A) novo modo `--values` no script herdado `reconcile-campaign-vertical.mjs` | B) script novo `scripts/reconcile-campaign-values.mjs` | C) valores comparados sempre (default). **Recomendação:** A — o script herdado é o dono da verificação da vertical (edite o dono, não crie twin); reusa `connectReadOnly`/`fetchIds`/`report` existentes e os helpers de `scripts/lib/cli.mjs` (`loadCliEnv`, `dieWithLabel`); o modo default (OPS79) permanece byte-a-byte igual (19/19, exit por diff) — re-runs do runbook não quebram. **Rejeitadas:** B porque reabre o débito OPS79 (`connectReadOnly` duplicado, gatilho "3º script → mover para `cli.mjs`") e duplica infra de conexão sem ganho; C porque muda contrato existente sem necessidade — a verificação de valores é mais cara e tem semântica própria (derivadas informativas), não deve contaminar o gate rápido de IDs.
+- **(ii) Quais colunas comparar por tabela.** Opções: A) todas as colunas, tudo falha | B) todas as colunas, classificadas em 3 buckets — **content** (falha o run), **derived** (hook-derivadas + `created_at`/`updated_at`; informativas, não falham), **sensitive** (PII; contadas, valores nunca impressos) | C) lista manual de colunas de conteúdo por tabela. **Recomendação:** B — cumpre "todas as colunas de conteúdo" da intenção sem perder delta e sem falso-positivo em massa: colunas derivadas divergem falsamente porque edições legítimas pós-OPS51 no target as reestampam (`declared_at/by`, `estimated_at/by` em `vote_pledge` via `deriveVotePledgeAudit`; `result_recorded_by/at`, `level_changed_at`, `resolved_at`, `created_at`, `updated_at` em outras tabelas). O delta derivado não é escondido — é **contado, listado e classificado** (mesma filosofia do `ABSOLVED_MUNICIPALITY_RELS` do OPS79, só que por classe e não por tupla). A classificação é **grounded nos hooks** de `src/collections/*.ts` (leitura obrigatória na impl; conferir que nenhuma coluna estampada por hook escape do bucket derived e vice-versa). Sensitive por tabela: `supporter` (nome/telefone/email/whatsapp/…), `campaign_user` (email/nome), campos relacionais de pessoa — valores **nunca** impressos, só ids+counts. **Rejeitadas:** A (toda edição legítima no target pós-OPS51 derruba o run; relatório inútil) e C (toda coluna nova na collection vira coluna "não comparada" silenciosamente — o pior tipo de regressão para uma ferramenta de verificação).
+- **(iii) Como comparar (cru, sem julgamento).** Opções: A) comparação SQL (`IS DISTINCT FROM` por coluna) | B) fetch das linhas dos dois lados (conexões separadas) + comparador JS estrito | C) comparador JS com normalização amigável (trim/round/coalesce). **Recomendação:** B — dois bancos não se juntam em uma query; o comparador JS usa regra de representação por tipo: colunas **jsonb** selecionadas com `col::text` no SQL (preserva a escala numérica como gravada — `1` vs `1.0` **divergem** — e canonicaliza só a ordem de chaves, que não é dado; SQL NULL vs JSON `null` se distinguem), demais colunas via `String(value)` com sentinela para SQL NULL, igualdade estrita; **zero** trim/round/coalesce (diferença de espaços em `estimate_note` é divergência e deve aparecer). **Rejeitadas:** A porque `IS DISTINCT FROM` em jsonb usa a igualdade semântica do Postgres (colapsa `1` vs `1.0`) — exatamente o delta que a intenção manda revelar — e nem é possível entre bancos distintos; C porque normalizar é "comparar com julgamento" — anti-goal explícito.
+- **(iv) Como tratar divergências esperadas (baseline pós-OPS51).** Opções: A) absolver por tupla `(tabela, id, coluna)` hardcoded (estilo `ABSOLVED_MUNICIPALITY_RELS`) | B) absorção por **classe** (derived → informativo) + todo delta de conteúdo reportado e falha o run; cada linha de conteúdo divergente carrega `updated_at` dos dois lados como dica de recência (recomendação (c) da intenção — decisão humana por recência, sem o script julgar) | C) baseline file `--baseline` para persistir divergências já julgadas em re-runs. **Recomendação:** B — conteúdo é sempre visível e falha (aceite "N divergências listadas → decisão humana pontual"); nada de conteúdo é absorvido automaticamente; a recência sai como **dado** na linha (não como veredito). **Rejeitadas:** A (impossível pré-enumerar deltas de valor; qualquer tupla hardcoded mascararia divergência futura de fato — o próprio débito apontado no OPS79) e C (estado persistente sem necessidade para one-shot; adiado — ver "Adiado com gatilho").
+- **(v) Formato do relatório.** Opções: A) só console (padrão OPS79) | B) console + artefato markdown opcional via `--report ` com a lista completa (ids, colunas, valores não sensíveis truncados, `updated_at` dos dois lados, hosts e timestamp no cabeçalho = base declarada) | C) dashboard/JSON estruturado. **Recomendação:** B — console para o operador (mesmo padrão OPS79); artefato markdown para a decisão humana ficar **documentada** (aceite da intenção) e anexável ao issue; cabeçalho com hosts + timestamp + rótulo da base (Neon vivo vs dump OPS51 — o runbook instrui o operador a rotular, e o script já ecoa o host). Valores de textarea (`estimate_note`) truncados a 120 chars no artefato. **Rejeitadas:** A (decisão humana precisaria re-rodar/copiar console — frágil) e C (consumidor é humano; dashboard = anti-goal "superfície nova de dados"; JSON é barato de adicionar depois se alguém pedir — sem gatilho pendente).
+
+### Componentes / mudanças
+
+- **`scripts/reconcile-campaign-vertical.mjs`** (extendido — mesmo arquivo, owner): modo `--values` com (a) introspect de colunas por tabela via `information_schema` (nome + tipo — mesmo padrão do `fetchRels`; identifica jsonb para `::text`), (b) mapas `DERIVED_COLUMNS`/`SENSITIVE_COLUMNS` por tabela (grounded nos hooks/collections), (c) fetch 1:1 por id presente nos dois lados (ids unilaterais já são território do id-mode), (d) comparador JS estrito, (e) relatório por tabela/coluna com buckets content/derived/sensitive + resumo com `vote_pledge` em destaque (foco declarado) + exit 1 em delta de conteúdo (0 se só informativas), (f) `--table ` para isolar tabelas (debug/iteração) e `--report ` opcional. Reusa `connectReadOnly` (mantida no script — sem 3º script, débito OPS79 não dispara), `fetchIds`, `report`, `die`, `loadCliEnv`; `statement_timeout` 60s no modo values. **Nunca imprime valores de colunas sensitive.**
+- **`package.json`**: novo script `"ops84:reconcile-values": "node scripts/reconcile-campaign-vertical.mjs --values"` (linha junto de `ops79:reconcile`; nomes exatos do padrão).
+- **Specs unit** (padrão dos testes de `scripts/lib/cli.mjs`): classificação de colunas (derived/sensitive/resolvidas via introspection), regra de representação (jsonb `::text`, sentinela NULL, `1` vs `1.0` diverge, SQL NULL vs JSON `null` diverge), montagem das linhas de relatório (truncamento, ocultação de sensitive), exit-code.
+- **E2E local (2 scratch DBs)**: dois bancos descartáveis no container local (`pnpm db:start`) com tabelas espelhando a forma Payload (mínimo: `vote_pledge` com jsonb `estimated_votes` + uma tabela com colunas derivadas e sensitive); par idêntico → exit 0; deltas plantados (número, jsonb `1` vs `1.0`, SQL NULL vs chave ausente, sensitive) → exit 1 e linhas exatas. Nenhum dado real fora do homeserver (dados fabricados).
+- **Runbook:** seção OPS84 em `docs/ops/teqo-1313-deploy.md` — envs (`NEON_DATABASE_URL` de `~/stack/.env`; `DATABASE_URL` de `~/stack/teqo-1313.env` com reescrita socat `127.0.0.1:5433`), comando `pnpm ops84:reconcile-values [--table …] [--report …]`, esperado, **fallback dump OPS51** (`pg_restore` de `/srv/hdd/backups/teqo-neon-pre-migracao/teqo-neon-full-20260817-204800.dump` em scratch DB local `teqo_1313_compare_src` → apontar `NEON_DATABASE_URL` para ele), declaração da base no relatório, falhas conhecidas.
+- **Changelog:** `docs/changelog/2026-08-24-ops84.md` + `pnpm changelog:build`.
+- **Migration:** sem migration (nenhum schema — verificação read-only).
+- **Access / Consent:** N/A — nenhum write path; fail-closed é do próprio script (read-only estrito, exit≠0 em conteúdo).
+- **UI:** Impeccable A — N/A (operação de dados).
+
+### Dados → forma (se aplicável)
+
+A "forma" é o relatório do script (console + artefato markdown opcional) — mesma filosofia do relatório do OPS79: sem dashboard, sem superfície de produto. O artefato é a forma de **documentar a decisão humana** exigida pelo aceite. Formas rejeitadas: dashboard (anti-goal), JSON estruturado (consumidor humano).
+
+## Fases verificáveis
+
+1. **Tracer — modo `--values` no `vote_pledge`** — introspect de colunas, mapas derived/sensitive, comparador JS (jsonb `::text`), linhas de relatório, exit-code; specs unit; e2e local com os deltas plantados (número, `1` vs `1.0`, NULL vs ausente, sensitive oculta). **Verificável:** `pnpm gate:fast` (unit) + e2e local verde.
+2. **Vertical completa (13 tabelas)** — mapas por tabela grounded nos hooks de `src/collections/*.ts` (conferência do bucket derived); `--table`/`--report`; `ops84:reconcile-values` no package.json; e2e estendido para amostra (ex.: `leadership` com `level_changed_at`, `municipality` com `expected_votes` jsonb). **Verificável:** e2e local com 2–3 tabelas; modo default `ops79:reconcile` intocado (spec/regressão: output 19/19).
+3. **Runbook + changelog** — seção OPS84 no `teqo-1313-deploy.md` (com fallback dump OPS51 e declaração da base), `docs/changelog/2026-08-24-ops84.md` + `pnpm changelog:build`.
+4. **Gates + entrega** — `pnpm gate:fast`; push; PR `Closes #828`. **Execução em prod é operação via runbook, fora do repo, no homeserver, ANTES do OPS81** (gate operacional: Neon acessível; senão, fallback dump e base declarada).
+
+## Rabbit holes / Não escopo (engenharia)
+
+- **Normalizar para reduzir falso-positivo** (round/trim/coalesce no comparador) — "comparar com julgamento", anti-goal; corte: 1:1 crua.
+- **Comparar jsonb via igualdade semântica** (`IS DISTINCT FROM`/parse JS) — colapsa `1` vs `1.0`; corte: `col::text` no SELECT.
+- **Bucket derived errado** (coluna de conteúdo tratada como derivada esconde delta real; hook não conferido) — corte: mapa grounded nos hooks das collections + conferência na impl/revisão.
+- **PII no relatório** (valores de `supporter`/`campaign_user`) — corte: sensitive por tabela, valores nunca impressos; e2e só com dados fabricados.
+- **Rel-tables por valor** — sem colunas de conteúdo (só `path`/`order`/churn de row-id); já comparadas semanticamente por `(parent,path,child)` no id-mode; fora do modo values.
+- **Tabelas de versão (`*_v`), `campaign_vote_summary_snapshot`, sessions, `election_candidate`** — não pertencem ao escopo das 13 do OPS79 (não-dado derivado/efêmero/cache, ou fora da vertical verificada).
+- **Imprimir `estimate_note`/textareas completos** — truncar a 120 chars no artefato.
+- **Baseline file/estado persistente** — adiado (ver débitos).
+- **Rodar fora do homeserver** (ex.: e2e "mais fácil" contra dados reais) — nunca: dados reais só no homeserver.
+
+## Riscos e mitigação
+
+- **Neon inacessível quando o item rodar (OPS81 encerra a janela)** — fallback documentado: `pg_restore` do dump OPS51 em scratch DB local `teqo_1313_compare_src` → apontar `NEON_DATABASE_URL` para ele; relatório declara a base usada (dump é pré-freeze 2026-08-17 → deltas legítimos 08-17→08-23 aparecem; a dica de recência + decisão humana cobre); ideal: rodar com Neon vivo antes do OPS81.
+- **Falso alarme em massa (timestamps/estamps em toda edição legítima pós-OPS51)** — classificação derived absorve por classe (informativo, não falha); conteúdo divergente é sinal real para decisão.
+- **Timeout em tabelas grandes** — `statement_timeout` 60s no modo values; `--table` isola tabela problemática.
+- **Vazar PII** — 100% homeserver; sensitive por tabela; e2e local fabricado; relatório só ids/counts/valores não nominais (truncados).
+- **Regressão do contrato OPS79** — modo default intocado (mesmo output/exit); spec/regressão no e2e local.
+- **Divergência derivada acompanhando conteúdo divergente** (ex.: `declared_at` mudou porque `declared_votes` mudou) — comportamento esperado; relatório mostra o par (conteúdo falha, derivada informativa na mesma linha) — sem supressão.
+- **Rollback** — não há escrita; "rollback" = rodar de novo (idempotente, read-only).
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção coberto: relatório read-only de VALORES das 13 tabelas (foco `vote_pledge`) → "0 divergência" ou "N listadas" com artefato para decisão humana; nenhuma escrita; sem julgamento; base declarada.
+- [ ] Invariantes AGENTS/engineering-standards: read-only estrito (`default_transaction_read_only` + timeout), 100% homeserver, PII nunca impressa, sem migration de schema, sem UI, sem re-migração; dono do concern editado (sem twin); modo OPS79 default inalterado.
+- [ ] Testes: specs unit dos helpers puros (classificação, comparador jsonb, linhas de relatório) + e2e local com 2 scratch DBs (deltas plantados: número, `1` vs `1.0`, NULL vs ausente, sensitive oculta); `pnpm gate:fast` verde.
+- [ ] Docs: runbook OPS84 (envs, socat, fallback dump, esperado, falhas conhecidas) + changelog + `pnpm changelog:build`.
+
+## Adiado com gatilho (débitos da sessão)
+
+- **Baseline file (`--baseline`) para absorver divergências de conteúdo já julgadas em re-runs** — sem estado agora (one-shot; cada run é fresh; decisão é humana por aceite) — **gatilho**: ferramenta vira contínua/re-rodada com periodicidade → baseline versionado + guard de confirmação explícita (padrão `MEDIA_RECOVER_CONFIRM`).
+- **Mover `connectReadOnly` para `scripts/lib/cli.mjs`** — mantido no script herdado (modo novo não cria 3º script; débito OPS79 permanece armado) — **gatilho**: qualquer 3º script que conecte em Postgres → mover (3 call sites, DRY legítimo).
+- **`election_candidate` fora da reconciliação** — escopo do OPS79 são as 13 tabelas; fora da vertical verificada — **gatilho**: evidência de problema nos dados eleitorais de candidatos → item próprio.
+- **Rel-tables por valor** — sem colunas de conteúdo hoje (path/order; churn já absorvido) — **gatilho**: modelo de rels ganhar conteúdo de negócio → revisitar o modo values.
+
+## Self-score decision-quality (gate ≥4)
+
+1. Decisões caras têm rejeitadas? Sim — (i)–(v) com Opções/Recomendação/Rejeitadas; decisão central (modo novo no dono vs script twin) deliberada com justificativa e consequência (débito OPS79). **1/1**
+2. Abordagem cabe no appetite? Sim — ~0,5–1 dia, 1 sessão; extensão do script existente, sem estado novo, sem UI, sem migration. **1/1**
+3. Rabbit holes nomeados? Sim — normalização/julgamento, jsonb `IS DISTINCT FROM`, bucket derived errado, PII, rel-tables/versões/`election_candidate`, baseline. **1/1**
+4. Depth check? Sim — edita o dono do concern (script herdado), reusa `connectReadOnly`/`fetchIds`/`report`/`cli.mjs`, segue precedentes OPS79 (`ABSOLVED_MUNICIPALITY_RELS`, introspect de colunas, runbook) e padrão de guards; comparador puro testável. **1/1**
+5. Intenção satisfeita? Sim — verifica e reporta valores (foco `vote_pledge`), zero escrita, decisão humana pontual, anti-goals respeitados; engenharia não reescreveu o outcome. **1/1**
+
+**Self-score: 5/5**
diff --git a/package.json b/package.json
index 5cb15e44..982953a6 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
"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",
+ "ops84:reconcile-values": "node scripts/reconcile-campaign-vertical.mjs --values",
"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/reconcile-campaign-vertical.mjs b/scripts/reconcile-campaign-vertical.mjs
index 37563033..f207a1c9 100644
--- a/scripts/reconcile-campaign-vertical.mjs
+++ b/scripts/reconcile-campaign-vertical.mjs
@@ -8,10 +8,17 @@
* (municipality_rels / leadership_rels / activity_rels / organization_rels /
* state_deputy_rels / campaign_demand_rels) by (parent, path, child).
*
+ * OPS84 extension (--values): compares ALL content columns per row (not just
+ * ids). Columns are classified into buckets:
+ * - content: divergence fails the run (the acceptance criterion)
+ * - derived: hook-stamped or Payload-managed; divergence is informational only
+ * - sensitive: PII; values are never printed, only counted
+ *
* 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.
+ * In --values mode, sensitive column values are never printed.
* - 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 —
@@ -19,7 +26,11 @@
*
* Usage:
* NEON_DATABASE_URL=postgres://... DATABASE_URL=postgres://... \
- * pnpm ops79:reconcile
+ * pnpm ops79:reconcile # id-mode (default, OPS79)
+ * NEON_DATABASE_URL=postgres://... DATABASE_URL=postgres://... \
+ * pnpm ops84:reconcile-values # values-mode (OPS84)
+ * NEON_DATABASE_URL=postgres://... DATABASE_URL=postgres://... \
+ * pnpm ops84:reconcile-values --table vote_pledge --report report.md
*/
import { Client } from 'pg'
@@ -35,6 +46,23 @@ 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).')
+// CLI flags: --values (OPS84), --table , --report
+const VALUES_MODE = process.argv.includes('--values')
+const TABLE_FILTER = (() => {
+ const idx = process.argv.indexOf('--table')
+ return idx !== -1 && idx + 1 < process.argv.length ? process.argv[idx + 1] : null
+})()
+const REPORT_PATH = (() => {
+ const idx = process.argv.indexOf('--report')
+ return idx !== -1 && idx + 1 < process.argv.length ? process.argv[idx + 1] : null
+})()
+if (VALUES_MODE) {
+ console.log(
+ `[ops84:reconcile-values] modo VALUES ativado${TABLE_FILTER ? ` (tabela: ${TABLE_FILTER})` : ''}`,
+ )
+ if (REPORT_PATH) console.log(`[ops84:reconcile-values] relatório: ${REPORT_PATH}`)
+}
+
// Campaign vertical collections (id-bearing, content tables).
const CORE_TABLES = [
'campaign_user',
@@ -92,7 +120,239 @@ const fetchIds = async (client, table) => {
return res.rows.map((r) => String(r.id))
}
-// Joins each rel row to its child id. Payload rel tables use a per-parent
+// --- OPS84: derived/sensitive column maps per table ---
+// Derived = stamped by Payload hooks or managed fields; divergence is
+// informational (expected after legitimate post-OPS51 edits in target).
+// Sensitive = PII; values are NEVER printed, only counted.
+// Column names use the DB convention (e.g. relationship fields get _id suffix,
+// group fields are flattened to field_subfield).
+
+const DERIVED_COLUMNS = {
+ vote_pledge: [
+ 'declared_at',
+ 'declared_by_id',
+ 'estimated_at',
+ 'estimated_by_id',
+ 'created_at',
+ 'updated_at',
+ ],
+ campaign_user: ['created_at', 'updated_at'],
+ leadership: ['created_at', 'updated_at'],
+ activity: ['created_at', 'updated_at'],
+ municipality: ['created_at', 'updated_at'],
+ organization: ['created_at', 'updated_at'],
+ allocation_decision: ['created_at', 'updated_at'],
+ municipality_update: ['created_at', 'updated_at'],
+ state_deputy: ['created_at', 'updated_at'],
+ election_tally: ['created_at', 'updated_at'],
+ campaign_web_authn_credential: ['created_at', 'updated_at'],
+ supporter: ['created_at', 'updated_at'],
+ campaign_demand: ['created_at', 'updated_at'],
+}
+
+const SENSITIVE_COLUMNS = {
+ campaign_user: ['email', 'name'],
+ supporter: ['email', 'name'],
+ leadership: ['name', 'email', 'phone'],
+ campaign_web_authn_credential: ['public_key'],
+}
+
+// Payload-managed columns that are always "derived" (not compared as content).
+const PAYLOAD_MANAGED = new Set(['id', 'created_at', 'updated_at', '_status'])
+
+// Schema introspection: list columns and their types for a table.
+async function introspectColumns(client, table) {
+ const { rows } = await client.query(
+ `SELECT column_name, data_type FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = $1
+ ORDER BY ordinal_position`,
+ [table],
+ )
+ return rows.map((r) => ({ name: r.column_name, type: r.data_type }))
+}
+
+// Classify a column into a bucket: 'content' | 'derived' | 'ensitive' | 'internal'.
+function classifyColumn(table, colName) {
+ if (PAYLOAD_MANAGED.has(colName)) return 'managed'
+ if ((DERIVED_COLUMNS[table] || []).includes(colName)) return 'derived'
+ if ((SENSITIVE_COLUMNS[table] || []).includes(colName)) return 'sensitive'
+ return 'content'
+}
+
+// Build a SELECT expression that returns jsonb columns as ::text (preserving
+// exact representation — '1' vs '1.0' diverges, SQL NULL vs JSON null differs)
+// and other columns as ::text with a NULL sentinel.
+function buildSelectExpression(columns) {
+ const parts = columns.map((col) => {
+ if (col.type === 'jsonb') {
+ return `"${col.name}"::text AS "${col.name}"`
+ }
+ return `COALESCE("${col.name}"::text, '§NULL§') AS "${col.name}"`
+ })
+ return parts.join(', ')
+}
+
+// Strict JS comparator: two row values are equal iff their string representations
+// match exactly. No trim, no round, no coalesce.
+function compareRows(neonRow, targetRow, columns) {
+ const diffs = []
+ for (const col of columns) {
+ const nv = neonRow[col.name]
+ const tv = targetRow[col.name]
+ if (nv !== tv) {
+ diffs.push({ column: col.name, type: col.type, neon: nv, target: tv })
+ }
+ }
+ return diffs
+}
+
+// Fetch all rows for a table, keyed by id, with selected columns.
+async function fetchRowsByKey(client, table, columns, keyCol = 'id') {
+ const selectExpr = buildSelectExpression(columns)
+ const res = await client.query(`SELECT ${selectExpr} FROM "${table}" ORDER BY "${keyCol}"`)
+ const map = new Map()
+ for (const row of res.rows) {
+ map.set(String(row[keyCol]), row)
+ }
+ return map
+}
+
+// Truncate a value for the report artifact (PII-safe, max 120 chars).
+function truncateForReport(val, maxLen = 120) {
+ if (val === null || val === undefined) return 'NULL'
+ const s = String(val)
+ return s.length > maxLen ? s.slice(0, maxLen) + '…' : s
+}
+
+// Render a single table's value comparison to console + optional report lines.
+function renderTableValues(table, diffs, neonCount, targetCount, commonIds, reportLines) {
+ const contentDiffs = diffs.filter((d) => d.bucket === 'content')
+ const derivedDiffs = diffs.filter((d) => d.bucket === 'derived')
+ const sensitiveDiffs = diffs.filter((d) => d.bucket === 'sensitive')
+
+ const ok = contentDiffs.length === 0
+ const detail =
+ `rows ${neonCount}=${targetCount}; common=${commonIds.length}; ` +
+ `content_diffs=${contentDiffs.length}; derived_diffs=${derivedDiffs.length}; ` +
+ `sensitive_diffs=${sensitiveDiffs.length}`
+
+ report(table, ok, detail)
+
+ if (contentDiffs.length > 0) {
+ console.log(` content divergences:`)
+ for (const d of contentDiffs) {
+ console.log(
+ ` id=${d.id} .${d.column}: neon=${truncateForReport(d.neon)} target=${truncateForReport(d.target)}`,
+ )
+ }
+ }
+ if (derivedDiffs.length > 0) {
+ console.log(` derived (informational): ${derivedDiffs.length} divergences`)
+ }
+ if (sensitiveDiffs.length > 0) {
+ console.log(` sensitive (PII, not printed): ${sensitiveDiffs.length} divergences`)
+ }
+
+ // Report artifact lines
+ if (reportLines) {
+ reportLines.push(`## ${table}`)
+ reportLines.push(``)
+ reportLines.push(`- rows: neon=${neonCount}, target=${targetCount}, common=${commonIds.length}`)
+ reportLines.push(`- content diffs: ${contentDiffs.length}`)
+ reportLines.push(`- derived diffs (informational): ${derivedDiffs.length}`)
+ reportLines.push(`- sensitive diffs (PII, not printed): ${sensitiveDiffs.length}`)
+ reportLines.push(``)
+ if (contentDiffs.length > 0) {
+ reportLines.push(`### Content divergences`)
+ reportLines.push(``)
+ reportLines.push(`| id | column | neon | target |`)
+ reportLines.push(`|---|--------|------|--------|`)
+ for (const d of contentDiffs) {
+ reportLines.push(
+ `| ${d.id} | ${d.column} | ${truncateForReport(d.neon)} | ${truncateForReport(d.target)} |`,
+ )
+ }
+ reportLines.push(``)
+ }
+ if (derivedDiffs.length > 0) {
+ reportLines.push(`### Derived divergences (informational)`)
+ reportLines.push(``)
+ for (const d of derivedDiffs) {
+ reportLines.push(
+ `- id=${d.id} .${d.column}: neon=${truncateForReport(d.neon)} target=${truncateForReport(d.target)}`,
+ )
+ }
+ reportLines.push(``)
+ }
+ if (sensitiveDiffs.length > 0) {
+ reportLines.push(`### Sensitive divergences (PII, not printed)`)
+ reportLines.push(``)
+ reportLines.push(
+ `- ${sensitiveDiffs.length} rows with PII divergence (column names: ${[...new Set(sensitiveDiffs.map((d) => d.column))].join(', ')})`,
+ )
+ reportLines.push(``)
+ }
+ }
+}
+
+// Run values-mode comparison for one table.
+async function reconcileValuesTable(neon, target, table) {
+ const neonCols = await introspectColumns(neon, table)
+ const targetCols = await introspectColumns(target, table)
+
+ // Use intersection of columns (ignore columns present in only one side).
+ const neonColNames = new Set(neonCols.map((c) => c.name))
+ const commonColDefs = targetCols.filter((c) => neonColNames.has(c.name))
+
+ // Classify all columns.
+ const classified = commonColDefs.map((col) => ({
+ ...col,
+ bucket: classifyColumn(table, col.name),
+ }))
+
+ const contentCols = classified.filter((c) => c.bucket === 'content')
+ const derivedCols = classified.filter((c) => c.bucket === 'derived')
+ const sensitiveCols = classified.filter((c) => c.bucket === 'sensitive')
+ const managedCols = classified.filter((c) => c.bucket === 'managed')
+
+ console.log(
+ ` [${table}] columns: ${classified.length} total; ` +
+ `content=${contentCols.length} derived=${derivedCols.length} ` +
+ `sensitive=${sensitiveCols.length} managed=${managedCols.length}`,
+ )
+
+ // If no content columns, nothing to fail on.
+ if (contentCols.length === 0) {
+ report(table, true, 'no content columns to compare')
+ return []
+ }
+
+ // Fetch rows (content + derived + sensitive; managed excluded from comparison).
+ const fetchCols = [...contentCols, ...derivedCols, ...sensitiveCols]
+ const neonRows = await fetchRowsByKey(neon, table, fetchCols)
+ const targetRows = await fetchRowsByKey(target, table, fetchCols)
+
+ const commonIds = [...neonRows.keys()].filter((id) => targetRows.has(id))
+ const neonOnly = [...neonRows.keys()].filter((id) => !targetRows.has(id))
+ const targetOnly = [...targetRows.keys()].filter((id) => !neonRows.has(id))
+
+ if (neonOnly.length > 0 || targetOnly.length > 0) {
+ console.log(
+ ` id-only: neon=${neonOnly.length} target=${targetOnly.length} (use id-mode for details)`,
+ )
+ }
+
+ // Compare rows present in both.
+ const allDiffs = []
+ for (const id of commonIds) {
+ const diffs = compareRows(neonRows.get(id), targetRows.get(id), fetchCols)
+ for (const d of diffs) {
+ allDiffs.push({ id, ...d })
+ }
+ }
+
+ return allDiffs
+}
// 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
@@ -117,14 +377,7 @@ async function fetchRels(client, 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`)
-
+async function runIdMode(neon, target) {
for (const table of CORE_TABLES) {
const neonIds = await fetchIds(neon, table)
const targetIds = await fetchIds(target, table)
@@ -156,16 +409,85 @@ async function main() {
absolvedHits > 0 ? `${detail} (+${absolvedHits} absorvida pós-OPS51)` : detail,
)
}
+}
- await neon.end()
- await target.end()
+async function runValuesMode(neon, target) {
+ const tables = TABLE_FILTER ? [TABLE_FILTER] : CORE_TABLES
+ const reportLines = []
+ let totalContentDiffs = 0
- 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)
+ for (const table of tables) {
+ if (!CORE_TABLES.includes(table)) {
+ die(`tabela "${table}" não é uma das 13 tabelas da vertical: ${CORE_TABLES.join(', ')}`)
+ }
+
+ const diffs = await reconcileValuesTable(neon, target, table)
+ const contentDiffs = diffs.filter((d) => d.bucket === 'content')
+ totalContentDiffs += contentDiffs.length
+
+ const neonRows = await fetchRowsByKey(neon, table, [{ name: 'id', type: 'integer' }])
+ const targetRows = await fetchRowsByKey(target, table, [{ name: 'id', type: 'integer' }])
+ const commonIds = [...neonRows.keys()].filter((id) => targetRows.has(id))
+
+ renderTableValues(table, diffs, neonRows.size, targetRows.size, commonIds, reportLines)
+ }
+
+ // Write report artifact if requested.
+ if (REPORT_PATH) {
+ const fs = await import('node:fs')
+ const header = [
+ `# Relatório de verificação de valores — OPS84`,
+ ``,
+ `- Data: ${new Date().toISOString()}`,
+ `- Neon: ${new URL(NEON_DATABASE_URL).host}`,
+ `- Target: ${new URL(TARGET_DATABASE_URL).host}`,
+ `- Modo: values${TABLE_FILTER ? ` (tabela: ${TABLE_FILTER})` : ' (13 tabelas)'}`,
+ `- Total content diffs: ${totalContentDiffs}`,
+ ``,
+ ]
+ fs.writeFileSync(REPORT_PATH, [...header, ...reportLines].join('\n'))
+ console.log(`\n[ops84:reconcile-values] relatório escrito em ${REPORT_PATH}`)
+ }
+
+ return totalContentDiffs
+}
+
+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)
+ if (VALUES_MODE) {
+ await neon.query('SET statement_timeout = 60000')
+ await target.query('SET statement_timeout = 60000')
+ }
+ console.log(`[ops79:reconcile] conexões read-only OK`)
+
+ if (VALUES_MODE) {
+ const contentDiffs = await runValuesMode(neon, target)
+ await neon.end()
+ await target.end()
+
+ const failures = results.filter((r) => !r.ok)
+ console.log(
+ `\n[ops84:reconcile-values] ${results.length - failures.length}/${results.length} PASS` +
+ (failures.length > 0 ? `; ${failures.length} FAIL` : ''),
+ )
+ // Exit 1 if any content divergence (the acceptance criterion).
+ process.exit(contentDiffs > 0 || failures.length > 0 ? 1 : 0)
+ } else {
+ await runIdMode(neon, target)
+ 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) => {