# Fase 1 — MySQL para PostgreSQL

Plano de execução, no mesmo formato da Fase 0: frentes com entregável e portão
de saída verificável, em vez de datas.

Escrito em 17/08/2026.

É a primeira fase que toca produção. Abre com a remediação que ficou adiada e
fecha com o corte do banco.

---

## Ordem e dependências

Duas decisões travam quase tudo, e por isso vêm primeiro. Nenhuma é técnica.

```
F1.1 Remediação de produção ──────────────────┐
                                              │
F1.2 Decisões  ── SaaS ──► F1.3 Schema ──► F1.4 Consultas ──┐
     de arquitetura                    └──► F1.6 Agendamento │
               └─ RPO/RTO ─────────────────► F1.7 Alta       │
                                              disponibilidade│
F1.5 Estado fora do disco ────────────────────┘              │
                                                             ▼
                                                    F1.8 Corte
                                                             │
                                                    F1.9 BI ─┘
```

O caminho crítico passa por **F1.2 → F1.3 → F1.4 → F1.8**. A F1.5 é o pré-requisito
esquecido: sem ela a F1.7 não funciona de verdade, e ela é a única que exige
mudança de código de aplicação.

---

## ESCOPO ATUAL: produção congelada, trabalho no BVIP

Decisão do responsável em 23/08/2026: **não executar mais nada no servidor de
produção por ora.** Todo o trabalho segue no BVIP, que é o ambiente de teste e
homologação.

Isso não desfaz o que já foi feito nem deixa a situação pior do que estava — e
vale saber exatamente onde parou:

| | |
|---|---|
| Arquivos com dados pessoais no webroot | **Removidos** em 21/08. URLs em 404 |
| Apuração LGPD | **Feita. Zero acesso externo** no período retido |
| Rota `/autenticacao/migrar-senhas` | **Continua ativa** |
| Seis credenciais em texto claro | **Continuam** no código versionado |
| `local.php` e deploy | pendentes |

Ou seja: **a exposição de dado pessoal foi fechada**; o que permanece é
superfície de ataque que exige alguém querer explorá-la. O script
`bloquear-migrar-senhas.sh` está pronto e provado contra um nginx real, para
quando a decisão mudar.

---

## F1.1 — Remediação de produção

A W6 da Fase 0, adiada por decisão de 15/08. Sete passos em
`dia0-remediacao-producao.md`, com a parte mecânica já em script testado.

Não depende de nada e pode começar hoje. Está aqui porque foi deslocada para cá,
não porque precise esperar.

**Portão de saída:** `remediacao-dia0.sh` sem achados, `verificar-local-php.php`
sem falha fatal, `docs/incidente-lgpd-dia0.md` versionado com o resultado da
apuração — mesmo que vazio.

---

## F1.2 — Decisões de arquitetura

Duas perguntas de negócio que definem o desenho. Enquanto não forem respondidas,
tudo depois delas é retrabalho em potencial.

### SaaS multi-inquilino — DECIDIDO

Meta informada em 17/08: **~4.000 inquilinos até o fim de 2028**, a maioria com
uma unidade só. Comparação completa em `f1-2-decisao-saas.md`.

**Modelo: pool com coluna de inquilino e RLS para a massa, banco próprio para os
grandes** (a Barbearia VIP entre eles). É o único que chega a 4.000 — schema por
inquilino daria 332 mil tabelas no catálogo, e instância dedicada, 4.000 pilhas.

O que torna isso barato neste código: **RLS filtra sem a consulta saber.** A
conexão declara o inquilino ativo e o banco aplica a política. O código legado
segue sem alteração de consulta; muda um ponto só, onde a conexão é aberta.

A F1.3 passa a carregar o trabalho de schema, detalhado abaixo.

### Meta de perda e de retorno (RPO/RTO) — DECIDIDO

Confirmado em 17/08. Medição e raciocínio em `f1-2-metas-rpo-rto.md`.

**RPO 0 para falha de máquina e ≤ 30 s para perda de datacenter; RTO ≤ 5 minutos,
automático.**

A justificativa do responsável, que vale registrar porque é o que sustenta a
meta: *"se cai, o caixa deixa de conseguir fazer lançamento, mas a venda em si é
realizada e o cliente é atendido, tudo presencialmente. No caso de mudança,
passa-se a lançar corretamente."*

Ou seja, o faturamento não evapora numa parada curta — fica represado e entra
depois. Isso é o que torna 5 minutos suficiente. E tem um limite estrutural, o
**D28**: o sistema não sabe registrar uma venda na data em que ela ocorreu, então
o lançamento posterior só é inócuo enquanto a recuperação couber dentro do mesmo
caixa. É a razão de a meta ser 5 minutos e não uma hora.

O que sustenta: no pior minuto de 90 dias passam 9 vendas e R$ 1.305. Perder até
30 s custa cerca de R$ 653 — pouco para justificar replicação síncrona remota,
que cobraria latência em toda venda para sempre. E o modo de falha real de 2025
foi a perda de um datacenter inteiro, contra a qual um standby síncrono no mesmo
datacenter não protege: quem sobrevive é o nó remoto, que é assíncrono.

No lugar disso, entra um **relatório de reconciliação pós-promoção**: custa uma
vez e transforma perda invisível em redigitação de dez minutos.

**Portão de saída: ATINGIDO.** As duas decisões estão registradas com a
justificativa, em `f1-2-decisao-saas.md` e `f1-2-metas-rpo-rto.md`. O caminho
crítico está liberado — a F1.3 pode começar.

### Achado colateral: a janela de corte

Domingo movimenta 50,2 vendas contra 982,8 da sexta — 5% do movimento. **O corte
da F1.8 acontece no domingo de manhã.** Duas horas de parada ali atingem cerca de
4 vendas; as mesmas duas horas na sexta à tarde atingiriam mais de 130.

---

## F1.3 — Conversão do schema

83 tabelas de MySQL para PostgreSQL. Os pontos que exigem julgamento, não
conversão automática:

| Item | Decisão |
|---|---|
| `utf8mb3` → UTF-8 | Resolve o D6. Verificar antes se há dado já truncado |
| `tinyint(1)` usado como booleano | Vira `boolean`. Atenção ao D17 e ao `fidelizado_unidade`, que é booleano apesar do nome sugerir referência |
| `enum` | PostgreSQL tem tipo enumerado próprio, com regras de alteração diferentes |
| `datetime` | Decidir entre `timestamp` e `timestamptz` — a franquia é toda no mesmo fuso hoje, mas o multi-idioma da Fase 2 sugere `timestamptz` |
| `usuarios.senha_php83` | Coluna órfã. Some aqui (D7) |
| Ids fixos em regra de negócio | O cliente 1 "avulso" e afins. Registrar, não corrigir agora (D18) |
| **`inquilino_id`** | Coluna nas tabelas com escopo, preenchida com o inquilino 1. Fazer agora custa quase nada — depois seria `ALTER TABLE` em centenas de milhões de linhas |
| **Políticas RLS** | `ENABLE` + `FORCE`, com `WITH CHECK` e valor padrão vindo do contexto da conexão |
| **Particionamento** | **Adiado, com gatilho escrito.** Índice único em tabela particionada precisa conter a chave de partição, o que torna as PKs compostas e se propaga pelo Doctrine e pelos 67 testes. Entra quando `agendas` ou `vendas` passar de ~100 milhões de linhas |
| **Índices** | Em tabela com escopo, **todo índice começa por `inquilino_id`**. Caro de corrigir depois |

Levantamento feito e decisões registradas em **`f1-3-conversao-schema.md`**. O
schema é mais limpo que o típico legado: sem ENUM, SET, UNSIGNED, data zero no
schema, `ON UPDATE CURRENT_TIMESTAMP`, BLOB ou palavra reservada — e as 83
tabelas têm chave primária.

Três decisões saíram do levantamento: `int` vira **`bigint`** em `agendas`,
`vendas` e `vendas_produtos`, que projetam centenas de milhões de linhas;
`datetime` vira **`timestamptz`**, porque o Brasil tem quatro fusos e o SaaS terá
inquilinos em mais de um; e `double` vira `double precision`, conversão fiel que
preserva o comportamento (D30).

Duas pendências antes de gerar o DDL:

1. **`scripts/pre-migracao-limpeza.sql` em produção**, antes do dump. São 19
   linhas com anos que o PostgreSQL rejeita — o script já foi exercitado na base
   anonimizada.
2. **Conferir duplicatas na réplica de produção.** Decide se as restrições de
   unicidade do D29 entram nesta fase. A base anonimizada não serve: ela deriva
   login, e-mail, CPF e hash do `id`.

**Reconfirmado em 26/08/2026 no BVIP**, agora contra o schema de PRODUÇÃO
carregado da réplica, e não contra o `mysql.sql` versionado: 0 tabela mapeada
ausente, 0 coluna ausente, 0 tipo bloqueante, RLS com as 6 asserções e os 34
casos de telefone passando. Os 54 tolerados são o D17 pré-existente.

**Portão de saída: ATINGIDO.** Schema criado no PostgreSQL, `bvip`, com 84
tabelas e RLS em 75. `verificar-schema-postgres.php` acusa zero tabelas ausentes,
zero colunas ausentes e zero divergências bloqueantes; os 54 restantes são D17
pré-existente, toleradas por conversão implícita e documentadas. Os 67 testes de
caracterização continuam passando.

O portão roda no CI, num job próprio com PostgreSQL 16, junto com o teste de
isolamento entre inquilinos e o de normalização de telefone. O CI também confere
que o `postgres/schema.sql` versionado é idêntico ao que o gerador produz — ele
não é editado à mão.

**Resta desta frente**, e depende do servidor: rodar `pre-migracao-limpeza.sql`
em produção antes do dump, e conferir duplicatas na réplica para decidir o D29.

---

## F1.4 — Compatibilidade de consultas

O D24. 71 consultas inventariadas: 4 de risco alto, 29 a revisar, 38
compatíveis. Duas das quatro já confirmadas quebrando — a geração de cupons e o
relatório Dashboard de Colaboradores, que devolve HTTP 500.

O PostgreSQL não tem modo permissivo: o que quebrar, quebra em produção.

Ferramenta pronta: rodar a suíte com `ONLY_FULL_GROUP_BY` ligado no MySQL é o
melhor detector barato antes de haver PostgreSQL de pé.

**Portão de saída: ATINGIDO em 27/08/2026** — e **qualificado em 05/09/2026**,
que é o que esta linha existe para dizer. `auditar-group-by.php` com zero
quebras e zero consultas fora da análise, e a caracterização verde com
`ONLY_FULL_GROUP_BY` ligado — 67 testes, 128 asserções.

> **O zero era verdadeiro e era incompleto.** Ao pedir as telas do ERP contra o
> PostgreSQL (**D97**), duas falharam com erro de agrupamento — com esta
> auditoria dizendo zero. A causa estava dentro da própria ferramenta: ela
> tratava `GROUP BY vp.formaPagamento` como equivalente a `GROUP BY fp.id`,
> porque a junção casa aquele valor e os grupos são os mesmos. **São mesmo — e o
> PostgreSQL recusa assim mesmo**, porque só reconhece dependência funcional a
> partir da chave primária da própria tabela. Era a dedução do MySQL 8 escrita
> dentro do detector do defeito do MySQL.
>
> A regra saiu, a consulta foi corrigida, e a auditoria voltou a zero — agora um
> zero que também recusa aquela forma. **`ONLY_FULL_GROUP_BY` no MySQL não é
> substituto para executar no PostgreSQL:** o modo estrito do MySQL aceita a
> dependência através da junção, e por isso não podia ter acusado.

A suíte de contrato tem 4 falhas que **não medem esta frente**, e isso foi
provado: com o modo estrito ligado e desligado falham exatamente as mesmas
quatro. São instantâneos gravados com fixtures comparados contra a base real
carregada localmente, onde o endpoint devolve mais dados do que o instantâneo
esperava. O CI roda com fixtures, e lá passam.

De 33 quebras para zero: 9 mecânicas, 7 removidas por serem código ou colunas
mortas, 14 decididas com o responsável, e 3 suprimidas com justificativa escrita
e verificação por execução. Detalhe em `f1-4-consultas.md`.

O número começou em 4 porque a ferramenta classificava pela forma da consulta e
não pela consequência. Corrigi-la foi metade do trabalho — e ela terminou com uma
propriedade que não tinha: **não dar conta de uma consulta não é o mesmo que
aprová-la**, e o que ela não consegue ler aparece em separado, nunca em silêncio.

---

## F1.5 — Estado fora do disco local

Pré-requisito da F1.7, e a única frente da fase que mexe em código de aplicação.

Hoje o sistema grava em disco local, o que amarra a operação a uma máquina
específica:

- **Uploads** em sete diretórios (`arquivos`, `contratos`, `banners`,
  `assistentes`, `vip-class/{capas,materiais,certificados}`,
  `layout/img/avatar`). A replicação do banco não os replica: uma promoção
  entregaria o sistema sem contratos nem certificados, com referência no banco
  apontando para arquivo inexistente.
- **Sessões**, via `Laminas\Session\Container` — manipulador padrão do PHP, em
  arquivo local. Uma promoção desloga todos.

Uploads vão para armazenamento de objetos compatível com S3; sessões vão para
banco ou Redis.

**Portão de saída: ATINGIDO em 26/08/2026**, no servidor BVIP.

| | |
|---|---|
| Abstração de armazenamento | `Armazenamento` + driver local + driver S3 |
| Prova | 20 casos, os mesmos nos dois drivers, contra MinIO real |
| Sessão fora do disco | `DbTableGateway` sobre a tabela `sessoes` |
| Prova | 5 casos: grava, outro processo lê, terceiro vê alteração, destrói |

O que fecha o portão de verdade é o teste de sessão ter rodado **entre
processos**, e não dentro do mesmo: é isso que prova que uma promoção não
desloga ninguém.

**A lacuna que fica escrita, e não é surpresa:** por decisão de 19/08, os
objetos continuam na máquina da aplicação. A abstração está pronta e trocar de
driver é editar o `local.php`, mas enquanto a troca não acontecer a camada de
arquivos segue fora do failover — é o **D33**, com o inventário do que
implementar no **D34**, e o visagismo adiado no **D35**.

Ou seja: o portão está atingido no que ele mede — código pronto e provado. A
distribuição em nuvem é decisão de contratação, não de engenharia, e está
registrada como tal.

---

## F1.6 — Agendamento

Duas rotinas hoje moram **dentro do banco** como MySQL EVENTs (D26). O
PostgreSQL não tem equivalente: viram `pg_cron` ou agendador externo.

Junto vai a pendência da W3 que exige acesso ao servidor: **inventariar o
crontab**. Sabemos quais rotas de cron existem no código; não sabemos quais estão
agendadas nem com que frequência.

Vale conferir também se o event scheduler está de fato ligado em produção
(`SELECT @@event_scheduler;`) — um evento habilitado num servidor com o
scheduler desligado nunca rodou, e isso muda o que precisa ser migrado.

**Portão de saída: ATINGIDO em 27/08/2026.** O crontab de produção foi lido: 25
entradas via `curl`, mais os 2 `EVENT` dentro do banco. Cada rotina tem quem
dispara, com que frequência e o que quebra se falhar, em `f1-6-agendamento.md`.

O inventário encontrou quatro coisas que esperam decisão e não bloqueiam a fase:
uma rotina alcançável que ninguém agenda (D43), a trava usada em 2 de 29 rotinas
e sendo de arquivo (D44), duas entradas apontando para `www.` que pode não
responder, e a escolha entre `pg_cron` e agendador externo para os dois eventos.

---

## F1.7 — Alta disponibilidade

Requisito de 17/08: promoção automática, sem intervenção humana. Detalhe em
`alta-disponibilidade.md`.

Componentes: promoção automática (Patroni + etcd), quórum e fencing, VPN em
malha para o nó atrás de CGNAT, e troca de tráfego por DNS com verificação de
saúde — lembrando que o aplicativo móvel também resolve por DNS e há versões
antigas em campo.

Resolve junto o D27: o parque hoje é misto, com um nó em MySQL 8.4 contra 8.0.4x
dos demais. Na conversão todos nascem na mesma versão.

**Portão de saída: ENSAIADO EM LABORATÓRIO em 27/08/2026, não no parque.**

Um agrupamento Patroni + etcd foi montado e o primário morto com SIGKILL, três
vezes: promoção automática em **30 a 36 segundos** contra a meta de 300, com
reintegração automática do nó caído e RPO 0 configurável — o `sync_standby` é
designado sozinho ao ligar o modo síncrono.

O laboratório é versionado (`docker-compose.ha.yml`) e o roteiro é repetível
(`scripts/ensaiar-failover.sh`). Números e, principalmente, **o que eles não
provam**, em `f1-7-ensaio-failover.md`.

Faltam cinco itens para o portão fechar no parque real: VPN em malha para o nó
atrás de CGNAT, três nós de etcd para haver quórum, arquivos fora do disco local
(D33), troca de tráfego por DNS, e o ensaio com o primário de verdade.

---

## F1.8 — Migração dos dados e corte

~16,2 milhões de registros. Ensaio cronometrado antes do corte real, no
laboratório do BVIP, com o contêiner limitado a `cpus: "4"` e `memory: 16g` para
não medir num hardware seis vezes maior que o de produção.

O ensaio se repete até o tempo ser previsível e o resultado, idêntico duas vezes
seguidas.

**Portão de saída:** os mesmos 67 testes de caracterização, sem alteração
nenhuma, passando contra PostgreSQL. Se o comportamento financeiro mudou, eles
acusam. É o portão da fase inteira.

**FECHADO em 27/08/2026.** 67 testes, 128 asserções, verdes nos dois bancos. A
suíte de contrato tem as mesmas 4 falhas conhecidas nos dois — nenhuma nova. A
carga: 83 tabelas, 533 segundos, conferida por quatro provas independentes.
Registro completo em `f1-8-migracao-dados.md`. A sequência do corte está em
`procedimento-de-corte.md`, e o roteiro para validá-la antes, em
`ensaio-geral-do-corte.md`.

**O que o portão não cobre**, e precisa ser dito junto: ele prova que a carga
preserva os dados e que o comportamento financeiro medido não mudou. O ensaio
de corte com janela, tráfego e volta atrás **não foi feito** — e a cobertura é a
dos 67 testes, que são o núcleo financeiro, não os relatórios nem as 29 rotinas
de cron.

---

## F1.9 — Repontar o BI

O sistema de BI lê o MySQL de produção direto, com credencial que ninguém
mapeou (D9). Se o banco migrar sem ele, quebra no corte.

Aqui vira usuário **somente leitura** sobre réplica, nunca sobre o primário.

**Portão de saída:** BI lendo do PostgreSQL, com credencial própria e sem
permissão de escrita.

**PARCIALMENTE FECHADO em 27/08/2026.** O lado do banco está pronto e provado:
`bvip_bi`, somente leitura (cinco tentativas de escrita recusadas), com escopo
por inquilino (com contraprova) e lendo de réplica — onde nem o superusuário
escreve. O lado da ferramenta **não foi tocado**: repontar o Power BI exige
trocar o conector, instalar o provedor Npgsql no gateway e republicar, e nada
disso é trabalho de banco.

**ENCERRADA EM 09/09/2026, e não por ter sido concluída.** O Power BI saiu do
sistema por decisão do dono (**D9**), e o portão de saída desta frente — "BI
lendo do PostgreSQL" — deixou de ter sujeito. O papel `bvip_bi` saiu junto. O
que a frente entregou de permanente não foi o BI: foi o `bvip_app` e a
descoberta abaixo.

**A frente revelou um problema maior que ela.** As 75 políticas de isolamento da
F1.3 **não estavam em vigor**, porque a aplicação conecta como superusuário.
Corrigido no ambiente com `bvip_app`, com os 67 testes passando e o RLS valendo.
Produção ainda não. Ver **D48** e `f1-9-bi-e-papeis.md`.

---

## O que esta fase não faz

Vale dizer para não virar expectativa:

- **Não moderniza tela.** Isso é Fase 3. O sistema sai da Fase 1 com a mesma
  cara, rodando sobre outro banco.
- **Não corrige os defeitos caracterizados.** D20 (sangria deixa caixa negativo)
  e D22 (campos de Lava VIP cruzados nos royalties) continuam se comportando
  igual, de propósito: a suíte de caracterização existe justamente para provar
  que a migração não mudou comportamento nenhum, nem os errados. Corrigir é
  decisão separada, depois do corte.
- **Não troca o token da API do aplicativo** (D5). Derrubaria o app na mão dos
  clientes.
