# PLAN — Docker & configuração sistemática (dev · homolog · prod)

> **Origem:** `update_docker/09-veredito.md` (63 itens julgados). Este documento é o **plano de
> execução** do que o veredito aprovou, na ordem em que deve ser feito.
> **Escopo:** runtime de containers (Docker/Compose) e a configuração sistemática que garante o
> projeto em bom estado em **dev**, **homolog** e **prod**.
> **Estado:** em execução. **D0–D7 implementadas e validadas** — ondas A e B completas (imagem
> única, tuning do banco, observabilidade do edge/cache, isolamento de rede + hardening) e a D7
> (inventário de integridade, backup/restore da mídia fora do host, versioning + PAB no bucket,
> RustFS 1.0.2 com o dado provado intacto). **D8 é a única aberta** e está bloqueada por medição
> no host (7 dias de `docker stats`) e por três decisões de produto (nome do projeto, `X-Request-ID`
> e destino offsite da mídia). Cada task abaixo está registrada no `kd` (epic `epic_00vr694c`).
> **Regra do agente:** o `git` é somente-leitura — **quem commita é o humano** (AGENTS.md §0).

---

## 0. Regras transversais (valem para TODAS as tasks)

Estas regras vêm do Constitution e **não se negociam por task**. Qualquer implementação que as
viole está errada, mesmo que "funcione".

| # | Regra | Origem | Como se cumpre |
|---|---|---|---|
| **R1** | **`APP_ENV` no `.env` é o ÚNICO interruptor** de ambiente | ADR-032 | Um artefato só. **Proibido** criar `docker-compose.dev.yml` / `.prod.yml` / `.env.dev` / `.env.prod` (o ADR-032 já descartou os dois). Toda diferença dev↔homolog↔prod sai de `config/environments.php` + `APP_ENV`. |
| **R2** | **`homolog` = `prod`** | ADR-032 §"perfis" | Homolog espelha produção: **mesmo** runtime, **mesmas** timeouts, **mesma** rede, **mesma** imagem, **mesmo** Postgres. As **únicas** diferenças admitidas são as já declaradas no ADR-032: OPcache com `validate_timestamps=1` e log em `debug`. Qualquer task que crie um desvio novo entre homolog e prod é rejeitada. |
| **R3** | **Artefato único, logs ligados em produção** | ADR-031/032/024 | Nenhuma config congelada no build; `docker compose logs` funciona nos três perfis. O `json-file` rotacionado (10 MB×3) é a retenção mínima garantida. |
| **R4** | **O `.env` guarda identidade + segredos**; default vive em `config/` | ADR-032 | Ao adicionar variável: default em `config/` (ou no Compose com fallback), `.env.example` só com o **mínimo** e **comentado** o que for opcional. Nada de flag redundante. |
| **R5** | **Determinismo** | ADR-001 | Nenhuma escolha dependente de ordem de container, relógio ou `rand()`. Timeouts e limites são **números explícitos**, nunca "o default do Docker". |
| **R6** | **Zero CDN / zero cópia strong-copyleft** | ADR-002 | Nenhuma imagem/binário com AGPL/GPL. Stack de coleta **rejeitada** (veredito §6). Licença permissiva só, com gate. |
| **R7** | **Não-breaking change**: o que já funciona continua funcionando | ADR-042 | Nenhuma alteração de rota, contrato HTTP, formato de dado persistido ou nome de tabela. Mudança de runtime só. |
| **R8** | **Toda mudança precisa de um guard que a impeça de voltar** | ADR-038/001 | Nenhuma task fecha sem: (a) um **gate** que falhe se a mudança regredir, (b) um **teste** que prove o comportamento, (c) o `make check` **2× verde** (sem flaky). |
| **R9** | **Execução no container**, nunca no host | AGENTS.md | `make exec cmd='…'` para tudo que roda PHP; `docker compose` para o resto. |
| **R10** | **Nada de `rm -rf`** | AGENTS.md | Descarte em `.pi/trash/`. |

### O "3 em 1" que cada task deve responder

O plano exige, para **cada** task, três respostas explícitas — é a estrutura usada em §2:

1. **Implementar** — o que muda, em quais arquivos, e por quê (com a decisão de projeto).
2. **Testar/validar** — como se prova que funciona, **nesta sessão**.
3. **Não-regressão** — o que impede que a melhoria volte a ser perdida (gate + teste + onde).

---

## 1. Panorama

| Onda | Issue (kd) | Objetivo | Itens do veredito | Risco |
|---|---|---|---|---|
| **A** | `D0` | **Corrigir o defeito de upload** (cadeia de timeout) | SK-01, SK-09, P-02, P-07, SK-04 | Baixo |
| **A** | `D1` | **Parar o desperdício e o ruído** (telemetria, storage) | O-03, RS-03, RS-02 | Baixo |
| **A** | `D2` | **Ambiente e operação contínua** (`.env`, preflight, backup, retenção) | U-01, U-05, U-06, R-04, R-05, SK-13 | Baixo |
| **B** | `D3` | **Imagem única + cadeia de suprimentos** | P-01, P-04, S-09, ADR-031 | **Médio** (rebuild) |
| **B** | `D4` | **Postgres conforme ADR-007** | SK-11, P-05 | Médio (restart) |
| **B** | `D5` | **Isolamento de rede + hardening** | S-03, S-01, R-01, R-02, R-03, SK-10 | **Médio** (topologia) |
| **B** | `D6` | **Observabilidade do edge e do cache** | O-01, SK-05, P-06, R-06, SK-06 | Baixo |
| **C** | `D7` | **Backup da mídia + upgrade do RustFS** | RS-06, RS-01, RS-08, RS-02 | **Alto** (dados) |
| **C** | `D8` | **Medição no host + decisões de produto** | P-08, SK-02, SK-12, S-10, U-07 | — (bloqueado) |

**Ordem obrigatória:** A (D0→D1→D2) → B (D3→D4→D5→D6) → C (D7→D8).

> **Desvio registrado (2026-10-21):** o `kd` bloqueava a **D6** pela **D5** (rede), mas a
> sessão 4 do §7 agrupava `D4 + D6` e a D6 **não toca topologia** (é log do edge, conf do
> cache e `php.ini` — nenhum serviço muda de rede). A D6 foi executada **antes** da D5, e a
> D5 (o maior blast radius) continua por último da onda B, como o §1 exige.

**Por que essa ordem:**

- **D0 antes de tudo.** É o defeito de produção. E o `read_body`/`write` precisam subir **juntos** —
  subir só um troca o sintoma (504 → HTTP 100) sem resolver.
- **D1 antes de B.** Telemetria que aponta para host inexistente é desperdício por request; fechar
  isso primeiro evita medir garbage durante as ondas B.
- **D3 antes de D4/D5/D6.** A imagem única e o build reprodutível são pré-requisito de todo
  rebuild seguinte (D3.D4/D3.D5 pinam PECL e checksum).
- **D4 antes de D5.** Postgres reinicia; melhor fazer o tuning do banco com o pool ainda quieto.
- **D5 (rede) por último da onda B.** A segmentação é a mudança de maior blast radius: só entra
  com o resto já validado, e com a matriz de reachability como teste de não-regressão.
- **D7 (mídia) por último de tudo.** Toca o engine que guarda os dados de produção ⇒ só depois
  do backup existir.

---

## 2. Onda A — corrigir o que está quebrado

### `D0` — Cadeia de timeout de upload (P0, defeito medido)

**Defeito:** o limite de 110 MiB declarado em três camadas (ADR-026) é inalcançável. O Caddy corta
em ~10 s (`read_body 10s` ⇒ `HTTP=504` medido em 10,00 s) e, com o primeiro corte resolvido,
aparece o segundo (`write 30s` é prazo **total** do request ⇒ `HTTP=100` + `writing: … i/o timeout`,
também medido). Com os dois a 300 s, o upload de 50 MiB a 2 MB/s **chega ao app** (controle-positivo).

**O número não é chutado:** o Apache já impõe `RequestReadTimeout body=20,MinRate=500` (mata quem
envia <500 B/s). O cliente mais lento aceito é 500 B/s ⇒ 110 MiB ÷ 500 B/s ≈ **231 s**. A janela
tem de ser ≥ isso: **300 s**.

| Task | Implementar | Testar/validar | Não-regressão |
|---|---|---|---|
| **D0.T1** — Caddy: timeouts do cliente | `docker/web/Caddyfile`: `read_body 300s`, `read_body_idle 30s`, `read_header 5s` (manter), `write 300s`, `write_idle 30s`, `idle 5m` (manter) | `caddy validate` (já validado: *Valid configuration*); `curl --limit-rate 2M -F file=@50MiB` ⇒ **sem 504 e sem 100**; `docker compose logs web` sem `i/o timeout` | **Gate novo** em `tools/security-check.sh`: `read_body 300s` **e** `write 300s` **e** `read_body_idle`/`write_idle` presentes, com a **ordem** verificada (`read_body ≥ max_input_time`; `write ≥ read_body`) — não só a presença. `tools/gates-selftest.sh` ganha a fixture que quebra a ordem |
| **D0.T2** — Apache: prazo do proxy | `docker/web/app.conf`: `ProxyPass … timeout=60` → **`timeout=300`** (o `connectiontimeout=5` e o `RequestReadTimeout body=20,MinRate=500` **permanecem** — é a defesa por taxa) | `apachectl -t`/container valida o conf; `curl` pelo caminho Apache→Caddy passa (o canário `tools/whm-canary.sh` do host WHM) | Gate: `ProxyPass … timeout=300` no `security-check.sh` (mesma família do `read_body`); fixture no `gates-selftest` |
| **D0.T3** — PHP: janela de leitura | `docker/php-fpm/php.ini`: `max_input_time = 30` → **`300`**. `max_execution_time = 30` **permanece** (é orçamento de CPU, não de rede) | `php -i` no container: `max_input_time => 300`; upload de 100 MiB a 2 MB/s sem *execution timed out* no log do FPM | Gate: `max_input_time = 300` **e** `max_execution_time = 30` (ambos, para não "resolver"raising o tempo de CPU) |
| **D0.T4** — FPM: reciclagem e ping | `docker/php-fpm/zz-custom.conf`: `request_terminate_timeout = 360s` (> `max_input_time` + `max_execution_time` = 330 s), `process_control_timeout = 10s`, `emergency_restart_threshold = 10`, `emergency_restart_interval = 1m`, `ping.path = /ping`, `ping.response = pong`, `listen.backlog = 511`. `pm.status_path` **não** é exposto no Caddy | `cgi-fcgi -bind -connect 127.0.0.1:9000` responde `pong`; request lento simulado não deixa worker preso (o worker morre em 360 s, não "pra sempre") | Gate: `request_terminate_timeout` **e** `ping.path` no `security-check.sh`; `pm.status_path` **ausente** do Caddy (gate negativo) |
| **D0.T5** — A invariante, como asserção | `tools/security-check.sh`: nova asserção **de ordem** entre os 4 arquivos (Caddyfile, `app.conf`, `php.ini`, `zz-custom.conf`): `apache ≥ caddy read_body ≥ php max_input_time` e `caddy fastcgi read_timeout ≥ fpm request_terminate_timeout ≥ max_input_time + max_execution_time` | `tools/gates-selftest.sh` injeta um arquivo com a ordem quebrada ⇒ **o gate falha** (prova de que o gate pega) | O gate é o guard permanente; `CheckPipelineTest` cobra que o passo `security-check` roda no `make check` |
| **D0.T6** — Prova de ponta a ponta | Script de evidência (em `tools/`, não em `tests/`) que faz upload/download lento e grava o resultado | `tools/` script roda contra o stack local: upload 100 MiB @2 MB/s e download 100 MiB @2 MB/s ⇒ **sem 504/100/502**; saída em `build/check/` | O script entra no `make check` como passo **gated** (staleness) para não pagar o custo em todo ciclo |

> **Nota de risco (R8):** aplicar D0.T1–D0.T4 **em um único commit**. Subir `read_body` sem
> `write` (ou sem o `ProxyPass`) deixa o upload em `HTTP=100` — outro sintoma, ainda quebrado.

#### Execução da D0 — o que mudou em relação ao plano

**Implementado** (D0.T1–T6). Três desvios do plano, todos medidos:

1. **A aritmética do plano estava errada.** "500 B/s ⇒ 231 s" não fecha: 110 MiB ÷ 500 B/s =
   64 h. O número 231 s corresponde a **500 KiB/s**. O `MinRate=500` do Apache (bytes/s) é o
   **piso anti-drip** — mata quem envia <500 B/s — e não a taxa do cliente legítimo. A taxa
   legítima assumida é 500 KiB/s: 115 343 360 B ÷ 512 000 B/s ≈ **225 s**, e a janela é
   **300 s** (33 % de folga). Consequência: a cadeia inteira comporta cliente ≥ ~385 KiB/s
   num upload de 110 MiB. É uma **decisão de produto**, não uma dedução — e a cadeia inteira
   (não só o Caddy) é que a impõe.
2. **O `php_fastcgi` tem prazos próprios** (`read_timeout`/`write_timeout`, padrão 60 s/10 s),
   que o plano não listava: sem eles, subir só o `servers.timeouts` deixaria o edge desistir
   (504) antes de o FPM responder. Adicionados `read_timeout 360s` e `write_timeout 360s`.
3. **`process_control_timeout` e `emergency_restart_*` são diretivas globais do FPM**, e o
   `include` do `php-fpm.conf` **não herda** a seção: sem um `[global]` explícito no arquivo
   do pool, o FPM recusa a configuração inteira (exit 78 ⇒ container em loop de restart).

**Também medido:** o `ping.path` do FPM responde com os headers CGI e **corpo vazio** — o valor
de `ping.response` não é aplicado no PHP 8.4.26. A prova de vida do FPM é, portanto, "o master
respondeu ao path", não "o corpo é `pong`". Prová-la exige `cgi-fcgi` (`libfcgi-bin`), que não
está na imagem — fica para a onda da imagem (D3), onde mexer na imagem é natural.

**Prova de que os guards pegam** (não são snapshot):

| Guard | Defeito reintroduzido | Resultado |
|---|---|---|
| Gate de ordem (`security-check.sh`) | 6 fixtures: `read_body` abaixo do PHP, `write` abaixo do `read_body`, `read_timeout` abaixo do FPM, `request_terminate_timeout` abaixo do PHP, janela estreita (ordem ok, piso violado), `pm.status_path` exposto | **6/6 falham** como esperado |
| Prova de ponta a ponta (`tools/upload-chain-check.sh`) | `read_body 10s` restaurado | **504** + "voltou em 10 s, menos que os 16 s esperados" ⇒ **FALHOU** |
| Prova de ponta a ponta (mesmo script) | corrigido (`read_body 300s`) | HTTP 419 em **16,12 s** (exatamente 8 MiB a 500 KiB/s) ⇒ **OK** |

---

### `D1` — Parar o desperdício e o ruído

**Defeito:** em produção, `OTEL_EXPORTER_OTLP_ENDPOINT` aponta para `http://jaeger:4318`, que só
existe no profile `telemetry` (dev-only) ⇒ **toda request** tenta exportar para um host
inexistente. E o storage só loga `ERROR` (nível `error`), então o sinal útil não existe.

| Task | Implementar | Testar/validar | Não-regressão |
|---|---|---|---|
| **D1.T1** — Telemetria sem endpoint | `config/observability.php`: endpoint **vazio** ⇒ `NullTelemetry` (sem tentativa de exportação). `config/environments.php`: **todos** os perfis com endpoint vazio — o `dev` inclusive; o profile `telemetry` + `OTEL_EXPORTER_OTLP_ENDPOINT` é opt-in. **Não** desligar `TELEMETRY_ENABLED` (a capacidade é de 1ª classe, ADR-024) — só o **destino** | `php artisan tinker`/teste: `TelemetryStatus` reflete o estado correto por perfil; nenhum perfil tenta exportar sem destino (log sem `otel export failed` derivado de DNS) | Teste de perfil: `production`/`homolog`/`dev` ⇒ sem endpoint por padrão. Gate em `security-check.sh` |
| **D1.T2** — `.env`/defaults coerentes | `config/_OTHER_CONFIGS.md`: `OTEL_EXPORTER_OTLP_ENDPOINT` documentado (vazio = sem exportação em todos os perfis; dev opt-in = jaeger); o `.env.example` fica **mínimo** (identidade + segredos, D2b) | `docker compose config` com o `.env` do exemplo resolve sem erro; `config/_OTHER_CONFIGS.md` com o valor e o porquê | `EnvironmentConfigTest`/`RuntimeConfigTest` (já existentes) cobrem a variável |
| **D1.T3** — Storage com log útil | `docker-compose.yml`: `RUSTFS_OBS_LOGGER_LEVEL: "error"` → **`"info"`**. **NÃO** definir `RUSTFS_OBS_USE_STDOUT` (no 1.0.1 ela **silencia** o stdout — medido: 60+ linhas sem ela, **0** com ela) | `docker compose logs storage --tail=50` mostra eventos de scanner/durability; restart continua emitindo | Gate: `RUSTFS_OBS_LOGGER_LEVEL` = `info` **e** `RUSTFS_OBS_USE_STDOUT` **ausente** (gate negativo — é a armadilha medida) |
| **D1.T4** — Readiness do S3 | `docker-compose.yml`: healthcheck `curl -fsS http://127.0.0.1:9000/health` → **`/health/ready`** (medido: 200 no 1.0.1, com `read_quorum`/`write_quorum`; `/cluster/health` = 403, não serve). `start_period: 30s` | `docker compose up -d storage php-fpm queue` ⇒ `php-fpm` só sobe com o S3 *ready*; `docker compose ps` mostra `healthy` | Gate: healthcheck do storage usa `/health/ready`. **Nota:** `/health/ready` só no 1.0.1 ⇒ se D7.T4 subir o RustFS, revalidar |

---

### `D2` — Ambiente e operação contínua

**Defeito:** o `.env.example` não declarava as variáveis que o Compose usa, e o `up_prod.sh`
procurava um `docker-compose.prod.yml` **inexistente** (código morto, e forbidden por ADR-032).

> **Decisão do dono (2026-10-21, após a implementação):** o `.env` **não cresce**. Ele é
> **identidade + segredos**, e nada mais — o operador não gerencia mais campos. Todo o resto
> tem **default funcional no Compose** (`${VAR:-padrão}`) e o clone novo sobe sem preencher
> nada. O invariante deixou de ser "a variável está declarada no `.env.example`" e passou a ser
> **"toda variável do Compose tem default (`:-`) ou é obrigatória por natureza (`:?`, com
> mensagem)"** — que é o que de fato impede o `docker compose` de falhar com
> *"variable is not set"*. Ver `D2.T1` (revisão) e `D2b` no `kd`.

| Task | Implementar | Testar/validar | Não-regressão |
|---|---|---|---|
| **D2.T1** — `.env` enxuto (revisão) | `.env.example`: **nenhuma** variável ativa nova; as 8 de ajuste pontual (dono dos bind mounts, caminhos de dados, porta, região, tag da imagem) ficam **documentadas em `config/_OTHER_CONFIGS.md`** como ajuste opcional — o `.env.example` não as lista. O default mora no Compose | o `.env.example` só declara identidade + segredos; `docker compose config` resolve sem erro; o clone novo sobe sem preencher nada | Gate no `security-check.sh`: toda `${VAR}` do Compose tem default (`:-`) ou é obrigatória (`:?`) — com fixture que injeta uma variável sem default |
| **D2.T2** — Preflight de obrigatórias | `up_prod.sh`: valida as obrigatórias (`DB_PASSWORD`, `REDIS_PASSWORD`, `AWS_*`) **antes** de qualquer efeito, e falha com mensagem clara | `./up_prod.sh --dry-run` sem `.env` ⇒ erro nomeando a variável | Gate: o preflight roda antes do backup (a ordem backup→build→migrate é coberta por `DeployPipelineTest`) |
| **D2.T3** — Código morto removido | `up_prod.sh:155`: remover o bloco que procurava `docker-compose.prod.yml` (ADR-032: "docker-compose.prod.yml separado — descartada") | `grep -rn "docker-compose.prod" .` ⇒ vazio | Gate: `docker-compose.prod.yml` **não pode** ser referenciado (gate negativo, ligado ao ADR-032) |
| **D2.T4** — Retenção do WAL como rede de segurança | `tools/wal-retention.sh` **já existe** (I7.T2 fechada: ferramenta + cron no runbook + gate). Falta: chamar em `up_prod.sh` (poda idempotente pós-deploy) para hosts **sem** cron (dev/CI) e para o backup de DB não competir com o arquivo | `./up_prod.sh --dry-run` mostra a chamada; `tools/wal-retention.sh --dry-run --json` num cluster de teste; `du -sh` do `wal_archive` **diminui** após a execução | A ferramenta já é coberta pelo `gates-selftest` (I7.T2). **Nova** asserção: `up_prod.sh` invoca a retenção |
| **D2.T5** — Backup diário (fora do deploy) | `wiki/runbooks/deploy-e-recuperacao.md`: cron do host chamando `./up_prod.sh --backup-only` (o comando **já existe**). Sem container novo (ADR-001) | `up_prod.sh --backup-only` produz dump válido (`pg_restore --list`); o runbook tem a linha de cron copiável | `DeployPipelineTest`/`BackupDrill` (o `tools/db-restore-drill.sh` já mediu RTO 9 s) |
| **D2.T6** — Documentar o estado dev/prod | `wiki/specs/16-edge-apache-whm.md` e `wiki/specs/17-up-prod.md`: registrar a **cadeia de timeout** (§3 do veredito) e a regra `homolog = prod` | `tools/docs-check.sh` verde; runbook reflecte a cadeia | `docs-check` já é gate do `make check` |

---

### `D1` e `D2` — execução (resumo)

**D1 (telemetria e storage) — fechada.** O destino da telemetria passou a vir do **perfil**
(`telemetry_endpoint`): **todos** os perfis nascem **sem destino** — o `dev` inclusive; o jaeger
é opt-in (profile `telemetry` + `OTEL_EXPORTER_OTLP_ENDPOINT`). Sem endpoint, o binding resolve
`NullTelemetry` — a **capacidade** continua de pé (ADR-024), só não há tentativa de exportação por
request contra um host inexistente. O storage deixou de ser mudo (`RUSTFS_OBS_LOGGER_LEVEL=info`,
sem a `RUSTFS_OBS_USE_STDOUT` que **silencia** o stdout no 1.0.1) e o healthcheck passou a checar o
**readiness** (`/health/ready`). *Desvio:* a `RUSTFS_OBS_USE_STDOUT` não podia ser proibida por
`grep` de texto — o gate tropeçaria no próprio comentário que documenta a armadilha; o gate olha a
**chave** YAML (`deny_key`).

> **Correção pós-D1 (23/10):** o `dev` ainda apontava para `http://jaeger:4318` sem o profile no
> ar; cada tentativa bloqueava **~5–8 s** na resolução de DNS (medido: `curl_exec` com
> `CURLOPT_TIMEOUT_MS=50` levou 7,8 s; o timeout do cURL não cobre o resolver síncrono) e o
> breaker de 30 s só adiava o próximo stall. O endpoint agora nasce vazio também no dev.

**D2 (ambiente e operação contínua) — fechada.** O `.env.example` ficou **mínimo** (identidade
e segredos): a revisão **D2b** removeu as variáveis de ajuste pontual e o gate passou a exigir
**default funcional** em toda `${VAR}` do Compose (`:-`/`-`/`:?`) em vez de presença no
`.env.example`; os defaults e o ajuste opcional estão documentados em
`config/_OTHER_CONFIGS.md`. O `up_prod.sh` ganhou **preflight** dos segredos obrigatórios (falha
nomeando a variável, antes de qualquer efeito) e a **retenção do WAL** antes do backup (com o
disco cheio o `pg_dump` falha); o código morto que procurava `docker-compose.prod.yml` foi
removido dos dois scripts (ADR-032); o runbook ganhou o **cron do backup diário**; e as specs
16/17 passaram a registrar a cadeia de timeout e a regra `homolog = prod`.

*Desvios:* (a) a retenção do WAL roda **antes** do backup, não "pós-deploy" como o plano
dizia — a razão declarada no plano ("o backup de DB não competir com o arquivo") só é
atendida antes; (b) a primeira versão da D2 chegou a declarar as 7 variáveis no
`.env.example`; a **D2b reverteu por decisão do dono** (o `.env` não cresce) — inclusive
`COMPOSE_PROJECT_NAME`, que fica derivado do `APP_NAME` (preenchê-lo com o fallback
`concordia` criaria a colisão entre tenants que a D8.T3 vai decidir).

**Falso vermelho encontrado no caminho (pré-existente, corrigido):** 3 testes vermelhos que
não eram da D0 — 2 asserções que fixavam **wording** de `up_prod.sh` (o humano reescreveu o
arquivo no commit `5634090`; o contrato continuava valendo) e o symlink local
`public/storage`, que a ADR-030 proíbe (artefato local de 15/out, descartado em
`.pi/trash/`). As 2 asserções foram reescritas para afirmar **estrutura**, não texto.

---

## 3. Onda B — conformidade de ADR e higiene

### `D3` — Imagem única + cadeia de suprimentos (ADR-031)

**Defeito:** ADR-031 §32 diz *"`queue` reusa a mesma imagem do `php-fpm`"*, mas o Compose tem **dois
blocos `build:`** ⇒ `docker compose config --images` lista `concordia-php-fpm` **e**
`concordia-queue`, e há **6 imagens ≈ 5 GB** no host. Além disso: `pecl install` **sem versão** e
tarball do BLAKE3 **sem checksum** violam ADR-001/031.

| Task | Implementar | Testar/validar | Não-regressão |
|---|---|---|---|
| **D3.T1** — Uma imagem só | `docker-compose.yml`: `image:` no `php-fpm` (com `build:`) e **referência à mesma `image:`** no `queue` com `pull_policy: never` (sem isso o Compose falha com *image not found* — verificado) | `docker compose config --images` ⇒ **uma** entrada de imagem PHP; `docker compose up -d` sobe os dois serviços | Gate: `docker compose config --images` não pode listar duas imagens PHP (prova por diferença de conjunto) |
| **D3.T2** — Build único | `up_prod.sh`: `build --no-cache php-fpm queue` → **`php-fpm`**. `.gitlab-ci.yml`: `up -d --build php-fpm queue` → sem duplicar build | `up_prod.sh --dry-run` mostra **um** build; tempo de build cai (era 2×) | Gate: `up_prod.sh` constrói **apenas** `php-fpm` |
| **D3.T3** — Imagem menor | `docker/php-fpm/Dockerfile`: purgar o toolchain PECL (`pecl channel-update` + cache) e `/tmp/pear`; remover `docker-php-source` após as extensões | `docker images` ⇒ tamanho menor que 878 MB; build **verde** | Gate: tamanho da imagem **≤** limite (ou, mais simples: o build não deixa `/usr/src/php` nem `/tmp/pear`) |
| **D3.T4** — PECL pinado | `docker/php-fpm/Dockerfile`: pinar `apcu`, `redis`, `imagick`, `pcov` em versões compatíveis com PHP 8.4 (validar no build) | build da imagem **verde**; `php -m` lista as 4 extensões; `php --ri <ext>` mostra a versão | Gate: o Dockerfile **não** contém `pecl install` sem versão (gate de texto) |
| **D3.T5** — Checksum do BLAKE3 | `docker/php-fpm/Dockerfile`: `curl` do tarball com **sha256 verificado** (`sha256sum -c`), contra o `BLAKE3_REF` já pinado (commit `3d86cdaf…`) | build verde; `php -r 'echo blake3_hash("x");'` funciona; o `sha256` está no Dockerfile | Gate: o download tem verificação de checksum (gate de texto) |
| **D3.T6** — A matriz do ADR | `wiki/adr/ADR-031-imagens-bases-pinadas.md`: atualizar a **matriz de imagens** (a matriz é parte do ADR) com o digest novo | `docker buildx imagetools inspect <img>@<digest>` confere; ADR coerente com o Compose | `docs-check` + gate de que o digest do Compose == digest do ADR |
| **D3.T7** — SBOM/proveniência da imagem | `.gitlab-ci.yml`: `docker buildx build --sbom --provenance` (barato, sem tool novo) publicando `sbom-imagem.cdx.json`. **Scan** de imagem fica de fora (decisão: gate de CI com política de CVE = decisão do time, não agora) | artefato de SBOM publicado no CI | `make sbom` (já existe) continua cobrindo as dependências PHP |

> **Risco desta onda:** D3.T3–D3.T5 exigem **rebuild** da imagem (build > 4 min) ⇒ o `make check`
> completo precisa rodar depois. É a única onda que toca a imagem.

### `D4` — Postgres conforme ADR-007

**Defeito:** ADR-007 §43 exige `wal_compression=on`, `max_wal_size` e `checkpoint_timeout` — **os
três ausentes** em `docker/postgres/postgresql.conf`. E o `/dev/shm` do container é **64 MB**
contra `shared_buffers = 256MB`.

| Task | Implementar | Testar/validar | Não-regressão |
|---|---|---|---|
| **D4.T1** — Parâmetros de WAL | `docker/postgres/postgresql.conf`: `wal_compression = on`, `max_wal_size = 1GB`, `checkpoint_timeout = 15min`, `min_wal_size = 128MB`, `checkpoint_completion_target = 0.9` | `SHOW wal_compression` ⇒ `on`; `SHOW max_wal_size` ⇒ `1GB`; `pg_stat_archiver` continua arquivando (`failed_count = 0`) | Gate: os 5 parâmetros obrigatórios presentes no conf (o ADR-007 é a fonte) |
| **D4.T2** — Memória compartilhada | `docker-compose.yml`: `shm_size: 256mb` no serviço `postgres` (hoje 64 MB) | `df -h /dev/shm` no container ⇒ 256M; query com parallel workers não falha | Gate: `shm_size` presente e ≥ `shared_buffers` |
| **D4.T3** — Logs de diagnóstico | `docker/postgres/postgresql.conf`: `log_lock_waits = on`, `log_temp_files = 10MB`, `log_checkpoints = on`, `log_autovacuum_min_duration = 1s` (ADR-024: observabilidade). **Sem** `log_connections`/`log_disconnections` (ruído) | `docker compose logs postgres` mostra checkpoint/lock-wait quando provocados | Gate: os 4 presentes; os 2 de conexão **ausentes** |
| **D4.T4** — Gate de conformidade | `tools/migration-check` (ou `security-check.sh`): validar os parâmetros do ADR-007 no conf montado | fixture com `wal_compression = off` ⇒ gate falha | `gates-selftest.sh` com a fixture |

### `D5` — Isolamento de rede + hardening de container (ADR-020/021)

**Defeito (medido):** hoje `web` (o único serviço exposto à internet) **alcança** `storage:9000`
e, por consequência, o Postgres e o Valkey. E `cap_drop: ALL` no Postgres **quebra o container**
(o entrypoint faz `chown`/`chmod` como root).

| Task | Implementar | Testar/validar | Não-regressão |
|---|---|---|---|
| **D5.T1** — Duas redes | `docker-compose.yml`: `edge` (web, php-fpm, queue, mailpit, jaeger) e `data` (**`internal: true`**; php-fpm, queue, postgres, valkey, storage). Matriz **medida** antes: `web→db` bloqueado · `app→db` alcança · `db→internet` bloqueado · `app→internet` alcança (o `composer install` do deploy **precisa** de egress) | `docker compose exec web sh -c 'nc -z db 5432'` ⇒ falha; `… php-fpm …` ⇒ alcança; `… postgres … nc -z 1.1.1.1 443` ⇒ falha | **Gate de rede** (novo): a matriz acima é asserida no `make check` (é barato e é o guard do isolamento) |
| **D5.T2** — `no-new-privileges` | `docker-compose.yml`: `security_opt: ["no-new-privileges:true"]` em **todos** os serviços | `docker inspect` mostra a opção; containers sobem | Gate: presente em todos os serviços |
| **D5.T3** — `cap_drop` seletivo | `docker-compose.yml`: `cap_drop: [ALL]` em `web`, `valkey`, `storage`; **NÃO** no `postgres` (medido: `chown: … Operation not permitted` ⇒ não sobe) nem no `queue` sem validar escrita em `storage/framework` | `postgres` sobe e fica `healthy`; `web`/`valkey`/`storage` sobem sem capability extra | Gate: `cap_drop: [ALL]` presente nos 3 e **ausente** no `postgres` (gate negativo, com a razão em comentário) |
| **D5.T4** — Limites de processo | `docker-compose.yml`: `pids_limit` (256 php/queue, 128 web/valkey) e `ulimits.nofile: 65536`. `mem_limit`/`cpus` **adiados** (D8.T1) | `docker inspect` mostra os limites; nada de fork/exit sob carga leve | Gate: `pids_limit` e `nofile` presentes |
| **D5.T5** — Encerramento gracioso | `docker-compose.yml`: `init: true` em `queue` e `php-fpm`; `stop_grace_period: 150s` (queue) e `30s` (php-fpm) | `docker compose stop` não deixa zumbi nem mata job no meio; `docker compose down` limpo | Gate: `stop_grace_period` do `queue` **>** `--timeout` do worker (invariante, não valor solto) |
| **D5.T6** — Timeout do worker | `docker-compose.yml`: `queue:work --sleep=1 --tries=3 --timeout=120 --backoff=3 --max-time=3600 --max-jobs=1000` (hoje **sem** `--timeout` ⇒ default 60 s, e sem `--backoff` ⇒ job de 90 s é morto e **reexecutado 3×**) | job longo roda uma vez e falha uma vez; `failed_jobs` não acumula | Gate: `--timeout=120` e `--backoff=3` presentes |
| **D5.T7** — Poda e healthcheck do worker | `queue:prune-failed --hours=48` agendado (agenda do Laravel) + healthcheck do serviço `queue` (o único de produção sem healthcheck) | `docker compose ps` mostra `queue` `healthy`; `failed_jobs` podado | Gate: healthcheck presente no `queue` |

### `D6` — Observabilidade do edge e do cache

**Defeito:** o `Caddyfile` **não tem** `log` ⇒ **nenhuma** linha de acesso é emitida (o `logging`
do Docker está configurado, mas o Caddy não loga). E o Valkey não tem `log-format`, nem
`hide-user-data-from-log` (PII no log, ADR-019), nem `lazyfree`.

| Task | Implementar | Testar/validar | Não-regressão |
|---|---|---|---|
| **D6.T1** — Access log JSON | `docker/web/Caddyfile`: bloco `log { output stdout; format filter { wrap json; fields { >request>headers>Cookie delete; >request>headers>Authorization delete } } }` — **sintaxe já validada** com `caddy validate` | `docker compose logs web --tail=1 \| jq .` ⇒ JSON com `status`/`duration`; `Cookie`/`Authorization` **ausentes** | Gate: bloco `log` presente **e** os 2 headers com `delete` (gate de texto) |
| **D6.T2** — Correlação por `X-Request-ID` | garantir que o `X-Request-ID` do app aparece na linha de acesso (o app já o emite no response; o log precisa do header) | um request com `X-Request-ID` conhecido aparece com o mesmo id no log do Caddy **e** no log JSON do Laravel | Gate: o log do edge e o log do app são correlacionáveis (teste e2e) |
| **D6.T3** — Valkey: formato e privacidade | `docker/valkey/valkey.conf`: `log-format logfmt`, `log-timestamp-format iso8601`, `hide-user-data-from-log yes`, `lazyfree-lazy-eviction/expire/server-del yes`, `latency-monitor-threshold 100`, `tcp-backlog 511` — **conjunto já validado** no Valkey 9.1.2 | `valkey-server` carrega o conf sem erro; `docker compose logs valkey` em logfmt; a senha **não** aparece | Gate: as 6 diretivas presentes; `maxmemory 256mb`/`volatile-lru` **mantidos** (já existem — P-06 do catálogo partiu do pressuposto errado) |
| **D6.T4** — Senha fora do argv | `docker-compose.yml`: healthcheck `valkey-cli -a "$REDIS_PASSWORD"` → **`VALKEYCLI_AUTH=$$REDIS_PASSWORD valkey-cli ping`** (a senha em `argv` aparece em `docker top`) | `docker top` **não** mostra a senha; healthcheck fica `healthy` | Gate: healthcheck sem `-a`/`--requirepass` no comando de healthcheck |
| **D6.T5** — `allow_url_fopen` | `docker/php-fpm/php.ini`: `allow_url_fopen = Off` (não há `file_get_contents`/`fopen`/`include` com URL `http` em `src/` — verificado; o exportador OTLP usa `curl`, que não é afetado) | `php -i` ⇒ `allow_url_fopen => Off`; o export de telemetria e o `IndexNow` continuam funcionando | Gate: `allow_url_fopen = Off` presente; teste do exportador OTLP verde |

---

### `D3` — execução: uma imagem só e a cadeia de suprimentos

**Implementado** (T1–T7). O Compose agora dá **uma** imagem (`concordia/php-fpm:local`) ao
`php-fpm` **e** ao `queue` (o `queue` perdeu o `build:` próprio e ganhou `pull_policy: never`),
e o `up_prod.sh`/CI/Makefile constroem **só** o `php-fpm`. O Dockerfile passa a pinar
**versão e checksum** de cada extensão PECL e do tarball do BLAKE3, e a limpar o toolchain de
build da imagem de runtime.

**Três descobertas que o plano não previa** (todas medidas no build real):

1. **`pecl install` com vários pacotes instala só o primeiro e o último.** Com
   `pecl install apcu-5.1.28 redis-6.3.0 imagick-3.8.1 pcov-1.0.12`, o PECL instalou
   **apcu e pcov** e pulou redis e imagick; o `docker-php-ext-enable` falhou depois com
   *"redis does not exist"* — erro que **parecia** de versão. A forma correta é **uma
   extensão por comando**.
2. **O CDN do `pecl.php.net` é intermitente**: 504 Gateway Timeout no download de
   `redis-6.3.0.tgz` — numa versão que **existe** (o release list mostra 6.3.0 e o tarball
   responde 200 na URL sem extensão). O build agora tenta as duas URLs, repete 4× e
   **confere o SHA-256** do tarball antes de instalar.
3. **A limpeza da imagem precisa de cuidado**: `apt-get purge --auto-remove` dos `-dev`
   levaria junto as bibliotecas de runtime (libicu, libpq, libzip, libonig, libmagickwand),
   que vieram como dependência deles — e as extensões já compiladas parariam de carregar. O
   `purge` **sem** `--auto-remove` + uma verificação no build (as 5 extensões carregam e
   `blake3_hash()`/`Imagick` funcionam) fecham isso.

**Gates** (com fixtures que provam que pegam): uma imagem só (php-fpm ≡ queue, sem `build:`
no queue), build único no `up_prod.sh`, PECL **com versão e checksum**, checksum do BLAKE3,
remoção do source do PHP, e **todo digest do Compose/Dockerfile na matriz do ADR-031** (a
matriz é parte do ADR: mudá-la sem atualizar o ADR é o mesmo que não pinar).

---

### `D4` — execução: o Postgres conforme o ADR-007

**Implementado** (T1–T4). Os **três** parâmetros que o ADR-007 §43 exige e que faltavam
(`wal_compression`, `max_wal_size`, `checkpoint_timeout`) entraram no conf, com o valor
**verificado no banco** — e não só no arquivo:

| Parâmetro | Antes | Agora | Medido (`pg_settings`) |
|---|---|---|---|
| `wal_compression` | ausente | `on` | `pglz` |
| `max_wal_size` | ausente | `1GB` | `1024MB` |
| `min_wal_size` | ausente | `128MB` | `128MB` |
| `checkpoint_timeout` | ausente | `15min` | `900s` |
| `checkpoint_completion_target` | ausente | `0.9` | `0.9` |

O `/dev/shm` do container era o default do Docker (**64 MB**) contra `shared_buffers = 256MB`:
o worker paralelo falha com *"could not resize shared memory segment"*. Agora é `shm_size: 256mb`
(medido: `df /dev/shm` = 256M). O diagnóstico (ADR-024) entrou: `log_lock_waits`,
`log_temp_files`, `log_checkpoints`, `log_autovacuum_min_duration` — **sem**
`log_connections`/`log_disconnections` (ruído; 0 linhas de conexão no log depois do restart).

**Prova de que o diagnóstico funciona** (não é snapshot): `CHECKPOINT` ⇒ *"checkpoint starting"*
+ *"checkpoint complete"* no log; duas sessões com `LOCK TABLE` em ACCESS EXCLUSIVE ⇒ *"process
144 still waiting for AccessShareLock … after 1000.102 ms"* + *"Process holding the lock: 141"*.

**Desvios do plano:**

1. **O gate compara o VALOR, não a presença.** O plano dizia "os 5 parâmetros obrigatórios
   presentes no conf"; um `wal_compression = off` **presente** é exatamente o defeito. O gate
   confere o valor (`require_pg_param`), e o `shm_size` é comparado com o `shared_buffers`
   (número vs número) — a comparação que o §5.3.2 exige.
2. **`log_connections` no PG 18 é `string`, não booleano** (o default é vazio = sem log) — a
   ausência no conf é o que importa, e o gate **negativo** garante que ninguém o ligue.

**Gates** (com fixtures que provam que pegam): `wal_compression = off`, `max_wal_size`
pequeno, `log_lock_waits` desligado, `shm_size` menor que `shared_buffers` e
`log_connections` ligado — **5/5 falham** como esperado.

---

### `D6` — execução: observabilidade do edge e do cache

**Implementado** (T1–T5). O `logging` do Docker estava configurado, mas o **Caddy não logava**:
não existia uma única linha de acesso na stack. Agora há **uma linha JSON por request**, e o
`filter` apaga a credencial: `Cookie`/`Authorization` do request, `Set-Cookie` da **response**
(sem esta linha o log do edge vazava a sessão do CMS) e a CSP da response (~800 B estáticos por
linha — a linha caiu de **1952 → 1177 B**).

**Três descobertas que o plano não previa:**

1. **O path do `filter` não pode começar com `>`.** O exemplo da documentação do Caddy usa o `>`
   inicial, mas o parser do Caddyfile guarda a chave como está e o runtime não faz o strip: **o
   `delete` vira no-op silencioso**. Medido: com `>request>headers>Cookie delete` o `Cookie` e o
   `Authorization` continuavam na linha; sem o `>`, sumiram. O gate tem um **negativo** que
   proíbe `>request>headers>` e `>resp_headers>` (e a fixture injeta o path com `>`).
2. **A senha do Valkey estava no `ps` do HOST.** O plano restringia o D6.T4 ao healthcheck
   (`-a`), mas o critério de aceitação era *"o `docker top` não mostra a senha"* — e o
   `--requirepass` do **servidor** punha a senha do cache, da sessão e da fila no `ps`, legível
   por qualquer usuário da máquina. Um `include`/healthcheck só não resolvia. O novo
   `docker/valkey/entrypoint.sh` renderiza o conf de runtime (modo 600) dentro do container e o
   servidor sobe sem a senha em nenhum argv; o healthcheck usa `VALKEYCLI_AUTH=` (a senha no
   environment, que não aparece no `ps`). Medido: `ps aux | grep requirepass` ⇒ **0**; o
   processo roda como `valkey`/uid 999; `cache:clear` (o app autenticando) funciona.
3. **A senha com caractere especial quebrava o conf.** O `--requirepass` no argv aceitava
   espaço; no arquivo, não. O entrypoint escapa `\` e `"` e **quota** o valor — testado com
   `senha "com" espaço \backslash` (autentica; a senha errada falha).

**Também medido:** o `rename` do `filter` renomeia **dentro** do pai (`resp_headers>request_id`) —
o encoder não tem operação de mover para o topo. A correlação com o app (que tem `request_id` no
topo) é por **valor**, e funciona: o mesmo id aparece na linha do edge e no log JSON do Laravel ✓.

**Achado operacional (fora do escopo do repo):** o host tem `vm.overcommit_memory = 0` e o
Valkey avisa em **cada boot** que um *background save* pode falhar sob pressão de memória. O
sysctl **não é namespaced** — o container não ajusta. Documentado no runbook de deploy (passo 7
da instalação) + nota de risco no `kd`.

**Gates** (com fixtures que provam que pegam): Caddy sem o `delete` do `Cookie`, Caddy sem o
`delete` do `Set-Cookie`, Caddy com o path com `>`, Valkey sem `hide-user-data-from-log`,
Valkey sem `log-format logfmt`, Compose com a senha no argv do healthcheck e `php.ini` com
`allow_url_fopen` ligado — **7/7 falham** como esperado.

---

### `D5` — execução: isolamento de rede e hardening de container

**Implementado** (T1–T8). A topologia que existia era **uma** rede para tudo: o `web` (o
único serviço exposto à internet) alcançava o Postgres, o Valkey e o storage. Agora são
duas redes, e o contrato é **medido**, não declarado:

| Serviço | Redes | Banco/cache/storage | Internet |
|---|---|---|---|
| `web` | `edge` | **bloqueado** | sim |
| `php-fpm` / `queue` | `edge` + `data` | **alcança** | **sim** (o `composer install` precisa) |
| `postgres` / `valkey` / `storage` | `data` (`internal: true`) | — | **sem rota padrão** |

**Hardening:** `no-new-privileges` nos 6 serviços; `cap_drop: [ALL]` nos 5 que rodam
non-root (o Postgres fica de fora — o entrypoint precisa de CAP_CHOWN); `pids_limit` e
`ulimits.nofile`; `init: true` + `stop_grace_period` (150 s na fila, 30 s no FPM); o
worker com `--timeout=120 --backoff=3`; healthcheck do worker; e a poda de `failed_jobs`
agendada (48 h).

**Três descobertas que o plano não previa (todas medidas):**

1. **O binário do Caddy tem capability de arquivo** (`cap_net_bind_service=ep`). Com
   `cap_drop: [ALL]` o exec é negado — *"operation not permitted"* — porque o kernel exige
   que a capability do arquivo esteja no permitted set. Islado: `cap_drop: [ALL]` sozinho
   quebra; `no-new-privileges` sozinho **não**. A exceção ficou **documentada** no Compose
   e o gate exige que ela continue lá.
2. **O Valkey não pode rodar `cap_drop: [ALL]` como root.** O entrypoint da imagem faz o
   *drop* de privilégio com `setpriv` (precisa de CAP_SETUID) e o `chown` do conf de
   runtime (CAP_CHOWN) — sem as capabilities o container morre em loop
   (*"chown: /tmp/valkey.runtime.conf: Operation not permitted"*). A solução: o container
   **já nasce** como o usuário `valkey` da imagem (uid 999, fixo no digest pinado) — sem
   drop, sem chown, e o volume já tem a propriedade certa.
3. **O `init: true` muda o PID 1 — e o reload continua funcionando.** O `make reload`
   envia `USR2` ao PID 1, que agora é o `docker-init` (tini). O tini repassa o sinal ao
   master do FPM: medido, o master trocou de PID (168 → 190) e o app continuou 200 ✓.

**Guard permanente:** `tools/network-check.sh` (estático **e** runtime), rodado pelo
`make check`; o hardening é gate do `security-check.sh`. **14 fixtures** novas no
`gates-selftest` — todas falhando como esperado (o `web` na rede de dados, a rede de dados
sem `internal: true`, o Postgres com `cap_drop`, o Caddy sem a exceção de capability, a
janela de parada menor que o timeout do worker, o worker sem `--timeout`/`--backoff`, o
worker sem healthcheck, a poda não agendada, …).

---

## 4. Onda C — o que exige decisão, medição ou cuidado com dados

### `D7` — Backup da mídia + upgrade do RustFS (o P0 real)

**Defeito:** a mídia publicada **não tem backup nenhum** — single-drive, EC:0, sem versioning, sem
cópia fora do host. Se o host morre, tudo se perde. (O ADR-030 §9 remetia ao backlog; o veredito
§1 reclassifica como P0.) O upgrade para 1.0.2 (digest `sha256:1617cf45…`, **confirmado**) só pode
vir **depois** do backup.

| Task | Implementar | Testar/validar | Não-regressão |
|---|---|---|---|
| **D7.T1** — Inventário da mídia | Levantar objetos, tamanho e integridade do bucket antes de qualquer cópia (o `08` avisa: não pular etapas) | relatório com contagem/tamanho/checksum por prefixo | — (tarefa de leitura) |
| **D7.T2** — Backup da mídia (sem `mc`) | Comando **Artisan** no app usando o cliente S3 já presente (Flysystem/AWS SDK) para copiar objetos ao bucket offsite + manifesto. **Proibido** `mc` (MinIO client = AGPL ⇒ viola ADR-002) | `--dry-run` lista o que seria copiado; execução real + **restauração** de um objeto num bucket de scratch | Gate: o comando existe, é idempotente e roda no `make check` em modo `--dry-run`; teste de restauração |
| **D7.T3** — Bucket: versioning + PAB | `EnsureStorageBucketCommand`: `versioning` habilitado + **PublicAccessBlock** (o bucket é privado; o PAB impede vazamento) | `get_bucket_versioning`/`get_public_access_block` confirmam | Gate: o bootstrap do bucket verifica PAB/versioning a cada start |
| **D7.T4** — Upgrade do RustFS | `docker-compose.yml`: `rustfs/rustfs:1.0.1-glibc@…` → **`1.0.2-glibc@sha256:1617cf459224b161bde874144f96969421ad07b4eb086a44c38d1fe31f36c595`** (digest confirmado por `imagetools inspect`); `wiki/adr/ADR-031-…` atualizado | `rustfs --version` ⇒ 1.0.2; `/health/ready` 200; **GET de um objeto existente** (o dado antigo tem de continuar legível); PUT/GET/range/multipart de objeto novo | Gate: o digest do Compose == digest do ADR-031; **`make check` E2E=1 verde** (o `08` avisa: validar leitura de objetos existentes) |
| **D7.T5** — Reavaliar o stdout do storage | **Depois** de D7.T4: no 1.0.2 a `RUSTFS_OBS_USE_STDOUT` passa a ser honrada com endpoints por sinal (nota de release) ⇒ reexecutar o A/B (log **com** e **sem** a variável) e decidir | A/B medido: 60+ linhas vs 0 ⇒ registrar a decisão | Gate: a decisão fica **documentada** na nota do ADR-030; o gate de D1.T3 é ajustado ao comportamento novo |

### `D8` — Medição no host + decisões de produto (bloqueado por decisão)

| Task | Implementar | Testar/validar | Não-regressão |
|---|---|---|---|
| **D8.T1** — Medir o host WHM | `docker stats` no host de produção por 7 dias (p50/p95 de CPU/RAM por serviço) — **ADR-001: medir antes de otimizar** | relatório com números | — (medição) |
| **D8.T2** — `mem_limit`/`cpus` | com D8.T1: `mem_limit`/`cpus`/`pids_limit` por serviço, com folga medida (um limite errado = `OOMKilled` em loop) | `docker events --filter event=oom` vazio por 7 dias | Gate: os limites presentes, com o número **derivado** da medição (comentado no Compose) |
| **D8.T3** — Nome do projeto (U-07) | **Decisão de produto**: `APP_NAME` é o nome do tenant **e** a origem do slug do Compose (spec 18). O repositório `cms` irmão resolve para o mesmo `concordia` ⇒ `down --remove-orphans` de um derruba o outro | a decisão está escrita (nota kd + spec 18) | **Independente da escolha**: preflight no `up_prod.sh` que **recusa** subir se o nome do projeto já estiver em uso por **outro diretório** (label `com.docker.compose.project.working_dir`) — essa é a proteção que impede o dano |
| **D8.T4** — `X-Request-ID` (S-10) | **Decisão**: impor UUID (ADR-024 diz X-Request-ID = UUIDv7) ou registrar a divergência (hoje aceita 8–128 chars do cliente) | teste que fixa o comportamento escolhido | o teste **existente** (`abc-12345` preservado) é atualizado só se a decisão for "UUID" |
| **D8.T5** — Medições pendentes | `max_header_size` (SK-02: default real 16 KiB, medir `Cookie`+headers reais), autovacuum (SK-12, medir `pg_stat_user_tables`), `LOG_DAILY_DAYS` (SK-10/O-04: 730 dias de log **técnico** na cota do WHM — a trilha de auditoria tem retenção própria, ADR-035) | números medidos | a decisão de retenção vai para `config/logging.php` **e** para o runbook |

---

## 5. Protocolo de validação e não-regressão (transversal)

### 5.1 O que roda em **cada** task

| Passo | Comando | Critério |
|---|---|---|
| 1 | `make check` | verde (o pipeline **acumula** erros e mostra o tempo de cada check) |
| 2 | `make check` (2ª vez) | verde — **sem flaky** (ADR-001). Duas vezes é o DoD, não superstição |
| 3 | `make check E2E=1` | verde nas 5 projetos (desktop, mobile, sem JS, CMS desktop, CMS mobile) + axe sem violação A/AA |
| 4 | gate novo da task | fixture inválida ⇒ gate **falha** (prova de que o guard funciona) |

### 5.2 Ordem de verificação quando a task mexe em runtime

```
1. validação estática do artefato      (caddy validate / apachectl -t / php -l / docker compose config)
2. smoke do serviço afetado             (sobe, healthcheck healthy, um request real)
3. a prova específica da task           (upload/download lento, A/B de log, matriz de rede)
4. make check                           (2×)
5. make check E2E=1                     (se tocou rota/HTML/CSS/JS — o gate de e2e é opt-in local)
6. evidência gravada                    (build/check/, com o comando e a saída)
```

### 5.3 Regras de não-regressão que **valem para o plano inteiro**

1. **Nenhuma task fecha sem guard.** Gate + teste. Sem isso, a melhoria é um snapshot e volta.
2. **O guard verifica a *ordem*, não só a presença.** O defeito do upload existia porque
   `read_body 10s` era *presente*; a correção só se sustenta porque os prazos estão *ordenados*.
   `require_text` não basta — precisa de comparação de valores.
3. **Gate negativo onde a armadilha é a ausência.** `RUSTFS_OBS_USE_STDOUT` (silencia), `cap_drop`
   no Postgres (quebra o boot), `docker-compose.prod.yml` (quebra o interruptor único),
   `pm.status_path` exposto (vaza status do pool).
4. **Toda mudança de runtime é medida nos 3 perfis** (dev, homolog, prod) — e, por R2, **homolog e
   prod têm que dar o mesmo resultado**. Se divergirem, a task está errada.
5. **Nenhuma alteração em `src/` sem teste unitário** e sem o ciclo completo (`make check` 2×).
6. **Mudança de `config/` e de `docker/` também é mudança de comportamento** ⇒ o gate é obrigatório
   mesmo sem tocar PHP.
7. **Determinismo:** nenhum teste pode depender de ordem de container, relógio ou rede externa.
8. **Nada de "depois a gente vê":** item sem destino no plano (ou no backlog com dono) não entra.

---

## 6. Como este plano se conecta ao `kd`

- **Epic:** `epic_00vr694c` — *DOCKER — runtime e configuração sistemática (dev/prod) a partir do
  veredito 09*.
- **Programa (âncora canônica):** `wiki/implementation/07-runtime-docker.md` — o epic é ancorado
  lá (é o que o `kd doctor` exige: épico-raiz com programa). Este `PLAN.md` é o **working doc**;
  o programa é o registro canônico e aponta para cá.
- **Issues (D0…D8):** uma por onda/domínio, com o recorte do §1.

| Issue | `kd` id | Tasks |
|---|---|---|
| **D0** — cadeia de timeout de upload | `task_0028o2ww` | `task_01k79vdz`… (6) |
| **D1** — telemetria e storage | `task_01pts3x0` | (4) |
| **D2** — ambiente e operação contínua | `task_004g2nwa` | (6) |
| **D3** — imagem única e suprimentos | `task_00pvl75o` | (7) |
| **D4** — Postgres conforme ADR-007 | `task_01hast3b` | (4) |
| **D5** — isolamento de rede e hardening | `task_01ewcw05` | (8) |
| **D6** — observabilidade do edge e do cache | `task_01bnnc7g` | (5) |
| **D7** — backup da mídia e upgrade do RustFS | `task_01ivqk3b` | (5) |
| **D8** — medição no host e decisões | `task_01hf054c` | (5) |

- **Tasks (D0.T1…D8.T5):** 50 no total, uma por mudança atômica, com o corpo no formato
  **Implementar / Testar-validar / Não-regressão** (o mesmo deste plano) + as regras transversais.
- **Fechamento:** `kd task close --id <ID> --outcome <…>` **só** com evidência (ADR-001/038:
  "só declara com evidência"). A evidência é a saída do `make check` + da prova específica.
- **Sincronização:** `kd write` das decisões tomadas (ex.: número da cadeia, decisão do
  `X-Request-ID`, nome do projeto) — o `notas/` é a memória, `PLAN.md` é o plano.
- **Consulta:** `kd task list --ready --sort impact` (o próximo passo desbloqueado) ·
  `kd task show --id <ID>` (contexto) · `kd task graph --root epic_00vr694c`.

**Regra de ouro do epic:** o commit é do **humano**. O agente fecha a task no `kd`, deixa a
evidência registrada e **não** faz `git commit` (AGENTS.md §0).

---

## 7. Ordem de ataque sugerida (sessão a sessão)

| Sessão | Escopo | Por quê |
|---|---|---|
| **1** | `D0` completo (T1–T6) | é o defeito de produção; 1 commit; fecha com `make check` 2× |
| **2** | `D1` + `D2` | baixo risco, tudo observável; fecha o `.env` e a operação contínua |
| **3** | `D3` (imagem) | 1 rebuild; valida o pipeline inteiro de uma vez |
| **4** | `D4` + `D6` | **feita** — tuning de banco + observabilidade, sem tocar topologia |
| **5** | `D5` (rede) | **feita** — maior blast radius; a matriz de reachability ficou guard permanente |
| **6** | `D7.T1–T3` (mídia) | inventariar, backup, PAB — antes de tocar no engine |
| **7** | `D7.T4–T5` (upgrade RustFS) | só com o backup verde |
| **8** | `D8` | depende de medição do host e de decisão de produto |

---

## 8. Riscos do plano (o que pode dar errado)

| Risco | Mitigação |
|---|---|
| **Timeout maior ⇒ worker preso por mais tempo** | As defesas são **taxa mínima** (Apache `MinRate=500`) + **idle** (Caddy `read_body_idle 30s`) + rate limit da app (ADR-021) — as três entram **juntas** com D0 |
| **Upload lento ⇒ exaustão do pool do FPM** (novo, amplificado pela D0) | Antes da D0, o corte em ~10 s devolvia o worker rápido; agora ele pode ficar preso até 300 s, e o pool tem 10 workers (`pm.max_children`). O rate limit da app limita a **taxa** (5/min/IP no `lgpd-dsar`), não a **concorrência** de uploads lentos. **Não** se mexe em `pm.max_children` sem medir (ADR-001): o dimensionamento vai para D8, com o número derivado da medição do host |
| **Segmentação de rede quebra egress legítimo** (`composer install`, IndexNow, feeds) | Matriz medida antes; `composer install` validado no CI; o guard de rede roda no `make check` |
| **Rebuild da imagem quebra o runtime** | D3 é uma sessão isolada, com `make check` E2E=1 logo depois |
| **`cap_drop` no Postgres** | Já medido que quebra ⇒ aplicado seletivamente, com gate **negativo** |
| **Upgrade do RustFS perde acesso a mídia** | D7.T4 só depois do backup verde; a prova é **ler um objeto existente** |
| **Chain de timeout inconsistente entre homolog e prod** | R2: homolog e prod são verificados juntos em toda task de runtime |
| **`make check` passando mas E2E quebrado** | E2E roda quando a task toca rota/HTML/JS; no resto, o smoke do serviço affected + a prova específica |
