# O ensaio geral do corte

Escrito em 27/08/2026.

O `procedimento-de-corte.md` diz **o que fazer**. Este diz **como saber se o
procedimento funciona antes de precisar dele**.

> **14/10/2026 — o corte foi partido em duas etapas, por decisão do dono** (o topo do `procedimento-de-corte.md`). **Este
> é o ensaio da etapa 2**, a migração do MySQL para o PostgreSQL, e fica como está. A etapa 1 — o código novo sobre o
> MySQL de produção — **não tem ensaio escrito**: o que ela pede e onde ensaiá-la estão em
> `docs/procedimento-de-corte-etapa-1.md` ("O ensaio da etapa 1"). Depois da etapa 1, uma cópia da produção já traz o
> passo 0 aplicado e a `usuarios_horarios` semeada e gravada pelo código novo.

---

## Para que serve

Três coisas, e vale distinguir porque uma execução única entrega só a primeira:

1. **Um número de janela defensável.** Quanto tempo o corte leva, no hardware de
   produção, medido e não estimado.
2. **A prova de que o procedimento é repetível.** Duas execuções com o mesmo
   resultado. Uma execução que deu certo pode ter dado certo por acaso.
3. **A prova de que os portões acusam.** Um portão que nunca falhou não é
   portão. Isso se demonstra quebrando cada um de propósito.

---

## Antes de começar: congele a origem

**O critério de validade mais importante, e o mais fácil de violar sem
perceber.**

Se a base MySQL de origem receber uma venda entre a primeira execução e a
segunda, as duas mediram coisas diferentes — e comparar os tempos engana em vez
de informar. Isso não é uma falha do procedimento: é uma comparação sem sentido.

`scripts/ensaiar-corte.sh` grava uma **impressão digital da origem** (contagem e
maior id das seis maiores tabelas) e `comparar-ensaios.sh` **anula o ensaio** se
ela mudar, em vez de reportar diferença de tempo.

Na prática: use uma cópia parada da produção, não uma réplica viva.

---

## Antes de começar: a cópia precisa do que a produção vai ter

Três coisas que o roteiro não faz, e que mudam o que ele mede (03/10/2026):

**O passo 0, aplicado na cópia.** No corte, a produção recebe as dez migrações
do laço do passo 0 antes do dump (`procedimento-de-corte.md`, "O que hoje
bloqueia"; **D323**), e o roteiro de carga já pede as colunas delas: o
`migrar-dados.sh` sai do `mysql.sql` de hoje (**D379**). Numa cópia sem elas,
pela leitura do roteiro (não rodado — ele é a carga), o `SELECT` de cada tabela
com coluna nova falha **calado** (o erro do `mysql` vai para o nulo), o `\copy`
recebe zero linhas e passa no `conferir()` como `COPY 0`, e quem acusa é a
primeira prova da conferência, com `vendas`, `unidades` e
`pontuacoes_historicos` vazias no PostgreSQL. Numa cópia da produção feita para o
ensaio, aplique o mesmo laço do procedimento, as dez, como no corte; elas são
idempotentes.

**Na cópia do desenvolvimento (`franquiabv_dev`), rode o laço abaixo** (desde
09/10/2026): hoje é o do procedimento inteiro -- as dez, na mesma ordem, contra a
`franquiabv_dev`. Ela já tem as dez, e nela não fazem nada: as seis primeiras ela
já tinha, a sétima, a do índice de `agendas.id_pai` (**D220**), desde a noite de
09/10/2026, a oitava, a dos aparelhos do push (**D461**), desde a manhã de
11/10/2026, a nona, a das colunas do cupom do dia (**D271**), desde a madrugada de
12/10/2026, e a décima, a do "Não" do PDV antigo (**D453**), desde a manhã do mesmo
dia (abaixo). O dono respondeu a pergunta 62 do lote na manhã de 09/10, com a opção
(b), "Criar já na cópia local, nos dois bancos, como no D308", e à noite a migração
rodou na `franquiabv_dev` como este laço a roda, sozinha (12 s no servidor, pelo
`exec_time` do `ALTER` no binlog, sem travar as escritas; na segunda vez, "indice ja
existe"), e o PostgreSQL da cópia ganhou o índice que o gerador emite,
`agendas_idx_agendas_id_pai (inquilino_id, id_pai)`, por `CREATE INDEX CONCURRENTLY`.
O laço continua separado do procedimento porque nem toda migração pode rodar na
cópia: criar esquema em banco de cópia pede a autorização do dono, e
`conferir-passo-zero-do-corte.php` confere que este laço é o do procedimento, na
mesma ordem, menos as migrações cujo cabeçalho diz "NAO rode na copia local" --
hoje nenhuma; de 11 a 12/10/2026, a nona (abaixo). Desde a resposta 83 do dono
(12/10/2026, a opção (a)), o esquema que só acrescenta -- tabela, coluna e índice que
não apagam linha -- entra nas duas cópias locais quando a resposta de uma pergunta o
pede, cada um com a sua migração no passo 0; o que apaga linha continua pedindo a
autorização um a um. A sétima teve a frase do commit `2d3c6a0` (na manhã de 09/10, minutos
antes da resposta 62) até a noite do mesmo dia, quando o índice foi criado na cópia.
Quando uma migração nova não puder rodar aqui, a frase entra no cabeçalho dela e ela
sai deste laço, as duas mudanças juntas; quando o dono autorizar, o inverso. O
índice não muda o que a carga copia nem o que a conferência compara, e o PostgreSQL
do ensaio nasceria com ele de todo jeito: a fase `schema` do roteiro gera o
`schema.sql` do `mysql.sql` do repositório.

**A oitava teve a frase de 10/10 a 11/10/2026:** a dos aparelhos do push,
`migracao-clientes-aparelhos.sql` (**D461**), entrou com ela no commit `ed61814`, até
o dono responder se a tabela entrava já na cópia local. Ele respondeu na resposta 72
do lote, a opção (c): criar já, nos dois bancos da cópia, e a base do teste do BVIP
pela mão dele. Na manhã de 11/10/2026 a migração rodou na `franquiabv_dev` como este
laço a roda (o `CREATE TABLE` sozinho no `binlog.000048`, posição 16120135, às
06:33:19), e o PostgreSQL da cópia ganhou a tabela que o gerador emite, com o
inquilino e o RLS, numa transação só (às 06:33:29); vazia nos dois, e igual, coluna
por coluna, à dos bancos da suíte (**D461**). A frase saiu do cabeçalho e a migração
entrou neste laço juntas. **Esta, ao contrário do índice, muda a carga e a
conferência**: o `scripts/migrar-dados.sh`, gerado do `mysql.sql`, copia
`clientes_aparelhos` como as outras. Na cópia sem a tabela o `SELECT` dela falhava
**calado** — o `mysql_` do roteiro descartava o erro do cliente MySQL, o `COPY` recebia
a entrada vazia e respondia `COPY 0`, que a conferência dele aceitava (**D462**; desde
08/11/2026, a fatia 128, **a carga para na tabela que a origem não tem**: o erro do
cliente sai na tela, o código de saída dele vai a um arquivo que a `conferir()` lê
antes da linha do `COPY`, e o roteiro para com o nome da tabela — provado com um
`docker` de mentira, pelo `scripts/ensaiar-carga-que-para.sh`, e nunca rodado de
verdade) —, e a contagem do
`conferir-migracao.php` (o passo 5), um `UNION ALL` com o `COUNT(*)` de cada tabela
do `mysql.sql`, falhava inteira no MySQL. Com a tabela na cópia, a contagem do lado
do MySQL responde (medida só ela, por leitura, no D462); o ensaio inteiro não foi
rodado de novo, porque ele escreve no MySQL da cópia (a `limpeza` e o `passo3`).

**A nona teve a frase de 11 a 12/10/2026:** a dos cupons,
`migracao-cupons-clientes-desconto-produto.sql` (**D271**), acrescenta à
`cupons_clientes` o desconto e o produto do dia em que o cupom foi dado, e entrou com
a frase no commit `2408c28`, até a resposta da pergunta 83 do lote (o esquema em bloco
nas cópias locais). O dono a respondeu na madrugada de 12/10/2026, com a opção (a), e
às 05:37 a migração rodou na `franquiabv_dev` como este laço a roda (o `ALTER` sozinho
no `binlog.000048`, posição 30869289, 1 s pelo `exec_time`; na segunda vez, "colunas ja
existem, nada a fazer"); às 05:39 o PostgreSQL da cópia ganhou o que o gerador emite
para as duas colunas, com o índice e a chave estrangeira, numa transação só. As duas
nulas nos 100.375 cupons, e a `cupons_clientes` igual, coluna por coluna, à dos bancos
da suíte (**D271**). A frase saiu do cabeçalho e a migração entrou neste laço juntas.
**Como a oitava, esta muda a carga e a conferência**: o `scripts/migrar-dados.sh`,
gerado do `mysql.sql`, pede as duas colunas no `SELECT` da `cupons_clientes`, e na
cópia sem elas o `SELECT` falhava **calado** -- o `COPY 0` que a conferência dele
aceitava (**D462**; desde 08/11/2026 a carga para ali, com o nome da tabela). Com as colunas, o `SELECT` da carga responde nos dois bancos da
cópia (medido só ele, por leitura: 100.375 linhas em cada). O `conferir-migracao.php`
contra as duas cópias não chega às provas: sai com 2 na recusa das fixtures (560 linhas
no MySQL e 93 no PostgreSQL, em 38 tabelas, em 12/10/2026), como diz a lista abaixo.
O ERP não quebrava na cópia sem elas, e segue não quebrando onde elas faltam (a base
do teste do BVIP, até a próxima cópia exportada): a entidade não as mapeia, e o código
pergunta se elas existem (`ISBase\Doctrine\ColunaNoBanco`, o **D473**).

**A décima nunca teve a frase:** a do "Não" do PDV antigo,
`migracao-vendas-produtos-pacote-recusado.sql` (**D453**, a resposta 80 do dono),
acrescenta à `vendas_produtos` a coluna `pacote_recusado` -- o item lançado com "Usar
pacote contratado: Não" não herda a sessão do item tirado --, e é esquema que só
acrescenta: pela resposta 83, entrou nas duas cópias no dia em que nasceu. Às 09:01:09
de 12/10/2026 a migração rodou na `franquiabv_dev` como este laço a roda (no
`binlog.000048`, desde a posição 32516955, um `ALTER TABLE vendas_produtos ... ALGORITHM=INSTANT`
só, terminado em 32517397; 2 s com a conferência, medidos de fora; na segunda vez,
"coluna ja existe, nada a fazer"); às 09:02:31 o PostgreSQL da cópia ganhou a linha que
o gerador emite para ela, numa transação só. A coluna 0 nos 3.264.661 itens do MySQL e
nos 3.264.612 do PostgreSQL, e a `vendas_produtos` igual, coluna por coluna, à dos bancos
da suíte (**D453**). Muda a carga como a nona: o `SELECT` da `vendas_produtos` no
`scripts/migrar-dados.sh` pede a coluna, e responde nos dois bancos da cópia (medido só
ele, por leitura). Onde ela falta, o "Não" vale só para o lançamento, sem erro (a
`ColunaNoBanco`, o **D473**).

**A décima primeira também nunca teve a frase:** a dos motivos e dos autores de Contas a Pagar,
`migracao-saidas-motivos-e-autores.sql` (**D205**, **D207** e **D199**; as respostas 139, 140 e 18 do dono),
acrescenta oito colunas a `saidas`, `saidas_parcelas` e `saidas_parcelas_pagamentos` -- quem excluiu a despesa e a
parcela, o motivo da quitação sem pagamento, e o motivo e o autor do estorno --, esquema que só acrescenta: pela
resposta 83, entrou nas duas cópias no dia em que nasceu. Às 01:05:29 de 13/10/2026 a migração rodou na
`franquiabv_dev` como este laço a roda (no `binlog.000048`, desde a posição 44919560, três `ALTER TABLE ...
ALGORITHM=INSTANT` só, um por tabela, terminados em 44921729; 2 s com a conferência, medidos de fora; na segunda
vez, às 01:05:41, "colunas ja existem, nada a fazer" nas três, e o binlog parado); às 01:06:01 o PostgreSQL da cópia
ganhou as linhas que o gerador emite para elas, numa transação só. As oito nulas nas 13.238 despesas, 15.076 parcelas
e 13.902 pagamentos, nos dois bancos, e as três tabelas iguais, coluna por coluna, às dos bancos da suíte
(**D207**). Muda a carga como a nona e a décima: os três `SELECT` do `scripts/migrar-dados.sh` pedem as colunas, e
respondem nos dois bancos da cópia (medidos só eles, por leitura). Onde elas faltam, Contas a Pagar não guarda o
motivo nem o autor, sem erro (a `ColunaNoBanco`, o **D473**).

**A décima segunda também nunca teve a frase:** a da vigência da taxa de royalty,
`migracao-royalties-aliquotas.sql` (**D368**, a resposta 134 do dono), cria a `royalties_aliquotas` -- a taxa nova vale do
ciclo seguinte, com quando e quem mudou --, tabela nova e vazia, que só acrescenta: pela resposta 83, entrou nas duas cópias
no dia em que nasceu. Às 08:44:05 de 13/10/2026 a migração rodou na `franquiabv_dev` como este laço a roda (no
`binlog.000048`, desde a posição 51040736, um `CREATE TABLE IF NOT EXISTS` só, terminado em 51041792; 1 s com a
conferência dos índices, medido de fora; na segunda vez, às 08:44:22, a mesma saída e o mesmo `CREATE TABLE IF NOT
EXISTS` no binlog, sem efeito); às 08:44:23 o PostgreSQL da cópia ganhou a tabela que o gerador emite, tirada linha a linha
do `postgres/schema.sql` -- o inquilino na frente, o índice com ele, as duas chaves estrangeiras, o RLS ligado e forçado e a
política --, numa transação só e com a conferência de ausência dentro dela (`docker exec -i franquiabv-postgres-1 psql -U
franquiabv -d franquiabv_dev < <o roteiro>`, saída 0). Vazia nos dois, e igual, coluna por coluna, à dos bancos da suíte:
16 linhas do `information_schema` iguais às da `franquiabv_cilocal` no MySQL; 24 do retrato (as colunas, com o padrão e a
identidade, os índices, as restrições, o RLS, a política e os quatro privilégios do `bvip_app`) iguais às da
`franquiabv_pgtestes` no PostgreSQL (**D368**). Muda a carga como a oitava: o `scripts/migrar-dados.sh`, gerado do
`mysql.sql`, copia a `royalties_aliquotas` como as outras, e o `SELECT` dela responde nos dois bancos da cópia. Onde ela
falta, a taxa nova volta a valer no dia em que muda, sem erro (a `ColunaNoBanco`).

**A décima terceira também nunca teve a frase:** a do registro da devolução de crédito,
`migracao-clientes-creditos-devolucoes.sql` (**D179**, a resposta 89 do dono), cria a `clientes_creditos_devolucoes` -- quem
autorizou, quem operou, quanto, quando e a retirada do caixa por onde o dinheiro saiu --, tabela nova e vazia, que só
acrescenta: pela resposta 83, entrou nas duas cópias no dia em que nasceu. Às 22:01:41 de 15/10/2026 a migração rodou na
`franquiabv_dev` como este laço a roda (a conferência dos índices deu a chave primária e os quatro índices), e de novo às
22:01:42, com a mesma saída e sem efeito (`CREATE TABLE IF NOT EXISTS`); antes, a tabela não existia (0 no
`information_schema`). Às 22:01:57 o PostgreSQL da cópia ganhou a tabela que o gerador emite, tirada linha a linha do
`postgres/schema.sql` -- o inquilino na frente, os quatro índices com ele, as quatro chaves estrangeiras e a do inquilino, o
RLS ligado e forçado e a política --, numa transação só e com a conferência de ausência dentro dela (`docker exec -i
franquiabv-postgres-1 psql -U franquiabv -d franquiabv_dev -v ON_ERROR_STOP=1 < <o roteiro>`, saída 0): o RLS ligado e
forçado (`t|t`), os quatro privilégios do `bvip_app` (`DELETE,INSERT,SELECT,UPDATE`) e zero linhas. Muda a carga como a
oitava: o `scripts/migrar-dados.sh`, gerado do `mysql.sql`, copia a `clientes_creditos_devolucoes` como as outras. Onde ela
falta, a devolução sai do caixa como retirada e não deixa a linha do registro, sem erro (a `ColunaNoBanco`).

**E as duas colunas do D500** (16/10/2026, a resposta 231 (a) do dono: cancelar a retirada da devolução a desfaz), `desfeita_por` e `desfeita_em`, entraram na mesma migração: no `CREATE TABLE` (a produção a cria já com elas) e num `ALTER` idempotente, depois dele, para a tabela que já existe sem elas -- as duas cópias. Pela resposta 83, entraram nas duas cópias no dia: às 10:29:08 a migração rodou na `franquiabv_dev` como este laço a roda (o `ALTER ... ALGORITHM=INSTANT`; o `SHOW CREATE TABLE` de antes e o de depois diferem só nas duas linhas delas), e de novo às 10:29:09, com o aviso "colunas do D500 ja existem, nada a fazer"; antes, a tabela tinha 0 linhas. Às 10:29:22 o PostgreSQL da cópia ganhou as duas linhas que o gerador emite (`desfeita_por integer DEFAULT NULL`, `desfeita_em timestamptz DEFAULT NULL`), numa transação só e com a conferência de ausência dentro dela, saída 0; na segunda vez, "as colunas ja existem: nada feito". Ensaiada antes em dois bancos descartáveis (apagados no fim): a tabela de 15/10 mais a migração nova duas vezes, e o banco sem a tabela mais a migração nova duas vezes, deram o mesmo `SHOW CREATE TABLE`. A carga (`migrar-dados.sh`, regerada do `mysql.sql`) copia as duas. Onde elas faltam, cancelar a retirada da devolução é o cancelamento de antes (o dinheiro volta à gaveta, o crédito fica baixado), sem erro (a `ColunaNoBanco`).

**A décima quinta também nunca teve a frase:** a do registro de quem abre os arquivos, `migracao-arquivos-acessos.sql` (**D147**, a resposta 253 (c) do dono; a LGPD dele é o **D583**), cria a `arquivos_acessos` -- o arquivo, quem entrou, a pessoa assumida quando difere, a unidade da sessão, abrir ou baixar, e quando --, tabela nova e vazia, que só acrescenta: pela resposta 83, entrou nas duas cópias no dia em que nasceu. Às 22:25:27 de 22/10/2026 a migração rodou na `franquiabv_dev` como este laço a roda (a conferência dos índices deu a chave primária e os dois índices; 0 linhas), e de novo às 22:25:29, com a mesma saída e sem efeito (`CREATE TABLE IF NOT EXISTS`); antes, a tabela não existia (0 no `information_schema`). Às 22:26:16 o PostgreSQL da cópia ganhou a tabela que o gerador emite, tirada linha a linha do `postgres/schema.sql` -- o inquilino na frente, os dois índices com ele, as duas chaves estrangeiras e a do inquilino, o RLS ligado e forçado e a política --, numa transação só e com a conferência de ausência dentro dela (saída 0; na segunda vez, "a tabela ja existe: nada feito"): o RLS ligado e forçado (`t|t`), os quatro privilégios do `bvip_app` (`DELETE,INSERT,SELECT,UPDATE`) e zero linhas; as colunas, os índices, as restrições, a política e os privilégios iguais aos da `franquiabv_pgtestes`, e no MySQL iguais aos da `franquiabv_cilocal`. Muda a carga como a oitava: o `scripts/migrar-dados.sh`, gerado do `mysql.sql`, copia a `arquivos_acessos` como as outras (e, regerado, as três colunas do sorteio que faltavam nele, o **D584**). Onde ela falta, o arquivo é entregue como antes e não deixa a linha do registro, sem erro (a `ColunaNoBanco`).

**A décima sexta também nunca teve a frase:** a do rastro do WhatsApp da unidade, `migracao-unidades-whatsapp-eventos.sql` (**D591**, a F3 do desenho do WhatsApp, pela resposta 136 (a) do dono; a LGPD dele entrou no **D583**), cria a `unidades_whatsapp_eventos` -- a unidade, o evento (`codigo_pedido` ou `desconexao`), o resultado (`sucesso` ou `falha`), o motivo tratado, quem entrou, a pessoa assumida quando difere, e quando --, tabela nova e vazia, que só acrescenta: pela resposta 83, entrou nas duas cópias no dia em que nasceu. Às 16:53:39 de 23/10/2026 a migração rodou na `franquiabv_dev` como este laço a roda (a conferência dos índices deu a chave primária e o índice (`unidade`, `data`); 0 linhas), e de novo no mesmo segundo, com a mesma saída e sem efeito (`CREATE TABLE IF NOT EXISTS`); antes, a tabela não existia (0 no `information_schema`), e o `SHOW CREATE TABLE` dela, sem o `AUTO_INCREMENT`, é o mesmo da `franquiabv_cilocal`. Às 16:53:51 o PostgreSQL da cópia ganhou a tabela que o gerador emite, tirada linha a linha do `postgres/schema.sql` -- o inquilino na frente, o índice com ele, a chave estrangeira para `unidades` e a do inquilino, o RLS ligado e forçado e a política --, numa transação só e com a conferência de ausência dentro dela (saída 0; na segunda vez, "a tabela ja existe: nada feito"): o RLS ligado e forçado (`true|true`), os quatro privilégios do `bvip_app` (`DELETE,INSERT,SELECT,UPDATE`) e zero linhas; as colunas, os índices, as restrições e a política iguais aos da `franquiabv_pgtestes`. Muda a carga como a oitava: o `scripts/migrar-dados.sh`, regerado do `mysql.sql`, copia a `unidades_whatsapp_eventos` como as outras (e só ela mudou nele). Onde ela falta, o código sai e a desconexão acontece como antes e não deixam rastro, e a `GET /adm/api/whatsapp` devolve `ultimos: null`, sem erro (a `ColunaNoBanco`).

**A décima sétima também nunca teve a frase:** a de desde quando o PDV grava cada coluna dos relatórios, `migracao-colunas-gravadas.sql` (a fatia 43, REL-CONSULTA-E-EXPORTACAO; a 1.5 do `docs/f3-relatorios/spec-ordem.md`), cria a `colunas_gravadas` -- o nome da coluna e o instante em que a migração dela rodou --, tabela nova e vazia, sem chave estrangeira, que só acrescenta: pela resposta 83, entrou nas duas cópias no dia em que nasceu, e **nenhuma linha entra nela aqui** (cada migração da fatia do PDV grava a sua). Às 21:49:50 de 26/10/2026 a migração rodou na `franquiabv_dev` como este laço a roda (a conferência dos índices deu a chave primária em `nome`; 0 linhas), e de novo no mesmo segundo, com a mesma saída e sem efeito (`CREATE TABLE IF NOT EXISTS`); antes, a tabela não existia (0 no `information_schema`), e o `SHOW CREATE TABLE` dela é o do `mysql.sql`. Às 21:50:18 o PostgreSQL da cópia ganhou a tabela que o gerador emite, tirada linha a linha do `postgres/schema.sql` -- o inquilino na frente, a chave primária em `nome`, a chave estrangeira do inquilino, o RLS ligado e forçado e a política --, numa transação só e com a conferência de ausência dentro dela (saída 0; na segunda vez, "a tabela ja existe: nada feito"): o RLS ligado e forçado (`t|t`), os quatro privilégios do `bvip_app` (`DELETE,INSERT,SELECT,UPDATE`) e zero linhas. Muda a carga como a oitava: o `scripts/migrar-dados.sh`, regerado do `mysql.sql`, copia a `colunas_gravadas` como as outras (e só ela mudou nele). Onde ela falta, o relatório diz "ainda não gravado" em toda coluna, sem erro (a `ColunaNoBanco`).

**A das confirmações também nunca teve a frase:** o número de cada confirmação das escritas de dinheiro, `migracao-confirmacoes.sql` (o **D162** e o **D285**, a fatia 103; a resposta 87 (a) do dono, "um número por confirmação"), cria a `confirmacoes` -- quem confirmou, o número, a unidade, a escrita, o sha256 do pedido sem a senha e a primeira resposta, com o índice único (`usuario`, `chave`) --, tabela nova e vazia, sem chave estrangeira, que só acrescenta: pela resposta 83, entrou nas duas cópias no dia em que nasceu. Às 04:49:29 de 07/11/2026 a migração rodou na `franquiabv_dev` como este laço a roda (a conferência dos índices deu a chave primária, o único e o da data; 0 linhas), e de novo no mesmo segundo, com a mesma saída e sem efeito (`CREATE TABLE IF NOT EXISTS`); antes, a tabela não existia (0 no `information_schema`), e o `SHOW CREATE TABLE` dela, sem o `AUTO_INCREMENT`, é o do `mysql.sql`. Às 04:49:38 o PostgreSQL da cópia ganhou a tabela que o gerador emite, por `postgres/migracao-confirmacoes.sql` -- o inquilino na frente, os dois índices com ele, a chave estrangeira do inquilino, o RLS ligado e forçado e a política --, numa transação só e com a conferência de ausência dentro dela (saída 0; na segunda vez, "a tabela ja existe: nada feito"): o RLS ligado e forçado, os quatro privilégios do `bvip_app` (`DELETE,INSERT,SELECT,UPDATE`) e zero linhas; as colunas, os índices, as restrições, a política e os privilégios iguais aos de um banco montado do `schema.sql`. Ensaiada antes em quatro bancos descartáveis (o `mysql.sql` e o `schema.sql` do HEAD com a migração duas vezes, contra os de hoje), apagados no fim. Muda a carga como a oitava: o `scripts/migrar-dados.sh`, regerado do `mysql.sql`, copia a `confirmacoes` como as outras (e só ela mudou nele). Onde ela falta, o número da confirmação é ignorado e as três escritas gravam como antes, sem erro (a `ColunaNoBanco`).

**E as três da fatia 114 (ESQUEMA-QUE-SO-ACRESCENTA, 07/11/2026) também nunca tiveram a frase.** A do índice da grade, `migracao-agendas-indice-colaborador-data.sql` (o **D189**; as suas 126 (a) e 196 (b)), troca o `FK_AGENDAS_colaborador` de uma coluna pelo de (`colaborador`, `data`), com o mesmo nome, como a das vendas por cliente: as duas cópias já o tinham desde 02/10/2026, e às 18:56:47 de 07/11/2026 ela rodou na `franquiabv_dev` como este laço a roda e disse "indice ja ampliado, nada a fazer", duas vezes (o PostgreSQL da cópia já tem `agendas_fk_agendas_colaborador (inquilino_id, colaborador, data)`, o que o gerador emite). A do índice das saídas do estoque, `migracao-produtos-estoque-indice-data.sql` (o **D630**), criou o `IDX_PRODUTOS_ESTOQUE_data_criacao` no mesmo segundo (0,16 s, as 33.576 linhas) e, na segunda vez, "indice ja existe"; o PostgreSQL da cópia o ganhou às 18:56:53 por `postgres/migracao-produtos-estoque-indice-data.sql` (`CREATE INDEX CONCURRENTLY`, válido; na segunda vez, "already exists, skipping"). A do envio do NPS, `migracao-nps-envio.sql` (o **D648**), acrescentou a `nps.envio` nula no mesmo segundo (`INSTANT`; 37.957 pesquisas, 0 com envio) e, na segunda vez, "coluna envio ja existe"; o PostgreSQL da cópia a ganhou às 18:56:54 por `postgres/migracao-nps-envio.sql`, com os privilégios do `bvip_app`. Todas só acrescentam (a resposta 83 (a)), e nenhuma muda linha. Muda a carga como a oitava: o `scripts/migrar-dados.sh`, regerado do `mysql.sql`, copia a `nps.envio` (e só ela mudou nele). **A da `nps.envio` é condição de subir**: a entidade mapeia a coluna, e sem ela toda leitura e gravação de `nps` falha.

**A dos assistentes com a Anthropic também nunca teve a frase:** `migracao-assistentes-anthropic.sql` (a fatia 133, ASSISTENTES-PORTA-DO-CLAUDE, 08/11/2026; a sua decisão de 07/11/2026 e as respostas 366 (c) e 369 (c)) acrescenta as cinco colunas de `assistentes` (as instruções, com o conjunto utf8mb4 na coluna, o modelo, o esforço, o teto de tokens e a página, única e nula) e cria as quatro tabelas da conversa, da cota e do uso, vazias e em utf8mb4: só acrescenta (a resposta 83 (a)), e entrou nas duas cópias no dia em que nasceu, como este laço a roda (a segunda vez sem efeito: "colunas ja existem" e `CREATE TABLE IF NOT EXISTS`); o PostgreSQL da cópia por `postgres/migracao-assistentes-anthropic.sql`, numa transação só, com a conferência de ausência dentro dela. Muda a carga como a oitava: o `scripts/migrar-dados.sh`, regerado do `mysql.sql`, copia as cinco colunas e as quatro tabelas. Onde elas faltam, o assistente da tela nova diz "indisponível", sem erro (a `ColunaNoBanco`).

```bash
for f in scripts/migracao-pontuacao-validade-unidade.sql scripts/migracao-venda-autorizacao-fiado.sql \
         scripts/migracao-usuarios-horarios.sql scripts/migracao-clientes-sessoes.sql \
         scripts/migracao-pontuacao-resgate-autorizado.sql scripts/migracao-unidade-hash-anterior.sql \
         scripts/migracao-agendas-indice-id-pai.sql scripts/migracao-clientes-aparelhos.sql \
         scripts/migracao-cupons-clientes-desconto-produto.sql scripts/migracao-vendas-produtos-pacote-recusado.sql \
         scripts/migracao-saidas-motivos-e-autores.sql scripts/migracao-royalties-aliquotas.sql \
         scripts/migracao-clientes-creditos-devolucoes.sql scripts/migracao-sorteios-regra-do-cupom.sql \
         scripts/migracao-arquivos-acessos.sql scripts/migracao-unidades-whatsapp-eventos.sql \
         scripts/migracao-colunas-gravadas.sql scripts/migracao-venda-cancelamento.sql \
         scripts/migracao-caixa-valor-contado.sql scripts/migracao-produtos-estoque-motivo.sql \
         scripts/migracao-cota-do-pacote.sql scripts/migracao-custo-do-item.sql \
         scripts/migracao-indicacao-porta.sql scripts/migracao-vendas-indice-cliente-status-data.sql \
         scripts/migracao-confirmacoes.sql scripts/migracao-agendas-indice-colaborador-data.sql \
         scripts/migracao-produtos-estoque-indice-data.sql scripts/migracao-nps-envio.sql \
         scripts/migracao-assistentes-anthropic.sql; do
    echo "== $f"; docker compose exec -T mysql mysql -uroot -proot franquiabv_dev < "$f" || break
done
```

**Depois do laço, a linha do cancelamento** (a fatia 70, PDV-CANCELAMENTO; a nota 4 dela): na cópia o código que grava
quando e quem cancelou já está de pé, e a linha `cancelamento` de `colunas_gravadas` vai por
`scripts/colunas-gravadas-cancelamento.sql` (idempotente; o irmão do PostgreSQL em `postgres/`). Na produção ela não vai ao
passo 0: vai ao passo 7 da etapa 1 (`procedimento-de-corte-etapa-1.md`), depois da troca da árvore, e a carga a traz ao
PostgreSQL; o "7d" da janela só a escreve se não houver (o corte de uma vez só). Rodada em 03/11/2026 nas duas cópias, duas vezes: uma linha, a
segunda sem efeito. **Depois dela, a do desconto manual** (a fatia 71, PDV-DESCONTO-LANCADO-POR): o código que grava quem
lançou o desconto digitado também já está de pé, e a linha `descontoManual` vai por `scripts/colunas-gravadas-desconto-manual.sql`
(idempotente; o irmão em `postgres/`), logo depois da do cancelamento, na cópia e na produção (o passo 7 da etapa 1). Rodada em
03/11/2026 nas duas cópias, duas vezes: a linha nova, a segunda sem efeito. **Depois dela, a do valor contado** (a fatia 72,
PDV-GAVETA-CONTADA): o fechamento do caixa já pede e grava o dinheiro contado na gaveta, e a linha `valorContado` vai por
`scripts/colunas-gravadas-valor-contado.sql` (idempotente; o irmão em `postgres/`), logo depois da do desconto manual e sempre
depois da do cancelamento (a quebra pede as duas datas). Rodada em 03/11/2026 nas duas cópias, duas vezes: a linha nova, a
segunda sem efeito. **Depois dela, a do motivo da retirada** (a fatia 73, PDV-MOTIVO-DA-RETIRADA): a saída pela tela de
estoque já pede o motivo e a retirada de uso interno grava `uso`, e a linha `motivoDaRetirada` vai por
`scripts/colunas-gravadas-motivo-da-retirada.sql` (idempotente; o irmão em `postgres/`), logo depois da do valor contado. Rodada
em 03/11/2026 nas duas cópias, duas vezes: a linha nova, a segunda sem efeito. **Depois dela, a quinta e última, a da cota do
pacote** (a fatia 74, PDV-COTA-DO-PACOTE): o cadastro do pacote já pede o percentual da cota-parte de quem vendeu e a
finalização de toda sessão de pacote grava quem vendeu e a cota dele, e a linha `cotaDoPacote` vai por
`scripts/colunas-gravadas-cota-do-pacote.sql` (idempotente; o irmão em `postgres/`), logo depois da do motivo da retirada.
Rodada em 03/11/2026 nas duas cópias, duas vezes: a linha nova, a segunda sem efeito; as cinco linhas com data. **Depois
dela, a sexta, a do custo do item** (a fatia 74b, PDV-CUSTO-DO-ITEM; a 224 (b) do dono): o PDV já grava em cada item de produto
vendido o custo do cadastro naquele momento, e a linha `custoDoItem` vai por `scripts/colunas-gravadas-custo-do-item.sql`
(idempotente; o irmão em `postgres/`), logo depois da da cota do pacote, na cópia e na produção (o passo 7 da etapa 1). Rodada
em 03/11/2026 nas duas cópias, duas vezes: a linha nova, a segunda sem efeito; as seis linhas com data. **Depois dela, a
sétima, a da indicação do formulário** (a fatia 75, REL-INDICACAO-PORTA; as 264 (b) e 265 (b) do dono): o código já grava a
linha do convite do formulário com a porta, também para quem indica inativo, e a etapa 3 do amigo sem a ligação marca a adesão
nela, e a linha `indicacaoDoFormulario` vai por `scripts/colunas-gravadas-indicacao-do-formulario.sql` (idempotente; o irmão em
`postgres/`), logo depois da do custo do item, na cópia e na produção (o passo 7 da etapa 1). Rodada em 03/11/2026 nas duas
cópias, duas vezes: a linha nova, a segunda sem efeito; as sete linhas com data. **A migração da porta, no laço, escreve
também o passado** (as marcas "Indicação - <id>" viram linhas do formulário, uma vez, sem comissão): no ensaio geral, a
conferência do fim dela imprime as linhas por porta e as marcas sem linha. **Depois das sete, os pacotes de venda já cancelada**
(a fatia 74g; a resposta 287 (a) do dono, o **D688**): `scripts/encerrar-pacotes-de-venda-cancelada.sql` (o irmão em `postgres/`)
encerra uma vez, com a marca do cancelamento novo, os pacotes ainda ativos de venda já cancelada, depois do código da 74f e na
mesma ordem da produção (o passo 7 da etapa 1, e o 7d do corte de uma vez só), com a contagem e a lista impressas antes do
`UPDATE` e a conferência no fim (0); idempotente. **Não é migração, e não vai ao laço do passo 0.** **Na cópia franquiabv_dev não
roda** (a 287 (a) é do corte): no ensaio geral, roda no banco do ensaio, depois do código, e a contagem de lá deve bater com a da
cópia (em 03/11/2026: 118, de 66 vendas e 39 clientes, nenhum ainda válido). Rodado em 03/11/2026 num banco descartável montado da
cópia, nos dois bancos, duas vezes: os números no **D688**, a segunda sem efeito.

A do Líder é dado, roda no passo 3 da janela
(**D124**; decisão de 06/10/2026), e não muda o que o ensaio mede. `sql/sessoes.mysql.sql` **não** se
aplica: a conferência aceita a `sessoes` só no PostgreSQL (revisão de 03/10).

**As fixtures, que a suíte exige — e que a conferência recusa.** As fases
`caracterizacao` e `volta_atras` rodam a suíte de caracterização, e ela para sem
a unidade 90001000 das fixtures (**D378**). Mas desde 05/09/2026 a fase
`conferencia` para com **2** ("a comparação não vale") quando acha linha da
faixa das fixtures em qualquer dos dois bancos. Pela leitura dos dois roteiros
(o ensaio não foi rodado em 03/10, porque ele roda a carga):

- sobre a cópia do **desenvolvimento**, que tem as fixtures, o ensaio para na
  `conferencia`;
- sobre uma cópia da **produção**, que não as tem, passava da conferência e
  parava na `caracterizacao`. Carregá-las na cópia depois da carga mudaria a
  impressão digital da origem para a volta atrás, e a segunda execução sairia
  anulada. Desde 11/10/2026 elas vão só aos clones (abaixo), e é esse o caso que
  as duas fases cobrem; sobre a cópia do desenvolvimento, o ensaio continua
  parando na `conferencia`.

O ensaio de 27/08 rodou sobre a cópia do desenvolvimento, com as fixtures, antes
da guarda da conferência. **A janela foi decidida em 06/10/2026** (D378; lote de
perguntas, pergunta 7, a opção (a)): a suíte sai do passo 8 do corte. O ensaio
continua precisando dela — é ele que prova a volta atrás.

**Desde 11/10/2026 as duas fases da suíte rodam em clones** (D378, o caminho que
a recomendação da pergunta 7 deixou para o ensaio): as fixtures vão só a uma
cópia de cada banco, e as origens ficam como a carga as deixou.

- **`clone_pg`**, depois das provas dos papéis: o PostgreSQL carregado, já com a
  semente, copiado por `CREATE DATABASE <banco>_suite TEMPLATE <banco>`. O
  TEMPLATE não copia as configurações do banco nem os privilégios dele: a fase
  regrava as duas configurações que o `schema.sql` grava (o `search_path` e o
  fuso — sem o fuso, as datas sairiam três horas fora), **para se o clone não
  ficar com as mesmas da origem**, e roda o `criar-papeis.sh` sobre o clone.
  Depois, as fixtures e as sequências, como no `rodar-caracterizacao-postgres.sh`.
  O TEMPLATE pede a origem sem ninguém ligado: uma sessão aberta nela (um
  adminer, um psql) derruba a fase.
- **`caracterizacao`**: a suíte com um `local.php` próprio, o do PostgreSQL com o
  `dbname` do clone; o da fase `apontar` volta no fim.
- **`clone_mysql`**, antes da volta atrás: o MySQL não tem TEMPLATE, e o clone é
  o `mysqldump` da origem num banco novo com o charset dela, sem os eventos, e as
  fixtures por cima. O pipe roda num `bash -o pipefail`: um dump que morre no
  meio derruba a fase, em vez de deixar o clone pela metade. Numa cópia da
  produção, esta fase custa o tamanho do MySQL — não medido.
- **`volta_atras`**: o `local.php` restaurado, a semente e o conferidor da
  vigência leem a origem, como antes; a suíte roda no clone do MySQL, com o
  `local.php` dele, e o restaurado volta no fim.
- **`apagar_clones`**, fora do `TOTAL`: os dois clones saem. Se o ensaio parar
  antes, eles ficam, e a execução seguinte os apaga e refaz.

Os nomes são sempre os de `BANCO_PG` e `BANCO_MYSQL` com `_suite` no fim — **não
vêm do ambiente**, porque o ensaio apaga e refaz o clone por nome, e um
`CLONE_MYSQL` com o nome de outro banco vivo o apagava (a verificação de
11/10/2026). No fim, o clone do MySQL sai com o GRANT dele (`REVOKE IF EXISTS`):
o `DROP DATABASE` não o tira.

**As guardas do `local.php`, desde a mesma verificação.** O roteiro só começa com o
`local.php` da origem — o host `mysql` **e** o banco de `BANCO_MYSQL`, escritos —, e
confere de novo no `apontar`: o `local.php` do clone do MySQL, que uma execução
morta à força deixa, tem o mesmo host da origem, e passava pela guarda antiga para
ir por cima do guardado bom. E o trap só desfaz o que o próprio processo trocou,
depois do `apontar`, comparando o arquivo com o guardado byte a byte; antes disso
ele não escreve, nem com um guardado velho de outra execução em `ensaios/`.

**Provado** de dois jeitos. Sobre bancos descartáveis (11/10/2026: as funções
tiradas do roteiro, com um `--filter` na suíte): 28 provas verdes, e sete
mutantes, cada um vermelho. E pelo `scripts/ensaiar-clones-do-ensaio-de-corte.sh`,
no `conferir-tudo.sh` e no CI: o roteiro inteiro, num diretório temporário, com
dublês do `docker` e do `php` que anotam em que banco cada pedido caiu — 13 casos
(os dois do `local.php` do modelo do Docker desde 08/11/2026), sem tocar banco
nenhum. **Não rodado:** o ensaio inteiro, que escreve no MySQL da cópia, e qualquer
clone do tamanho da produção — o tempo e o disco das duas fases novas são do
laboratório. **No laboratório ele parava no começo até 08/11/2026** (D469): o
`local.php` de lá é o do `docker/config/local.php.docker`, que lê o host e o banco
do ambiente, e as guardas procuravam os dois escritos. Desde a fatia 128 as guardas,
o usuário do `GRANT` e o `apontar` leem a conexão pelo `local.php` AVALIADO dentro do
contêiner (`scripts/conexao-do-local-php.php`, que imprime `host|banco|usuário`, sem
a senha), e o `apontar` troca também a forma `getenv(...) ?:` — por isso o contêiner
`app` tem de estar de pé já na guarda do começo. Provado só pelos dublês; no
laboratório, não rodado. **Nunca carregue as fixtures na produção**, nem para fazer o
ensaio passar.

**O passo 3 da janela, na cópia.** Desde 06/10/2026 o passo 3 do corte faz
mais que a limpeza, por decisão do dono: esvazia o dicionário de CEP e repõe as
27 UFs (`limpar-dicionario-cep.sql`; **D74**, **D120**, **D430**), renomeia os
inativos dos pares de login que só diferem pela caixa e acerta os logins com
maiúscula (`medir-logins-com-maiuscula.sql`, D349), roda a migração do Líder
(D124) e, se a réplica deu mais que zero, tira a hora das datas de nascimento
(`medir-nascimento-com-hora.sql`, D194). **O roteiro do ensaio ganhou a fase
`passo3`** (revisão de 06/10/2026), logo depois da `limpeza`: o dicionário e o
Líder, que rodam sempre, no MySQL do ensaio — como a `limpeza`, ela escreve
nele. Numa cópia **da produção** é ela que mede o `DELETE` de cerca de 758 mil
linhas no hardware do ensaio, e só na primeira execução: o que ele apaga não
volta, e a segunda o acha vazio. Por isso ela fica fora da lista de tempos de
`comparar-ensaios.sh`; a carga das duas é a mesma, sem o dicionário. A cópia do
desenvolvimento tem 5 logradouros (contados em 03/10/2026), e ali a fase leva
quase nada. Os `UPDATE` dos logins e do nascimento **não** entram no ensaio: a
cópia anonimizada reescreveu logins e datas, e eles dependem da medida na
réplica. Quem os ensaia é `php scripts/ensaiar-sql-do-passo-3.php`, com dados
inventados, junto com os outros dois roteiros; e `--tamanho-de-producao` dá o
tempo do dicionário fora do ensaio (3,7 s e 5,2 s em 06/10/2026, ver "Quanto
tempo a janela precisa" no procedimento). **A fase não rodou ainda num ensaio
inteiro** (o ensaio não roda aqui); o comando dela rodou sozinho, num banco
descartável, pelo mesmo `docker compose exec`: 27 UFs e o Líder com
`gerente = 1`. O de 27/08, abaixo, ainda tinha o dicionário. E o **7c** do corte, a passada das tags (**D119**), não é fase do
roteiro: meça-a à parte, contra o PostgreSQL que o ensaio carregou, e some à
janela.

---

## O hardware é metade do resultado

O BVIP tem 24 núcleos; a máquina onde as execuções abaixo rodaram tem 20. A
produção tem **4 vCPU**. Cronometrar em qualquer um dos dois e usar o número
para dimensionar a janela dá uma estimativa otimista por um fator grande.

Limite o contêiner ao formato de produção antes de medir:

```yaml
postgres:
  deploy:
    resources:
      limits:
        cpus: "4"
        memory: 16g
```

`ensaiar-corte.sh` **registra se o limite existe**, e `comparar-ensaios.sh`
avisa quando não existe. Um ensaio sem limite ainda prova repetibilidade — só
não serve para dimensionar janela, e o registro diz isso com todas as letras em
vez de deixar o número solto.

Ainda assim erra a favor: quatro núcleos dedicados rendem mais que 4 vCPU
compartilhadas, e não há vizinho disputando disco. Erra por margem pequena e
conhecida, em vez dos 6× do número cru.

---

## Como rodar

```bash
SENHA_APP='…' sh scripts/ensaiar-corte.sh primeira
SENHA_APP='…' sh scripts/ensaiar-corte.sh segunda
sh scripts/comparar-ensaios.sh ensaios/primeira.txt ensaios/segunda.txt
```

**No BVIP, do clone do laboratório e com o projeto dele** (desde 08/10/2026, a
resposta 37 do dono; **D431**): pelo bloco "O ensaio geral do corte, no
laboratório", da seção "Laboratório de migração (Fase 1)" do
`ambiente-homologacao.md` — as mesmas três linhas, num subshell que entra no
clone, confere o `.env` dele e dá o `export PROJETO=franquiabv-lab`, e que PARA
se o clone ou o `.env` não forem os do laboratório. O roteiro fala com `docker
compose -p "$PROJETO"`, e sem o `export` usa `franquiabv`, que no BVIP é a
homologação — de qualquer diretório. Um `cd ~/franquiabv-lab && export ...`
solto não basta: com o `cd` falhando, o `export` não acontece e as três linhas
rodam assim mesmo (a revisão de 08/10/2026). A mesma seção diz como subir o
clone.

Cada execução refaz tudo — limpeza, o passo 3 decidido em 06/10/2026, schema, papéis, carga, as duas conferências,
a semente e a vigência do horário, a suíte de caracterização (67 testes em
27/08; 1.611 casos em 03/10/2026; desde 11/10/2026 nos clones com as fixtures) — e **ensaia a volta atrás no fim**. Se qualquer fase falhar, o
roteiro para ali e mostra as últimas linhas do log daquela fase.

Os registros ficam em `ensaios/`, que não é versionado: ele guarda uma cópia do
`local.php`, que tem credenciais.

### A volta atrás entra no ensaio de propósito

Metade do procedimento de corte é **poder desistir**, e essa metade quase nunca
é ensaiada — descobre-se que não funciona no dia em que ela é a única saída.

O roteiro aponta a aplicação para o PostgreSQL, roda a suíte, **volta para
o MySQL e roda a suíte de novo**. Se a segunda rodada falhar, a volta atrás não
existe, e é melhor saber num sábado de laboratório.

---

## As fases, e o que cada uma diz

| Fase | O que mede | Como escala |
|---|---|---|
| `limpeza` | `pre-migracao-limpeza.sql` no MySQL | com o número de linhas inválidas, não com o tamanho da base |
| `passo3` | o dicionário de CEP esvaziado, as 27 UFs repostas e a migração do Líder, no MySQL (desde 06/10/2026) | com o tamanho do dicionário: cerca de 758 mil linhas numa cópia da produção, e nada na segunda execução — fora da comparação de tempos |
| `schema` | gerar e carregar o DDL | quase constante — é estrutura, não dado |
| `papeis` | criar os dois papéis | constante, segundos |
| `carga` | **a fase dominante** | com o volume; as seis maiores tabelas concentram o trabalho |
| `conferencia` | as seis provas (a sexta, a forma, desde 31/08) | com o volume, e a quinta varre toda coluna de data |
| `semente` | a vigência do horário, do 5b | com o número de usuários |
| `papeis_provas` | as seis provas dos papéis (eram dezesseis até 09/09, D9) | constante |
| `clone_pg` | o PostgreSQL carregado copiado por TEMPLATE, com as fixtures só na cópia (desde 11/10/2026, D378) | com o tamanho do banco: é cópia de arquivo |
| `apontar` | trocar o `local.php` e limpar os proxies | constante |
| `vigencia` | o conferidor e o ensaio do 7b | constante |
| `caracterizacao` | a suíte de caracterização, no clone do PostgreSQL (ver acima) | cresce com a suíte: 67 testes em 27/08, 1.611 casos em 03/10 |
| `clone_mysql` | o dump do MySQL da cópia num banco novo, com as fixtures (desde 11/10/2026, D378) | com o volume, como a carga — não medido |
| `volta_atras` | apontar de volta e revalidar, com a suíte no clone do MySQL | como a `caracterizacao` |
| `apagar_clones` | os dois clones saem; fora do `TOTAL` | segundos |

**Por fase e não só o total**, porque elas escalam de formas diferentes. Se a
janela ficar apertada, é preciso saber *onde* apertar — e o total sozinho não
diz. Se a `conferencia` estiver custando metade do tempo, a decisão possível é
rodar parte dela depois de liberar o acesso; se a `carga` estiver, a decisão é
outra.

---

## O que se compara, e com que régua

Três classes de linha, com regras diferentes. **É a distinção entre elas que faz
a comparação significar alguma coisa.**

### Origem — idêntica, ou o ensaio é anulado

Contagem e maior id das seis maiores tabelas. Diferente significa que a base
mudou entre as execuções.

**Anulado não é reprovado.** Não há defeito a corrigir: há uma medição a refazer
com a origem parada.

### Resultado — idêntico, sem tolerância

Contagem no destino, valor da sequência, extremos de data, soma de
`vendas.valor_total`, e o veredito dos portões — cinco desde a vigência do
horário (as chaves `portao_` do registro; eram quatro).

**Duas execuções com tempos parecidos e contagens diferentes é o pior dos casos,
e o mais fácil de não perceber.** Se isto divergir, a carga não é
determinística, e entender por quê vem antes de qualquer corte.

### Tempo — dentro de uma faixa

Padrão: **20%** de diferença entre as duas execuções, por fase e no total.
Ajustável por `TOLERANCIA=…`.

Fases abaixo de 10 segundos não são julgadas: 1s contra 2s é 100% de diferença e
não significa nada.

Fora da faixa quer dizer que algo não é determinístico — cache frio numa e
quente na outra, vizinho na máquina, concorrência. **O número não serve para
dimensionar janela nenhuma até isso ser entendido.** Repita.

---

## O ensaio de 27/08/2026, no laboratório

Duas execuções, com a origem parada entre elas. **`ENSAIO VÁLIDO`.**

| Fase | 1ª | 2ª | Diferença |
|---|---:|---:|---:|
| `limpeza` | 25 s | 25 s | 0% |
| `schema` | 7 s | 8 s | curto |
| `papeis` | 2 s | 2 s | curto |
| **`carga`** | **521 s** | **511 s** | **1%** |
| `conferencia` | 244 s | 237 s | 2% |
| `papeis_provas` | 31 s | 31 s | 0% |
| `apontar` | 2 s | 1 s | curto |
| `caracterizacao` | 149 s | 140 s | 6% |
| `volta_atras` | 521 s | 500 s | 4% |
| **TOTAL** | **1502 s** | **1458 s** | **2%** |

Resultado idêntico nas duas: contagens, sequência, extremos de data, soma de
`vendas.valor_total` (`155232205.085480`) e os quatro portões.

**Estes números NÃO dimensionam a janela.** As duas execuções rodaram **sem
limite de contêiner**, numa máquina de 20 núcleos, com origem e destino no mesmo
host — e o registro diz isso em letras maiúsculas, para ninguém pegar o número
solto. O que eles provam é **repetibilidade**: 25 minutos de ponta a ponta, com
1% de variação na fase que domina.

Repare que `volta_atras` custa quase tanto quanto `carga`: são os 67 testes
rodando contra o MySQL, que é 3,5× mais lento que o PostgreSQL nesta suíte. Num
corte real a volta atrás não precisa rodar a suíte inteira — mas precisa rodar
alguma coisa, e vale saber quanto custa.

### O comparador acusou, e estava errado

Na primeira comparação o veredito foi `NÃO CONCLUSIVO`, apontando diferença em
`caracterizacao` e `volta_atras`. Os tempos e os resultados estavam todos certos;
o defeito era do comparador.

A mesma palavra servia de rótulo para **o tempo da fase** e para **o veredito do
portão**, e a busca pegava a primeira ocorrência. Ele comparava tempos achando
que comparava vereditos — e, como tempos variam, acusava sempre.

Vale registrar por dois motivos. Primeiro, o conserto: chaves com prefixo
`portao_`. Segundo, e mais importante: **um portão que acusa pelo motivo errado
custa a confiança de todos os outros.** Da segunda vez que ele disser "não
conclusivo", alguém vai supor que é bug de novo — e essa suposição, um dia,
estará errada.

---

## Provar que os portões acusam

Um portão verde só informa se ele for capaz de ficar vermelho. Estas quatro
quebras são deliberadas, baratas, e cada uma tem a sua restauração:

| Quebre assim | Portão que tem que acusar | Restaure com |
|---|---|---|
| `SELECT setval('vendas_id_seq', 1);` | sequência atrasada | `setval('vendas_id_seq', (SELECT max(id) FROM vendas))` |
| `UPDATE unidades SET royltes_servico = royltes_servico + 0.01 WHERE id=1;` | soma decimal | subtrair 0.01 |
| `ALTER DATABASE … SET timezone='UTC';` e reconectar | extremos de data | voltar para `America/Sao_Paulo` |

**ERAM QUATRO.** A quarta quebrava um `INSERT INTO unidades` conectado como
`bvip_bi` e exigia que o portão acusasse a escrita recusada. Saiu em 09/09/2026
com o Power BI (**D9**): o papel não existe mais, e falsificação que não pode
ser executada é decoração.

As três restantes já foram exercitadas em 27/08/2026 e as três acusaram. **Refaça-as
no ensaio**, porque um portão pode deixar de funcionar por uma mudança de
ambiente sem que ninguém mexa nele — o `-q` do psql escondeu a linha `COPY <n>`
exatamente assim.

---

## O teste da segunda pessoa

**Alguém que não escreveu os roteiros executa o `procedimento-de-corte.md` do
começo ao fim, sem perguntar nada.**

É o único jeito de encontrar o passo que está na cabeça de quem escreveu e não
no papel. Já aconteceu neste projeto: o roteiro de testes omitia o carregamento
das fixtures, e o sintoma foram 66 de 67 testes falhando com erro de chave
estrangeira — que parece defeito de código e é passo faltando.

Anote toda pergunta que essa pessoa precisar fazer. Cada uma é uma linha que
falta no procedimento.

---

## Quando o ensaio está pronto

Todas as condições, juntas:

| | |
|---|---|
| Duas execuções seguidas | com a origem parada entre elas |
| `comparar-ensaios.sh` | diz **ENSAIO VÁLIDO** |
| Contêiner limitado | 4 CPU e 16 GB, e o registro comprova |
| As três quebras de portão | feitas de propósito, e acusaram (eram quatro até 09/09) |
| O passo 0 | aplicado na cópia (na do desenvolvimento, pelo laço dela, sem o que o cabeçalho proíbe ali), e `conferir-passo-zero-do-corte.php` verde |
| A volta atrás | ensaiada, com a suíte verde depois dela — no ensaio, e não na janela (o passo 8, decidido em 06/10/2026) |
| Segunda pessoa | executou o procedimento sem perguntas em aberto |
| Teste manual | cronometrado à parte, e somado à janela |

---

## O que o ensaio não pode dizer

**Não diz que o corte vai correr bem.** Diz que a sequência funciona, que o
tempo é previsível, e que dá para voltar. O corte real tem tráfego, gente
esperando e decisões às três da manhã.

**Não mede a rede.** No laboratório origem e destino estão no mesmo host. Num
corte real podem estar em máquinas diferentes, e a transferência de ~950 MB
entre elas não aparece em nenhum número daqui.

**Não cobre os arquivos.** Contratos, certificados e fotos moram no disco local
(**D33**) e não entram em nenhuma fase.

**A base é a anonimizada, se for a do laboratório.** A anonimização reescreve
exatamente as colunas de texto e data onde moram os casos difíceis — foi assim
que 2.397 datas inválidas passaram despercebidas até a F1.3. **Ensaie contra
uma cópia da produção real**, não contra a anonimizada, ou o ensaio não mede o
que o corte vai encontrar.
