# 03 — Catálogo de melhorias

Cada item tem **origem** (prática do CMS ou lacuna do V7), **alvo**, **mudança**, **ganho**,
**risco** e **verificação**. Nada aqui está aplicado. IDs são estáveis e usados no
`04-plano-de-aplicacao.md` e no `06-snippets.md`.

Legenda: `P0` risco atual/ganho imediato · `P1` ganho relevante · `P2` estrutural/condicionado.
Esforço: `B` < 1 h · `M` 1–4 h · `A` > 4 h.

---

## Performance e capacidade

### P-01 — Uma imagem para `php-fpm` e `queue`

- **Origem:** CMS define `image: maxima/cms:<target>` por serviço e constrói uma vez por alvo
  (`cms/docker-compose.yml:38-50`). A decisão registrada no knudge (`decision_00a1jcss`) diz que o
  `queue` reusa a imagem do `php-fpm`. O V7 declara o mesmo `build` duas vezes, sem `image:`
  (`docker-compose.yml:35-42,64-71`).
- **Alvo:** `docker-compose.yml`, `up_prod.sh:336`, `.gitlab-ci.yml:32`.
- **Mudança:** declarar `image: ${PHP_IMAGE:-concordia/php-fpm}:${PHP_TAG:-local}` nos dois
  serviços; manter `build:` **somente** no `php-fpm`; `queue` referencia a mesma imagem. Build
  explícito apenas de `php-fpm`.
- **Ganho:** um build por deploy (hoje são dois), uma única tag para rollback, impossibilidade de
  `queue` e `php-fpm` divergirem por cache/ordem de build.
- **Risco:** `queue` sobe antes de a imagem existir em um host limpo. Mitigação: `up_prod` e CI
  constroem `php-fpm` antes do `up`; `docker compose config --images` deve listar uma única imagem.
- **Verificação:** `docker compose config --images`; `docker compose build`; `docker image ls | grep php-fpm`.
- **P/E:** P1 / B.

### P-02 — Pool FPM com limites reais e supervisão

- **Origem:** `cms/containers/cms/configs/fpm.conf:16-18,28-34` (`ping.path`,
  `pm.status_path`, `request_terminate_timeout`, `rlimit_files`). O V7 não tem nenhum
  (`docker/php-fpm/zz-custom.conf:12-27`).
- **Alvo:** `docker/php-fpm/zz-custom.conf`.
- **Mudança:** `ping.path=/ping`, `ping.response=pong`, `pm.status_path=/status`,
  `request_terminate_timeout=35s`, `process_control_timeout=10s`,
  `emergency_restart_threshold=10`, `emergency_restart_interval=1m`, `listen.backlog=511`,
  `security.limit_extensions=.php`, `rlimit_files=65536`. Não expor `/status` no Caddy (ele não
  roteia esse caminho; o acesso é interno).
- **Ganho:** request travado em I/O (onde `max_execution_time` não conta) deixa de prender um
  worker para sempre; o pool reinicia sozinho após falhas repetidas; healthcheck real (S-05).
- **Risco:** `35s` precisa ser maior que `max_execution_time=30` e menor que o `read_timeout` do
  Caddy (P-07). Se existir rotina legítima > 35 s (export grande), elevar os dois juntos.
- **Verificação:** `docker compose exec php-fpm sh -c 'SCRIPT_NAME=/ping SCRIPT_FILENAME=/ping REQUEST_METHOD=GET cgi-fcgi -bind -connect 127.0.0.1:9000'`;
  simular request lento e ver `request_slowlog_timeout`/`request_terminate_timeout` no stderr.
- **P/E:** P0 / B.

### P-03 — Contexto de build mínimo

- **Origem:** lacuna. O Dockerfile copia apenas `docker/php-fpm/entrypoint.sh`
  (`Dockerfile:49`), mas o contexto é a raiz (`docker-compose.yml:36`) e o `.dockerignore` não
  exclui `app/`, `src/`, `config/`, `database/`, `resources/`, `routes/`, `public/`, `tests/`,
  `tools/`, `stubs/`. A memória do projeto (`fact_0106hlw9`) afirma contexto mínimo; o arquivo não
  corresponde.
- **Alvo:** `docker-compose.yml`, `docker/php-fpm/Dockerfile:49`, `docker/php-fpm/README.md`.
- **Mudança:** `context: ./docker/php-fpm`, `dockerfile: Dockerfile`, e trocar o `COPY` para
  `entrypoint.sh`. Atualizar o comando de build documentado.
- **Ganho:** contexto de kilobytes em vez de árvore de código; build mais rápido e menor risco de
  material sensível entrar no builder por engano.
- **Risco:** quebra o build manual documentado em `docker/php-fpm/README.md:26-28`; atualizar junto.
- **Verificação:** `docker compose build --progress plain php-fpm` (tamanho do contexto);
  build manual `docker build -f docker/php-fpm/Dockerfile docker/php-fpm`.
- **P/E:** P1 / B.

### P-04 — Cache de build e purga do toolchain de PECL

- **Origem:** lacuna. O Dockerfile instala `$PHPIZE_DEPS` (`Dockerfile:38`) e nunca os remove;
  não usa cache mounts (`Dockerfile:1` tem `syntax=docker/dockerfile:1`, mas nenhum
  `--mount=type=cache`). O CMS usa `install-php-extensions`, que já faz limpeza
  (`cms/containers/cms/Dockerfile:53-68`).
- **Alvo:** `docker/php-fpm/Dockerfile:20-48`.
- **Mudança:** `RUN --mount=type=cache,target=/var/cache/apt,sharing=locked
  --mount=type=cache,target=/var/lib/apt/lists,sharing=locked` no `apt-get`; após o `pecl install`,
  `apt-get purge -y --auto-remove $PHPIZE_DEPS` e `rm -rf /tmp/pear`; opcionalmente trocar PECL por
  `install-php-extensions` **pinado por versão/digest**.
- **Ganho:** imagem menor (compiladores e headers fora do runtime), rebuild de extensão muito mais
  rápido com cache de APT.
- **Risco:** purgar `$PHPIZE_DEPS` pode remover bibliotecas que `imagick`/`redis` carregam em
  runtime; validar com `php -m` e um request real. Fixar versões das extensões (S-09).
- **Verificação:** `docker image ls` antes/depois; `docker compose exec php-fpm php -m`; build 2×
  com cache.
- **P/E:** P1 / M.

### P-05 — Postgres: memória compartilhada e logs de diagnóstico

- **Origem:** `cms/containers/database/configs/postgresql.conf:23-32` (GUCs de log).
  O V7 não define `shm_size` nem os GUCs de conexão/lock/temp (`docker-compose.yml:88-107`,
  `docker/postgres/postgresql.conf:1-40`).
- **Alvo:** `docker-compose.yml` (serviço `postgres`), `docker/postgres/postgresql.conf`.
- **Mudança:** `shm_size: 256mb`; `max_connections=100`; `log_checkpoints=on`,
  `log_connections=on`, `log_disconnections=on`, `log_lock_waits=on`, `log_temp_files=10MB`,
  `log_autovacuum_min_duration=1s`, `log_error_verbosity=default`.
- **Ganho:** evita falha de worker paralelo por `/dev/shm` pequeno (default 64 MB); locks, temp
  files e autovacuum passam a aparecer no log — sem isso, contenção é invisível.
- **Risco:** `log_connections/disconnections` aumentam volume em pool com muitas conexões; o
  coletor (O-02) precisa de retenção. Usar `10MB`/`1s` em vez de `0` do CMS para não inundar.
- **Verificação:** `SHOW shared_buffers`, `SHOW log_lock_waits`; consulta com `pg_sleep` e
  `docker compose logs postgres`; `docker stats` do serviço.
- **P/E:** P0 / B.

### P-06 — Valkey: latência, memória e privacidade

- **Origem:** `cms/containers/cache/configs/valkey.conf:15-20,25-33,43-63` (logfmt,
  `hide-user-data-from-log`, `lazyfree-*`, `activedefrag`, `latency-monitor-threshold`).
  O V7 só tem `maxmemory`/`volatile-lru` (`docker/valkey/valkey.conf:9-21`).
- **Alvo:** `docker/valkey/valkey.conf`.
- **Mudança:** `log-format logfmt`, `log-timestamp-format iso8601`,
  `hide-user-data-from-log yes`, `lazyfree-lazy-eviction yes`, `lazyfree-lazy-expire yes`,
  `lazyfree-lazy-server-del yes`, `latency-monitor-threshold 100`, `tcp-backlog 511`;
  `io-threads 2` **somente** com ≥ 2 vCPU; `activedefrag yes` **somente** com jemalloc e CPU
  sobrando. Não copiar `rename-command` sem validar o Valkey 9 — preferir ACL.
- **Ganho:** picos de latência visíveis (`LATENCY LATEST`), remoção de chaves sem travar a main
  thread, logs parseáveis pelo coletor, menos dados de usuário em log.
- **Risco:** `io-threads` em 1 vCPU piora; `activedefrag` consome CPU. Medir antes.
- **Verificação:** `valkey-cli INFO memory`, `INFO stats`, `LATENCY LATEST`; `docker compose logs valkey`.
- **P/E:** P1 / B.

### P-07 — Caddy: encerramento gracioso e timeouts de upstream

- **Origem:** `cms/containers/balancer/configs/env.prod.conf:25-40` (`fastcgi_keep_conn`,
  timeouts de connect/send/read). O V7 tem timeouts de cliente (`Caddyfile:10-16`), mas não de
  upstream nem `grace_period`.
- **Alvo:** `docker/web/Caddyfile`.
- **Mudança:** `grace_period 10s` no bloco global; em `php_fastcgi`:
  `dial_timeout 3s`, `read_timeout 40s`, `write_timeout 40s` (maiores que P-02).
- **Ganho:** deploy/restart não derruba conexões em andamento; FPM morto gera 502 em 3 s em vez de
  pendurar o cliente; alinhamento explícito com o limite do pool.
- **Risco:** `read_timeout` curto demais corta export legítimo; manter a cadeia
  Caddy ≥ FPM ≥ `max_execution_time`.
- **Verificação:** `docker compose stop php-fpm` e medir `curl`; `docker compose restart web` sob
  carga leve e conferir ausência de 502 no access log.
- **P/E:** P1 / B.

### P-08 — Limites de recursos por serviço

- **Origem:** lacuna nos dois projetos (grep por `mem_limit`/`cpus`/`pids_limit` = 0 em ambos os
  Compose). Sem limite, um pico de FPM ou de RustFS derruba o host inteiro.
- **Alvo:** `docker-compose.yml`.
- **Mudança:** `mem_limit`, `cpus`, `pids_limit` e `ulimits.nofile` por serviço. Referência para
  2 vCPU / 4 GB: `postgres 1g`, `php-fpm 1536m`, `queue 512m`, `valkey 320m`, `web 128m`,
  `storage 512m`; `pids_limit 256` (php/queue), `128` (web/valkey), `256` (postgres);
  `nofile 65536`. Ajustar após `docker stats`.
- **Ganho:** contenção de OOM e de fork bomb; previsibilidade de capacidade; o `memory_limit` do
  PHP (256M × 10 workers) deixa de ser o único teto.
- **Risco:** limite baixo gera `OOMKilled` e restart em loop. Subir com folga medida e monitorar
  `docker events --filter event=oom`.
- **Verificação:** `docker stats --no-stream`; `docker inspect -f '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}' <c>`;
  teste de carga leve.
- **P/E:** P1 / B.

### P-09 — OPcache preload (condicionado a medição)

- **Origem:** `cms/containers/cms/configs/99_prod.ini:13-16`. O V7 mantém JIT off com medição
  (`docker/php-fpm/php.ini:27-31`) e não usa preload.
- **Alvo:** `docker/php-fpm/php.ini` + `up_prod.sh`.
- **Mudança:** gerar um preload determinístico no deploy (lista de classes do `vendor`/`src`) e
  apontar `opcache.preload`; **não** copiar `opcache.jit=1255` do CMS — a medição do V7 mostra
  `tracing` 2,4× pior (spec 17 §4).
- **Ganho:** reduz compilação no primeiro request; potencial de p50 menor em troca de memória.
- **Risco:** preload com estado/IO quebra o boot; com bind mount e `validate_timestamps=0`, o
  arquivo só é relido no reload do FPM. Exige medição antes/depois (`opcache_get_status()`).
- **Verificação:** `opcache_get_status()['preload_statistics']`; comparar p50/p95 de `GET /`;
  rollback = remover a diretiva e `USR2`.
- **P/E:** P2 / M.

---

## Segurança

### S-01 — `no-new-privileges` e `cap_drop`

- **Origem:** lacuna. Nenhum dos Compose define `security_opt`/`cap_drop` (grep = 0).
- **Alvo:** `docker-compose.yml`.
- **Mudança:** `security_opt: ["no-new-privileges:true"]` em todos; `cap_drop: [ALL]` em `web`,
  `php-fpm`, `queue`, `storage` e `valkey` (validar o entrypoint do Valkey). Em `postgres`, aplicar
  `cap_drop: ALL` + `cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID]` **somente** se o
  entrypoint oficial precisar (ele roda como root e faz drop); alternativa: `user: postgres` com
  volume já pertencente ao UID.
- **Ganho:** impede escalada via setuid/binários do host; reduz o dano de um RCE no PHP.
- **Risco:** `cap_drop` quebra `chown`/`setuid` no boot do Postgres e o `gosu` do Valkey. Testar
  serviço a serviço; exceções documentadas.
- **Verificação:** `docker inspect -f '{{.HostConfig.SecurityOpt}} {{.HostConfig.CapDrop}}' <c>`;
  `docker compose up -d` e healthchecks verdes.
- **P/E:** P0 / B.

### S-02 — Rootfs read-only com `tmpfs` para `/tmp`

- **Origem:** lacuna.
- **Alvo:** `docker-compose.yml`.
- **Mudança:** `read_only: true` + `tmpfs: ["/tmp:size=256m,mode=1777"]` em `web`, `php-fpm`,
  `queue`, `valkey` e `postgres` (`/var/run/postgresql` também em tmpfs). Os bind mounts de código
  e storage continuam graváveis; o que fica imutável é o sistema de arquivos da imagem.
- **Ganho:** webshell não consegue gravar em `/usr/local`, `/etc` ou no docroot da imagem;
  auditoria fica mais simples (só bind mounts mudam).
- **Risco:** `php-fpm` grava upload temporário em `/tmp` (já configurado em `zz-custom.conf:36`);
  `postgres` grava socket em `/var/run/postgresql`; `caddy` usa `/data`/`/config` (volumes).
  Validar upload de 100 MB e `pg_isready`.
- **Verificação:** `docker compose exec php-fpm sh -c 'touch /x'` deve falhar; upload real pelo CMS;
  `pg_isready`.
- **P/E:** P1 / M.

### S-03 — Segmentação de rede

- **Origem:** o CMS separa `concordia-net` de `telemetry-net` (`internal: true`)
  (`cms/docker-compose.yml:415-419`). O V7 tem uma única bridge (`docker-compose.yml:176-177`),
  então `postgres`, `valkey` e `storage` compartilham o mesmo domínio de rede do `web`.
- **Alvo:** `docker-compose.yml`.
- **Mudança:** `edge` (web), `app` (web, php-fpm, queue, mailpit),
  `data` (**`internal: true`**; php-fpm, queue, postgres, valkey, storage),
  `telemetry` (php-fpm, queue, coletor; publicar apenas em `127.0.0.1`). `postgres`/`valkey`/
  `storage` só em `data`.
- **Ganho:** os dados não têm rota para a internet (exfiltração/exfil via dependência) e o `web`
  não alcança o banco; reduz movimento lateral.
- **Risco:** `internal: true` em rede com porta publicada não é confiável em todas as versões —
  manter `telemetry` fora do `internal` se precisar publicar 4318/16686. Testar resolução e egress.
  O healthcheck do `storage` usa `127.0.0.1`, então continua funcionando.
- **Verificação:** `docker compose exec postgres sh -c 'getent hosts example.com || true'` (deve
  falhar); `docker compose exec web wget -qO- http://postgres:5432` (deve falhar);
  `php-fpm` alcança `postgres`/`valkey`/`storage`; `/up` 200.
- **P/E:** P1 / M.

### S-04 — Credenciais de infraestrutura fora de argv

- **Origem:** lacuna. `REDIS_PASSWORD` vai por `--requirepass` (`docker-compose.yml:111`) e
  `POSTGRES_PASSWORD`/`AWS_*` por ambiente; o CMS também não usa `secrets`.
- **Alvo:** `docker-compose.yml`, `docker/valkey/`, `up_prod.sh`.
  `POSTGRES_PASSWORD_FILE` (suportado pela imagem oficial) com `secrets:`;
  Valkey via ACL file gerado no entrypoint a partir do secret (ou manter `--requirepass` e aceitar
  a exposição, documentando); RustFS 1.0.2 suporta `RUSTFS_ACCESS_KEY_FILE`/`RUSTFS_SECRET_KEY_FILE`
  (skill `rustfs/03:33-34`; `15:209-219`; validar após RS-01, pois a imagem atual é 1.0.1) —
  usar arquivos/secrets e separar root × workload
  (08 RS-07/RS-14). Manter `.env` em `600` (já feito em `up_prod.sh:19-20`) e fora do mount do
  `web` (S-06/P-03).
- **Ganho:** senha não aparece em `docker inspect`, `ps` nem no `Config.Env` de serviços que não
  precisam dela.
- **Risco:** `secrets:` só entrega arquivo; a aplicação Laravel lê `.env`, então o ganho maior é
  para os serviços de infra, não para o PHP. Não introduzir um cofre só para isso.
- **Verificação:** `docker inspect` sem valores; `valkey-cli -a` funciona; `pg_isready`.
- **P/E:** P2 / M.

### S-05 — Healthcheck por FPM ping

- **Origem:** `cms/containers/cms/Dockerfile:136-137` usa `cgi-fcgi` em `/ping`; o V7 usa
  `fsockopen` (`docker-compose.yml:56-62`), que só prova que a porta aceita conexão — com todos os
  workers ocupados o TCP ainda aceita (backlog) e o container fica “healthy” sem responder.
- **Alvo:** `docker/php-fpm/Dockerfile` (instalar `libfcgi-bin`), `docker-compose.yml`.
- **Mudança:** healthcheck
  `SCRIPT_NAME=/ping SCRIPT_FILENAME=/ping REQUEST_METHOD=GET cgi-fcgi -bind -connect 127.0.0.1:9000 | grep -q pong`,
  `interval: 10s`, `timeout: 3s`, `retries: 3`, `start_period: 20s`.
- **Ganho:** detecta pool travado/saturado; o `depends_on` do `web` passa a refletir a capacidade
  real de atender.
- **Risco:** depende de P-02 (`ping.path`); a imagem cresce ~1 pacote. Não usar `/up` aqui (ele
  depende de PG/Valkey e transformaria indisponibilidade de dados em restart do FPM).
- **Verificação:** ocupar todos os workers e ver o healthcheck falhar; `docker inspect` do estado.
- **P/E:** P0 / B.

### S-06 — Caddy nega arquivos sensíveis por regex

- **Origem:** `cms/containers/balancer/configs/sites/site.conf.template:235-243` bloqueia
  `composer.*`, `package*.json`, `phpunit.xml`, `vite.config.js` e dotfiles. O V7 bloqueia apenas
  `*.ini`, `*.log`, `error_log` e `.*` (`docker/web/Caddyfile:32-33`) — o matcher `path` do Caddy
  não atravessa `/`, então um caminho aninhado não é coberto.
- **Alvo:** `docker/web/Caddyfile`.
- **Mudança:** um `@forbidden path_regexp` cobrindo `(^|/)\.(?!well-known)` +
  `composer\.(json|lock)`, `package(-lock)?\.json`, `phpunit\.xml`, `phpstan.*\.neon`,
  `vite\.config\.js`, `.*\.(ini|log)`, `error_log`; `respond @forbidden 404`.
- **Ganho:** defesa em profundidade se o `root` for alterado por engano ou um symlink aparecer no
  `public/`; mantém `/.well-known/` (security.txt) acessível.
- **Risco:** regex bloquear asset legítimo com extensão `.log` (raro); testar `curl -I` nos
  caminhos e no `security.txt`.
- **Verificação:** `curl -s -o /dev/null -w '%{http_code}' localhost:8080/.env` → 404;
  `localhost:8080/composer.json` → 404; `/.well-known/security.txt` → 200.
- **P/E:** P1 / B.

### S-07 — Usuário padrão na imagem

- **Origem:** `cms/containers/cms/Dockerfile:201,217` (`USER www-data`). O V7 não define `USER`
  (`docker/php-fpm/Dockerfile`), então quem roda a imagem fora do Compose roda como root.
- **Alvo:** `docker/php-fpm/Dockerfile`.
- **Mudança:** `ARG UID=1000 GID=1000`, ajustar `www-data` e `USER www-data` no fim; manter o
  `user:` do Compose (que continua vencendo). Adicionar labels OCI.
- **Ganho:** defesa em profundidade para `docker run`, `compose run` e debug; imagem auditável.
- **Risco:** se o UID do host for diferente, o entrypoint pode não escrever em
  `storage/framework/php-env`; o Compose resolve, mas o uso direto exige `--user`.
- **Verificação:** `docker run --rm <img> id`; `docker compose up` e `/up` 200.
- **P/E:** P2 / B.

### S-08 — SBOM, proveniência e scan de imagem

- **Origem:** lacuna. O V7 gera SBOM de dependências PHP (`make sbom`, `Makefile:98-100`), mas não
  da imagem; o CMS tem Sonar, não scan de container. ADR-020 exige SBOM.
- **Alvo:** `.gitlab-ci.yml`, `Makefile`, `tools/`.
- **Mudança:** job `images` com
  `docker buildx build --sbom --provenance --load -t <img> .` e scan
  `trivy image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 <img>`
  (Trivy pinado por digest). Publicar `sbom.cdx.json` e o relatório como artefatos.
- **Ganho:** CVE de base/extensão bloqueia o pipeline; SBOM rastreia o que foi para produção.
- **Risco:** CVE sem correção (`--ignore-unfixed`) e ruído de base; usar `.trivyignore` com prazo.
  Não colocar no `make check` (rede + minutos); é gate de CI.
- **Verificação:** job verde em imagem limpa; job vermelho ao fixar uma base com CVE conhecida.
- **P/E:** P1 / M.

### S-09 — Pinagem real de extensões e checksum do BLAKE3

- **Origem:** o V7 instala `apcu redis imagick pcov` sem versão (`Dockerfile:28`) e baixa o
  BLAKE3 por `curl` sem checksum (`Dockerfile:38-44`). O CMS é pior (`latest` do
  `install-php-extensions`, `git clone` sem tag, `pip install docling`), mas ilustra o mesmo risco.
- **Alvo:** `docker/php-fpm/Dockerfile`.
- **Mudança:** fixar versões PECL compatíveis com PHP 8.4 (ex.: `redis-6.2.0`, `apcu-5.1.24`,
  `imagick-3.8.0`, `pcov-1.0.12` — validar no build); baixar o tarball do BLAKE3 e verificar
  `sha256sum -c` com o hash do commit pinado. Opcional: `SOURCE_DATE_EPOCH` +
  `--rewrite-timestamp` para reprodutibilidade (ADR-001).
- **Ganho:** build reprodutível e resistente a troca silenciosa de artefato; o commit pinado sem
  checksum não protege contra retag/comprometimento do tarball.
- **Risco:** versão de extensão incompatível com 8.4; validar `php -m` e a suíte.
- **Verificação:** build 2× e comparar o hash do tarball; `php -m | grep -E 'redis|apcu|imagick|pcov|blake3'`;
  `make check`.
- **P/E:** P0 / B.

### S-10 — `X-Request-ID` de entrada: confiança explícita

- **Origem:** o CMS gera `$request_id` na borda (`nginx.conf:151-169`, `headers.conf:14`). O V7
  aceita qualquer `X-Request-ID` de 8–128 caracteres (`[A-Za-z0-9._-]`) vindo do cliente
  (`src/core/Internal/Observability/RequestIdMiddleware.php:72-80`), o que permite forjar/correlacionar
  logs de terceiros.
- **Alvo:** `src/core/Internal/Observability/RequestIdMiddleware.php`, `docker/web/Caddyfile`.
- **Mudança:** aceitar o header apenas quando a request vem de proxy confiável **e** o valor é
  UUID; caso contrário gerar `uuid7()`. No Caddy, registrar `request>headers>X-Request-Id` e o
  status/duration no access log (O-01).
- **Ganho:** correlação edge↔app confiável, sem log forging.
- **Risco:** integrações internas que hoje enviam IDs não-UUID passam a ser ignoradas; documentar.
- **Verificação:** `curl -H 'X-Request-ID: forged'` e conferir que a resposta traz um UUID novo;
  request do Apache (proxy confiável) preserva o header.
- **P/E:** P1 / B.

---

## Observabilidade e logs

### O-01 — Access log JSON do Caddy

- **Origem:** `cms/containers/balancer/configs/nginx.conf:151-169` (JSON com `request_id`,
  `request_time`, `upstream_response_time`, `upstream_status`). O `Caddyfile` do V7 não tem `log`
  → nenhuma linha de acesso é emitida, apesar de `logging` do Docker estar configurado.
- **Alvo:** `docker/web/Caddyfile`.
- **Mudança:** `log { output stdout; format json; level INFO }`; remover headers sensíveis do log
  (`Authorization`, `Cookie`) com `format filter`/`fields` (validar a sintaxe na Caddy 2.11);
  incluir `request>headers>X-Request-Id` e `duration`.
- **Ganho:** status/latência/bytes por request no `docker logs` e no coletor; correlação com o log
  JSON do Laravel pelo `X-Request-ID`; base para SLO e rate limit observável.
- **Risco:** volume de log e PII (IP/UA) — o coletor (O-02) deve mascarar IP e ter retenção; não
  logar `Cookie`/`Authorization`.
- **Verificação:** `docker compose logs web --tail=1 | jq .`; request com falha proposital e
  conferir `status`/`duration`.
- **P/E:** P0 / B.

### O-02 — Coletor de logs e traces (Vector → Loki/Grafana)

- **Origem:** `cms/containers/telemetry/configs/vector.yaml:31-77` (máscara de PII) e
  `docker-compose.yml:226-260` (profile `telemetry`), mas os sinks são só `console`
  (`vector.yaml:80-96`), Loki não tem retenção (`loki.yaml:36-39`) e o Vector não tem buffer em
  disco. O V7 não tem coletor.
- **Alvo:** novo `docker/observability/` + serviço sob profile em `docker-compose.yml`.
- **Mudança:** Vector com `docker_logs` filtrado pelo projeto Compose + receiver OTLP; VRL de
  metadados e mascaramento (IP, e-mail, token, CPF); sink `loki` (labels: `project`, `service`,
  `env`; **nunca** `request_id` como label) e sink OTLP para Tempo/Jaeger; buffer em disco com
  volume; Loki com `retention_period` + compactor; Grafana com datasources provisionados em
  **todos** os alvos e senha vinda de secret; `auth_enabled` coerente com a rede (sem porta
  publicada).
- **Ganho:** logs e traces consultáveis após o restart do container; PII mascarada antes de sair
  do host (LGPD/ADR-019); retenção explícita em vez de `json-file` de 30 MB.
- **Risco:** cardinalidade e disco; VRL errado pode descartar log ou vazar PII — testar com
  amostras. Coletor não é dependência do request (fail-open).
- **Verificação:** `docker compose --profile telemetry up -d`; `curl` com e-mail no path;
  consultar Loki/Grafana e confirmar máscara; reiniciar `php-fpm` e ver o log anterior no Loki.
- **P/E:** P1 / A.

### O-03 — Coletor de traces presente em produção

- **Origem:** lacuna. `TELEMETRY_ENABLED=true` em produção (`config/environments.php:38`), endpoint
  default `http://jaeger:4318` (`config/observability.php:34`), mas o `jaeger` só sobe com
  `--profile telemetry` (`docker-compose.yml:164`) e o `up_prod.sh` não ativa o profile. O
  circuit breaker (`:36`) descarta os spans.
- **Alvo:** `up_prod.sh`, `.env.example`, `config/observability.php` (default).
- **Mudança:** escolher explicitamente: (a) `up_prod.sh` sobe `--profile telemetry`; ou (b)
  `OTEL_EXPORTER_OTLP_ENDPOINT` aponta para um coletor externo e o profile fica off. Desligar
  `TELEMETRY_ENABLED` é **exceção documentada** (ADR-024), nunca o default. Nunca “ligado com
  endpoint inexistente”.
- **Ganho:** traces realmente disponíveis; fim do trabalho desperdiçado e do ruído de falha de
  exportação.
- **Risco:** coletor local consome RAM/disco; subir junto com P-08 e O-02.
- **Verificação:** `curl -s http://127.0.0.1:16686/api/services` contém `concordia`; ou ausência de
  erro de exportação no stderr; `artisan about` mostra o endpoint.
- **P/E:** P0 / B.

### O-04 — Retenção local de log com teto

- **Origem:** `config/logging.php:86-96`: `daily` com `days=730` e sem limite de tamanho por
  arquivo; `json` em stderr em paralelo. Logs ligados são requisito; retenção infinita não.
- **Alvo:** `.env.example`, `config/logging.php` (default), documentação de operação.
- **Mudança:** manter `LOG_STACK=json,daily`; definir `LOG_DAILY_DAYS` por política (ex.: 90 ou
  365, conforme LGPD/auditoria), `LOG_LEVEL=info` em produção, e um limite/alerta de tamanho de
  `storage/logs`; opcionalmente comprimir os arquivos rotacionados via cron do host.
- **Ganho:** evita encher a cota do WHM (`storage` no docroot); mantém o log local para
  diagnóstico e o stderr para o coletor.
- **Risco:** reduzir demais a retenção pode conflitar com auditoria — a trilha de auditoria é
  separada (`core_audit_logs`, ADR-035), então o log técnico pode ser curto.
- **Verificação:** `du -sh storage/logs` após rotação; `LOG_DAILY_DAYS=1` em teste e conferir a poda.
- **P/E:** P1 / B.

---

## Confiabilidade

### R-01 — `init: true` no queue (e no FPM)

- **Origem:** lacuna. `php artisan queue:work` é PID 1 e pode deixar zumbis de `Symfony Process`
  (usado por jobs); o CMS também não usa `init`.
- **Alvo:** `docker-compose.yml`.
- **Mudança:** `init: true` em `queue` e `php-fpm` (o `web` não precisa; Caddy já reapa).
- **Ganho:** reap de processos, sinais corretos, menos PID leak em jobs longos.
- **Risco:** overhead mínimo; validar que o `php-fpm` continua recebendo `SIGQUIT`/`USR2` no PID
  correto (tini repassa).
- **Verificação:** `docker compose exec queue ps -o pid,ppid,stat,cmd`; rodar job que invoca
  processo e conferir ausência de `<defunct>`; `make reload`.
- **P/E:** P1 / B.

### R-02 — `stop_grace_period` no queue e no FPM

- **Origem:** lacuna. O Docker mata em 10 s por padrão; o worker Laravel termina o job atual ao
  receber `SIGTERM`, mas só se tiver tempo. O Compose usa `--max-time=3600` e `--tries=3`
  (`docker-compose.yml:70`), sem janela de parada.
- **Alvo:** `docker-compose.yml`.
- **Mudança:** `stop_grace_period: 120s` no `queue` + `--timeout=90` no worker; `20s` no `php-fpm`;
  `10s` no `web` (coerente com `grace_period` do Caddy, P-07).
- **Ganho:** deploys e restarts não interrompem job no meio; menos reprocessamento e duplicidade.
- **Risco:** deploy mais lento quando há job longo; `--timeout` deve ser menor que a graça para o
  worker conseguir abortar antes do `SIGKILL`.
- **Verificação:** `docker compose stop queue` durante job de 60 s; conferir conclusão no log;
  medir o tempo de `up -d`.
- **P/E:** P0 / B.

### R-03 — Healthcheck nos serviços que não têm

- **Origem:** o CMS tem healthcheck em quase tudo (`docker-compose.yml:134,158,193,253,408`); o V7
  não tem em `web`, `queue`, `mailpit` e `jaeger` (`docker-compose.yml`).
- **Alvo:** `docker-compose.yml`, `docker/web/Caddyfile`.
- **Mudança:** `web`: rota `respond /health 200` + `wget -qO- http://127.0.0.1:8080/health`
  (validar `wget` na imagem Caddy); `queue`: `pgrep -f 'artisan queue:work'` (adicionar `procps`);
  `mailpit`: endpoint de liveness (`/livez` — validar) ou `/api/v1/info`; `jaeger`: `GET /` em
  `16686`. Adicionar `start_interval: 5s` e `start_period` onde fizer sentido.
- **Ganho:** `docker compose ps` reflete a realidade; `depends_on` pode usar `service_healthy`;
  restart automático de processo travado (queue).
- **Risco:** `queue` saudável por processo não detecta backlog; alerta de backlog é métrica
  (`queue:monitor` agendado), não liveness. Evitar restart em loop por dependência externa.
- **Verificação:** matar o processo dentro do container e ver o estado `unhealthy`/restart;
  `docker compose ps`.
- **P/E:** P1 / B.

### R-04 — `up_prod.sh` com preflight e build único

- **Origem:** o CMS usa um `wait` dedicado (`Makefile:105`) e `infra-audit.sh`; o V7 tem um deploy
  maduro, mas constrói duas imagens (`up_prod.sh:336`), referencia um Compose de produção
  inexistente (`:151`) e só valida `APP_ENV`/`APP_KEY` (`:127-130`).
- **Alvo:** `up_prod.sh`.
- **Mudança:** `docker compose config -q` antes do lock; validar variáveis obrigatórias
  (DB/Redis/AWS/WEB_PORT/DATA_PATH/STORAGE_PATH) e rejeitar `change-me` em produção; construir só
  `php-fpm` (P-01); rotular a imagem com `org.opencontainers.image.revision=$DEPLOY_COMMIT`;
  gate de saúde via `docker compose ps --format json` (além do `wait_healthy`); remover o suporte a
  `docker-compose.prod.yml` (U-06).
- **Ganho:** falha antes de tocar em produção (env incompleto, Compose inválido), deploy mais
  rápido, rastreabilidade imagem↔commit.
- **Risco:** novas validações podem bloquear deploys existentes com `.env` incompleto — é o
  objetivo; documentar no release.
- **Verificação:** `./up_prod.sh --dry-run`; `.env` sem `DB_PASSWORD` falha antes do lock;
  `docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}'`.
- **P/E:** P1 / M.

### R-05 — Retenção do WAL arquivado

- **Origem:** `docker/postgres/postgresql.conf:37-40`: `archive_mode=on`,
  `archive_timeout=300` e `archive_command` copiando para `PGDATA/wal_archive` sem poda nem envio
  para S3. `archive_timeout` força troca de segmento a cada 5 min mesmo ocioso → ~288 segmentos/dia
  (16 MB cada, até ~4,6 GB/dia) que **nunca** são removidos.
- **Alvo:** `docker/postgres/postgresql.conf`, `up_prod.sh`/runbook, imagem do Postgres.
- **Mudança:** curto prazo: rotina de poda por idade/tamanho (host ou sidecar) + alerta de uso;
  alvo: `wal-g`/`pgBackRest` para o S3 (ADR-030) com `archive_command` remoto e retenção, ou
  `archive_mode=off` até a estratégia de PITR existir. Nunca manter o diretório crescendo.
- **Ganho:** evita disco cheio (parada do Postgres) e preserva a capacidade de PITR.
- **Risco:** remover WAL cedo demais quebra restore; a poda deve respeitar o backup base mais
  recente. Testar restore em base limpa (spec 17).
- **Verificação:** `du -sh $DATA_PATH/pgdata/pgdata/wal_archive`; simular 24 h; `pg_restore --list`
  do dump + replay; alerta de disco.
- **P/E:** P0 / B (poda) / M (S3).
- **Não cobre:** este item é do **WAL/banco**. A mídia no RustFS não é copiada por ele; ver
  `08-rustfs-storage.md` RS-06 (backup P0) e RS-10 (single-drive/`EC:0`).

### R-06 — Healthcheck do Valkey sem senha em argv

- **Origem:** `docker-compose.yml:117-122`: `valkey-cli -a "$$REDIS_PASSWORD" ping` — a senha
  aparece no comando e o `-a` emite warning. Se o `requirepass` for adicionado ao config, o probe
  atual (sem auth) passa a falhar.
- **Alvo:** `docker-compose.yml`.
- **Mudança:** `REDISCLI_AUTH="$$REDIS_PASSWORD" valkey-cli --no-auth-warning ping` (ou
  `-a` + `--no-auth-warning`, aceitando o argv) e `start_period: 10s`.
- **Ganho:** healthcheck correto com senha, sem vazar em log; alinhado ao S-04.
- **Risco:** `REDISCLI_AUTH` precisa ser suportado pela versão do `valkey-cli` (é compatível com
  `redis-cli`); validar.
- **Verificação:** `docker compose exec valkey sh -c 'REDISCLI_AUTH=$REDIS_PASSWORD valkey-cli --no-auth-warning ping'`;
  healthcheck verde.
- **P/E:** P0 / B.

---

## Usabilidade e DX

### U-01 — `.env.example` completo e preflight de obrigatórias

- **Origem:** lacuna. O `.env.example` tem 42 linhas e não documenta `UID`, `GID`, `WEB_PORT`,
  `STORAGE_PATH`, `DATA_PATH`, `BACKUP_*`, `LOG_*`, `OTEL_*`, `AWS_DEFAULT_REGION`,
  `AWS_USE_PATH_STYLE_ENDPOINT`, `MAIL_*`, `COMPOSE_PROJECT_NAME` — todos usados pelo Compose,
  `up_prod.sh` ou `config/`.
- **Alvo:** `.env.example`, `up_prod.sh`.
- **Mudança:** completar com valores default e comentário de efeito; no `up_prod`, validar as
  obrigatórias e recusar `change-me` em produção; documentar as opcionais.
- **Ganho:** um clone novo sobe sem “descobrir” variável por erro de runtime; menos suporte.
- **Risco:** nenhum; manter `APP_KEY` vazio e gerar no deploy.
- **Verificação:** `cp .env.example .env` em diretório limpo + `./up_prod.sh --dry-run`;
  `docker compose config -q`.
- **P/E:** P0 / B.

### U-02 — Variáveis `PORT_*` e tabela de portas

- **Origem:** `.env.docker.example:30-42` do CMS reserva uma faixa e nomeia cada porta. O V7 fixa
  `8025`, `16686`, `4318` (`docker-compose.yml:160,170-171`), que colidem com outros projetos no
  mesmo host.
- **Alvo:** `docker-compose.yml`, `.env.example`, `README`/`docker/php-fpm/README.md`.
- **Mudança:** `PORT_MAILPIT_UI`, `PORT_JAEGER_UI`, `PORT_OTLP_HTTP` (defaults fora da faixa
  `17010-17020` usada pelo CMS); manter bind em `127.0.0.1`; tabela de portas por profile.
- **Ganho:** dois projetos no mesmo host sem conflito; descoberta de portas documentada.
- **Risco:** scripts que assumem `8025`/`16686`; atualizar docs e `Makefile` se houver.
- **Verificação:** `docker compose --profile debug --profile telemetry config` mostra as portas
  esperadas; subir os dois projetos e conferir.
- **P/E:** P1 / B.

### U-03 — `make doctor`

- **Origem:** `cms/infra-audit.sh` audita borda, Valkey, OPcache, timezone do PG e Vector. O V7 não
  tem equivalente; o diagnóstico é manual.
- **Alvo:** `tools/infra-doctor.sh`, `Makefile`.
- **Mudança:** alvo `doctor` que verifica: `docker compose config -q`; containers/healthchecks;
  `GET /up` 200; headers de segurança e CSP no `web`; ausência de HSTS no Caddy (dono é o Apache);
  versão/timezone do PG; `PING` do Valkey; bucket S3; endpoint OTLP; permissão do `.env`.
- **Ganho:** um comando para diagnosticar host/cliente; base para runbook.
- **Risco:** não é gate e não substitui `make check`; não rodar em produção sem modo somente-leitura.
- **Verificação:** `make doctor` em ambiente saudável (tudo OK) e com `valkey` parado (FAIL claro).
- **P/E:** P1 / M.

### U-04 — Âncoras YAML para defaults

- **Origem:** o CMS usa `x-rootless`/`x-logging` (`docker-compose.yml:18-25`); o V7 só tem
  `x-logging`. Sem âncora, `security_opt`, `cap_drop`, `init`, `stop_grace_period` e `logging`
  serão repetidos em 8 serviços e vão divergir.
- **Alvo:** `docker-compose.yml`.
- **Mudança:** `x-service-defaults` com `restart`, `logging`, `security_opt`, `cap_drop`, `init`;
  merge por serviço. Lembrar que listas YAML **substituem**, não concatenam.
- **Ganho:** consistência e revisão mais curta.
- **Risco:** merge raso pode esconder `cap_add`/`tmpfs`; revisar `docker compose config` por serviço.
- **Verificação:** `docker compose config` e diff por serviço.
- **P/E:** P2 / B.

### U-05 — Documentar o artefato único e os logs ligados

- **Origem:** o axioma A0 deste mapeamento; o CMS tem três alvos e `env.<target>.conf`, o que o V7
  não deve replicar. A spec 17 fala em build de `php-fpm` e o README do docker não fixa a política.
- **Alvo:** `docker/php-fpm/README.md`, `wiki/specs/17-up-prod.md`, `README.md`.
- **Mudança:** seção curta: “uma imagem, um Compose, perfil por `APP_ENV`; **o artefato de dev
  é o que roda em produção** (bind mount + composer no deploy), com logs sempre ligados
  (stderr JSON + access log + coletor opcional); não existe `docker-compose.prod.yml`”.
- **Ganho:** impede que a prática de multi-stage do CMS seja reintroduzida por hábito.
- **Risco:** nenhum.
- **Verificação:** revisão de texto; `grep -r 'stage\\|prod' docker/` não deve sugerir alvos.
- **P/E:** P0 / B.

### U-06 — Remover o suporte morto a `docker-compose.prod.yml`

- **Origem:** `up_prod.sh:151` adiciona o arquivo se existir; ele não existe. Sob A0, ele nunca deve
  existir.
- **Alvo:** `up_prod.sh`.
- **Mudança:** remover o `if` e a variável `COMPOSE_FILES` condicional; deixar um único
  `-f docker-compose.yml` e documentar a proibição.
- **Ganho:** menos caminho não testado; elimina a tentação de congelar ambiente no Compose.
- **Risco:** nenhum (arquivo inexistente).
- **Verificação:** `grep -R 'docker-compose.prod' .`; `./up_prod.sh --dry-run`.
- **P/E:** P1 / B.

### U-07 — Colisão de `COMPOSE_PROJECT_NAME`

- **Origem:** `docker-compose.yml:6` e `cms/docker-compose.yml:6` usam o mesmo fallback
  `concordia`. Sem a variável, os dois repositórios são o **mesmo** projeto Compose; um
  `down --remove-orphans` remove containers do outro. O knudge registra que o nome deve vir do
  `APP_NAME` (`fact_00z4yg1n`), mas o fallback atual não garante isso.
- **Alvo:** `docker-compose.yml`, `.env.example`, `up_prod.sh`, `Makefile`.
- **Mudança:** tornar a variável obrigatória (`${COMPOSE_PROJECT_NAME:?defina COMPOSE_PROJECT_NAME}`)
  ou trocar o fallback por um valor exclusivo (`publicv7`); garantir que `.env.example` a defina;
  preflight recusa projeto já em uso por outro diretório.
- **Ganho:** evita que um deploy derrube o outro; sustenta “uma instância = um cliente”.
- **Risco:** `docker compose` direto passa a exigir a variável; documentar o `cp .env.example .env`.
- **Verificação:** `docker compose config --format json | jq .name`; `docker compose ls` com os
  dois repositórios; `./up_prod.sh --dry-run`.
- **P/E:** P0 / B.
