# P4 — Áreas de estatística (métricas privacy-first)

| Campo | Valor |
|---|---|
| **Fase** | P4 — Áreas de estatística (métricas privacy-first), F7+ |
| **`kd` épico** | `epic_003yl8py` |
| **`kd` fatias** | `task_000r40qa` (S1) · `task_005st7fj` (S2) · `task_01e379r3` (S3) · `task_01426ie7` (S4) · `task_015t3ggc` (S5) · `task_01m2zeqa` (P4.FIX) · `task_00epcj24` (P4.FIX2) |
| **Âncoras** | `wiki/implementation-phases/P4-estatisticas.md` §3/S1–S5, §4–§7 · `wiki/implementation-phases/TECNICO.md` §2–§6, §9–§11, §14 · ADR-047 (aceita), ADR-019, ADR-024, ADR-035, ADR-017, ADR-007 · `DECISOES.md` D4 · `src/core/Internal/Metrics/**` · `src/core/Metrics/**` · `src/core/Area/Cms/Logic/**` · `src/core/Contracts/{Metrics,MetricRegistry,UniqueCounter}.php` · `src/core/Contracts/Ports/MetricCard.php` · `src/layout/Cms/dashboard.blade.php` · `src/modules/Infraestrutura/Estatisticas/**` · `src/modules/Portal/Lgpd/**` · `routes/console.php` · `src/tests-e2e/{histories,tests}/24-estatisticas*` · `config/metrics.php` |
| **Status da revisão** | ✅ revisado — S1–S5 + P4.FIX (`task_01m2zeqa`) + P4.FIX2 (`task_00epcj24`); revisão paralela e verificação independente final aprovadas em 2026-10-25 |

## S1 — ADR-047, portas, schema de agregados, HLL e `metrics:expunge` (`task_000r40qa`)

### O que foi implementado

- **ADR-047 conferida**: status **Aceito** e indexada em `wiki/adr/README.md` (linha 87); nenhuma
  decisão nova foi criada.
- **Portas do Core** (interfaces pequenas, sem herança/trait):
  - `Core\Contracts\Metrics` — `increment(MetricBucket)` + `dailySeries`, `topRoutes`, `byModule`,
    `byStatus`, todas por `MetricsFilter` (período fechado, módulo e locale allowlist).
  - `Core\Contracts\MetricRegistry` + `Core\Contracts\Ports\MetricCard` — card contribuído pelo
    módulo declara `id`, `module`, `permission`, `label` (chave i18n), `icon` (Lucide), `position`,
    `value`, `url`; resolução determinística (`position`, depois `id`); `id` duplicado falha no
    registro. **Cards e dashboard não foram implementados** (S3).
  - `Core\Contracts\UniqueCounter` — HLL por dia no ValKey, entrada é `HMAC-SHA256` hex já calculado
    pelo chamador; implementação em memória para testes (`metrics.uniques.driver = array`, fixado em
    `phpunit.xml`).
- **DTOs públicos** (`Core\Metrics`): `MetricBucket` (valida classe de status e normaliza
  dia UTC/locale/módulo/rota; recusa path, query, e-mail), `MetricsPeriod` (inclusivo, ≤365 dias,
  início do dia UTC), `MetricsFilter`, e as linhas `DailyMetric`, `RouteMetric`, `ModuleMetric`,
  `StatusMetrics` (sempre as quatro classes).
- **Schema `core.core_metrics_daily`**: `id` (uuidv7) + `day`, `route_name`, `module`, `locale`,
  `status_class`, `requests`, `duration_sum_ms`, `duration_max_ms`, `bot_requests`; unique **exata**
  `(day, route_name, module, locale, status_class)`; índices `(day)`, `(module, day)`,
  `(route_name, day)`; `CHECK` de `status_class` e de contadores (`bot_requests <= requests`).
  Nenhuma coluna de IP/UA/URL/query/e-mail/cookie/sessão/fingerprint/hash. Migração idempotente;
  `down()` preserva em `backup_core_metrics_daily_<ts>` e renomeia os índices para o próximo `up()`.
- **`DatabaseMetrics`**: `INSERT ... ON CONFLICT DO UPDATE` atômico incrementando contadores e
  mantendo o máximo de duração; consultas com janela de `day` e ordenação explícita
  (`requests DESC` + desempate por rota/módulo). Falha é propagada (o *fail-open* é da borda, S2).
- **Retenção**: `metrics:expunge` (`--dry-run`, relatório, span `metrics.expunge` + `log_event`) e
  alvo `metrics-daily` no `RetentionRegistry` (padrão **400 dias**, `config/metrics.php`), com corte
  pelo `Clock` injetado. Aud/trilha intocada. HLL com TTL documentado de **90 dias**.
- **Testes (81 casos)**: schema/colunas/unique/índices/CHECKs, idempotência e `down()`→`backup_*`;
  upsert (idempotente, acumulação, estado do perdedor da corrida do unique, bots); série/período/
  filtros; ranking determinístico e limites; status; DTOs e normalizações; HLL em memória e ValKey
  (comandos/TTL via conexão falsa); registry (ordem, duplicado, metadados); retenção dry-run/real e
  comando; binds.

### Contrato (assinaturas)

```php
// Core\Contracts\Metrics
increment(MetricBucket $bucket): void;
dailySeries(MetricsFilter $filter): array;            // list<DailyMetric>, dia ASC
topRoutes(MetricsFilter $filter, int $limit = 10): array; // (requests DESC, route_name, module)
byModule(MetricsFilter $filter): array;               // (requests DESC, module)
byStatus(MetricsFilter $filter): StatusMetrics;

// Core\Contracts\UniqueCounter
add(string $day, string $anonymizedKey): void;        // Y-m-d + HMAC-SHA256 hex (64)
count(string $day): int;                              // estimativa PFCOUNT

// Core\Contracts\MetricRegistry
register(string $cardClass): void;                    // id duplicado → DuplicateMetricCard
cards(): array;                                       // array<string, MetricCard> (position, id)
get(string $id): ?MetricCard;  has(string $id): bool;
```

### Arquivos/superfícies para revisar

- `wiki/adr/ADR-047-metricas-privacy-first.md`, `wiki/adr/README.md` (índice).
- `src/core/Contracts/{Metrics,MetricRegistry,UniqueCounter}.php`, `Contracts/Ports/MetricCard.php`,
  `Contracts/README.md`, `Contracts/Ports/README.md`.
- `src/core/Metrics/{MetricBucket,MetricsPeriod,MetricsFilter,DailyMetric,RouteMetric,ModuleMetric,StatusMetrics}.php`
  e `Metrics/Exceptions/*`.
- `src/core/Internal/Metrics/**` (implementação, migração, comando, README, testes e fakes).
- `src/core/CoreServiceProvider.php` (binds, alvo de retenção, migração, comando).
- `config/metrics.php`, `config/_OTHER_CONFIGS.md`, `phpunit.xml`.
- Fakes: `src/core/Internal/Metrics/Test/Support/{Fake*Card,RecordingRedisConnection,RecordingRedisFactory}.php`.

### Pontos de revisão (D1–D14)

- **D1 — Simplicidade/PHP 8.4**: `declare(strict_types=1)`, `final`, `readonly`, `match`, enums
  fechados por constantes; sem trait/abstract/generic/`Result`; DI (Clock na borda/retention,
  Factory do Redis no contador); nenhum `new`/`resolve` em corpo de domínio.
- **D2 — Laravel 12**: migração idempotente (`hasTable`, `CREATE INDEX IF NOT EXISTS`), `down()`
  não-destrutivo; `env()` só em `config/metrics.php`; command registrado no provider; `config()` no
  código; nada de closure de rota.
- **D3 — Determinismo**: nenhum `now()`/`rand()` no domínio; dia vem do `MetricBucket` (UTC), corte
  de retenção do `Clock`; ordenação explícita em todas as queries; `UniqueInput` valida dia/hash.
- **D4 — Fronteiras**: alterações apenas em `core`; módulos consumirão `Core\Contracts\*` +
  `Core\Metrics\*` (público) — nada de `Internal` fora dos testes; `arch-check` verde.
- **D5 — Dependências**: nenhuma nova; usa `ext-redis`/ValKey já existentes; sem CDN/asset.
- **D6/D7 — CSS/HTML/a11y**: não se aplicam (sem view/rota nesta fatia).
- **D8 — i18n**: `MetricCard::label()` é **chave** i18n (`{token}::{seção}.{chave}`), nunca texto;
  nenhuma string visível nova (CLI em português como os comandos vizinhos).
- **D9 — Grep-friendly**: token `metrics`/`Metric` presente em contratos, DTOs, implementação,
  migração, config, comando e testes; nenhum módulo novo (name-check verde).
- **D10 — Dados**: PG 18, `date`/`text`/`bigint`/`integer`, PK uuidv7; unique exata e índices pedidos;
  `CHECK` fechado; sem `varchar`/float; migração aditiva e `down()` em `backup_*`.
- **D11 — Segurança/LGPD**: allowlist de dimensões; DTO recusa path/query/e-mail; HLL recebe só HMAC
  hex; nenhuma coluna/tabela com PII; retenção explícita; auditoria intocada.
- **D12 — Observabilidade**: `metrics.expunge` emite span + `log_event` com escalares (sem PII);
  ingestão/consultas não geram ruído por request (o span do relatório é do S4).
- **D13 — Testes/evidência**: 81 casos novos verdes; cobertura do diretório coberta pela suíte
  global; sem `sleep`/fork/retry — a corrida é provada pela unique + estado do perdedor.
- **D14 — Documentação**: README do diretório, README de contratos/portas, `config/_OTHER_CONFIGS.md`,
  esta ficha e a fila/inventário atualizados.

### Riscos e observações

- **HLL é estimativa** (ADR-047): únicos não servem para decisão financeira; `PFCOUNT` de chave
  ausente/expirada = 0. `EXPIRE ... NX` não estica o TTL a cada request.
- **TTL do HLL** é de 90 dias **a partir da primeira escrita do dia**; o agregado do dia continua no
  PG até 400 dias (dimensões diferentes, janelas diferentes — intencional).
- **Concorrência**: o upsert é uma única instrução e a unique é a verdade final; o teste cobre o
  estado em que a outra request já gravou. Não há teste com dois processos (determinismo > teatro).
- **`status_class`** dispara o heurístico `ui-column` do `migration-check` (contém `_class`): exceção
  justificada no arquivo — é classe **HTTP**, não CSS.
- **Sem `created_at`/`updated_at`**: a tabela segue exatamente as colunas da ADR-047 (o eixo temporal
  é `day`) e o caminho quente não paga mais uma escrita.
- **S2** deve: mapear 404/sem rota para `http.404`/`http.unknown` + `core`, respeitar
  `DNT`/`Sec-GPC` no único, e implementar o fail-open (log + seguir) em `terminate`.
- **S3** deve: filtrar por permissão e módulo ativo na **resolução** dos cards (o registry não
  conhece permissões) e isolar exceção de card.
- **S4** deve: usar `MetricsPeriod::lastDays`/`MetricsFilter` (≤365 dias), paginação determinística e
  supressão de célula pequena só no público (P12).
- `UniqueCounter` pode registrar dois comandos (`PFADD` + `EXPIRE`); se o custo de RTT do `terminate`
  aparecer na medição do S2, avaliar pipeline (decisão registrada, não silenciosa).

### Comandos úteis

```bash
make check                     # Pint/Prettier + PHPStan max + gates + suíte + cobertura
make check                     # 2× (sem flaky — ADR-001)
docker compose exec -T php-fpm ./vendor/bin/phpunit \
  --filter='MetricBucketTest|MetricsPeriodTest|MetricsSchemaTest|DatabaseMetricsTest|MetricRegistryTest|UniqueCounterTest|MetricsRetentionTest|MetricsBindingsTest'
php tools/migration-check && php tools/arch-check && bash tools/name-check.sh
```

## S2 — Ingestão no fim da resposta (`task_005st7fj`)

### O que foi implementado

- **Middleware terminable** `Core\Internal\Metrics\Http\Middleware\CollectRequestMetric`
  (alias **`metrics.collect`**, registrado em `CoreServiceProvider::boot()`): `handle` **só** grava
  `hrtime(true)` em `metrics.started_at`; `terminate(Request, Response)` monta o `MetricBucket` e
  escreve. Sem a marca (guards/manutenção curto-circuitaram) **não** coleta — request bloqueado
  antes do coletor não vira métrica.
- **Fiação** em `AreaRegistrar`: `metrics.collect` entra na pilha **só de portal e API** (nunca
  CMS), por último, depois de `security.headers`/`support.group`/`module.activation`/locale/
  manutenção — o locale já está resolvido e a request passou pelos guards.
- **404 real de path sem rota:** `Route::fallback(NotFoundController::class)` no portal e na API,
  dentro do grupo de middleware da área. Sem ele o kernel não executaria o `terminate` de área
  (não há rota) e o 404 de URL inexistente ficaria invisível. `RedirectLegacy` e
  `LocaleRoutes::matches` passaram a tratar **fallback como não-rota viva** (`isFallback`), então
  redirect legado e `hreflang`/sitemap continuam sem sombrear/mentir.
- **Dimensões e privacidade:**
  - rota = nome canônico (prefixo interno `default.` removido); **404 → `http.404`/`core`**
    (nunca path/slug); rota sem nome em outro status → `http.unknown`/`core`;
  - módulo = controller `Modules\…` via `ModuleToken` (snake_case canônico) → senão o **primeiro
    segmento do nome da rota que casa um módulo do manifesto** (`portal.home` → `home`,
    `dados-abertos.api.index` → `dados_abertos`) → senão `core`;
  - locale resolvido (`app()->getLocale()`), status via `MetricBucket::statusClassFor`, duração em
    ms de `hrtime`, `is_bot` por **classe** de UA (`BotClassifier`) — UA nunca persistido;
  - únicos = `HMAC-SHA256(versão|dia|IP|UA, pepper)` só no HLL; `DNT`/`Sec-GPC` desligam **só** o
    único (request continua agregado); sem pepper → HLL pulado + `metrics.ingest_failed`
    (`reason=pepper_missing`), sem pepper default inseguro.
- **Não coleta:** 1xx; `OPTIONS`/`HEAD` (probes de protocolo, decisão explícita e testada);
  `/up`; `/build/*`; CMS (fiação + checagem de host como defesa em profundidade).
- **Fail-open:** `increment` e HLL têm `try/catch` separados; a resposta não muda de status/corpo;
  `log_event('metrics.ingest_failed', {module, action, reason, exception})` — sem PII nem valores
  brutos. O `Metrics`/`UniqueCounter` continuam propagando no contrato (a borda decide).
- **Config:** `metrics.uniques.pepper` + `metrics.uniques.pepper_version` (env
  `METRICS_UNIQUE_PEPPER`/`_VERSION`), `.env.example`, `config/_OTHER_CONFIGS.md` e
  `phpunit.xml` com pepper determinístico (`test-pepper`).

### Contrato (assinaturas)

```php
final readonly class CollectRequestMetric
{
    public const string STARTED_AT = 'metrics.started_at';

    public function handle(Request $request, Closure $next): mixed;          // só marca hrtime
    public function terminate(Request $request, Response $response): void;    // coleta pós-resposta
}
```

### Arquivos/superfícies para revisar

- `src/core/Internal/Metrics/Http/Middleware/CollectRequestMetric.php`
- `src/core/Internal/Metrics/BotClassifier.php`
- `src/core/Internal/Http/Controllers/NotFoundController.php`
- `src/core/Internal/Routing/AreaRegistrar.php` (alias por área + fallback portal/API)
- `src/core/CoreServiceProvider.php` (alias `metrics.collect`)
- `src/core/Localization/LocaleRoutes.php` e `src/services/Seo/Http/Middleware/RedirectLegacy.php`
  (fallback não é rota viva)
- `config/metrics.php`, `config/_OTHER_CONFIGS.md`, `.env.example`, `phpunit.xml`
- `src/core/Internal/Metrics/README.md`
- `src/core/Internal/Metrics/Test/{MetricsIngestionTest.php,Support/ThrowingMetrics.php,Support/ThrowingUniqueCounter.php}`

### Pontos de revisão (D1–D14)

- **D1/D2:** `declare(strict_types=1)`, `final readonly`, DI no construtor, sem trait/abstract/
  `Result`; `env()` só no config; nenhuma closure de rota no app (fallback usa controller
  invokable).
- **D3:** dia do `Clock` injetado; duração de `hrtime` (não `microtime`); nenhuma chamada de rede
  fora do `increment`/HLL; testes com `FixedClock`.
- **D4:** só Core + o ajuste mínimo no middleware do service SEO (sem importar módulo).
- **D5:** nenhuma dependência nova.
- **D6–D8:** sem view/CSS/string visível nova; i18n não se aplica (tokens técnicos).
- **D9:** token `metrics`/`Metric` no middleware, DTO, config, README e testes.
- **D10:** nenhuma coluna/tabela nova; o schema de S1 já cobre as dimensões.
- **D11:** sem IP/UA/URL/query/hash no PG; HMAC só no HLL; DNT/GPC; fail-open; log sem PII.
- **D12:** `metrics.ingest_failed` só em falha (sem ruído por request); sem span por request
  (ADR-047 §6).
- **D13:** `MetricsIngestionTest` (17 casos) cobre agregado, soma, 404 nomeado e path sem rota,
  locale, query/IP/UA fora do banco, bot, DNT/GPC, fail-open de banco/ValKey, pepper ausente, CMS
  fora, API dentro, OPTIONS/HEAD, health/build, `terminate`+`hrtime` (e `handle` sem escrita) e
  `http.unknown`.
- **D14:** README do diretório, `config/_OTHER_CONFIGS.md`, `.env.example`, esta ficha, fila e
  inventário.

### Custo observado

- Medição no container dev (2026-10-25): upsert `ON CONFLICT` ≈ **0,79 ms**/request (200 iterações,
  transação revertida) e HMAC + HLL em memória ≈ **1,6 µs**; com ValKey real somam-se
  `PFADD`+`EXPIRE` (2 RTTs) já previstos em S1.
- A coleta é **pós-resposta** (`terminate`): não entra no TTFB; nenhum passo novo no `make check`
  (driver `array`, pepper fixo).

### Riscos e observações

- **Fallback de área**: portal/API passam a resolver qualquer GET sem rota pelo
  `NotFoundController` (404 real). Os dois consumidores de `getRoutes()->match` foram corrigidos
  para ignorar `isFallback`; qualquer código futuro que use `match` para "rota existe" precisa da
  mesma checagem (registrado aqui).
- **Método diferente de GET em path inexistente** (P4.FIX corrigiu a descrição): o fallback da área
  é registrado só em `GET`, então `POST`/`PUT` respondem **405** (`Allow: GET, HEAD`) e `OPTIONS`
  responde **200** com `Allow` — **não** 404, e nada é medido (nenhuma rota viva casou). Semântica
  aceita e coberta em `MetricsIngestionTest`, documentada em `src/core/Internal/Metrics/README.md`;
  um catch-all preservaria 404 mas quebraria o 405 de rota viva.
- **Sem pepper em produção** = sem únicos (agregados continuam). É uma falha **visível**
  (`metrics.ingest_failed` a cada request); configurar `METRICS_UNIQUE_PEPPER` no deploy.
- **Duração** inclui o restante da pilha depois do coletor? Não: a marca é gravada quando
  `metrics.collect` roda (depois de guards/locale/manutenção), então mede o controller/render —
  não o boot nem os middlewares anteriores.
- Testes de request em toda a suíte agora gravam 1 upsert por resposta; `RefreshDatabase` isola e o
  custo observado é sub-milissegundo.

### Comandos úteis

```bash
make check                     # Pint/Prettier + PHPStan max + gates + suíte + cobertura (2×)
docker compose exec -T php-fpm ./vendor/bin/phpunit --filter='MetricsIngestionTest'
docker compose exec -T php-fpm ./vendor/bin/phpunit \
  --filter='SeoRedirectTest|LocaleUrlInvariantTest|SeoTagsTest|SeoGraphTest'
```

## S3 — Cards do dashboard e catálogo por módulo (`task_01e379r3`)

### O que foi implementado

- **Resolução no Core** (`Core\Area\Cms\Logic\Actions\ResolveDashboardCards`), injetada no
  `CmsDashboardController`: parte de `MetricRegistry::cards()` e aplica, **fora do registro**,
  `Gate::allows($card->permission())` (deny-by-default) e
  `ModuleActivation::isActive($card->module(), default do manifesto)`. A ordem do registry
  (`position`, depois `id`) é preservada — o dashboard não reordena.
- **Contrato de retorno explícito** (`Core\Area\Cms\Logic\DTOs\ResolvedMetricCard`): o card
  declarado + `available`, `value` (`int|string|null`) e `url` já validada. Card que lança **não
  entra como invisível**: vira `available = false` (valor/URL nulos), o dashboard segue 200 e o log
  técnico `metrics.card_failed` leva `module`/`action`/`card`/classe da exceção (sem PII).
- **Cache 60 s por card** (`metrics.card.v1.{id}`): só o **valor** é cacheado. Permissão e ativação
  são reavaliadas a cada request — o mesmo valor cacheado não aparece para quem perdeu a permissão.
- **URL segura**: rota nomeada do CMS (`Route::has` → `route()`) ou path interno (`/...`); esquema
  externo, protocol-relative (`//host`) e `javascript:` são descartados.
- **Cards Core** registrados no `CoreServiceProvider::register()` (após os binds de
  `Metrics`/`Telemetry`), usando **tokens já declarados**:

  | Card | Permissão | Valor / janela | Destino |
  |---|---|---|---|
  | `core.active_users` | `acesso.ver` | `COUNT` de usuários não desativados (soft delete fora) | `acesso.cms.usuarios.index` |
  | `core.active_modules` | `painel.ver` | nº de módulos ativos (`activeModules`) | `painel.cms.modules` |
  | `core.audit_events_24h` | `painel.ver` | `COUNT` da trilha em `[-24 h, agora]` (formato com offset do `Clock`) | `painel.cms.audit` |
  | `core.portal_requests_7d` | `painel.ver` | soma de `Metrics::dailySeries` nos **7 dias UTC incluindo hoje** (bots incluídos) | `null` (relatório é da S4) |

- **Cards de módulo** (cada dono registra a própria classe no `register()`; um `COUNT` por card,
  sem N+1 e sem PII):
  - Notícias: `noticias.published` e `noticias.drafts` (`status`, posições 100/110, `noticias.ver`).
  - Dados Abertos: `dados_abertos.datasets` (datasets com `status = publicado`, 200,
    `dados_abertos.ver`).
  - LGPD: `lgpd.open_dsars` (`completed_at IS NULL`, 300, `lgpd.ver`).
- **Dashboard** (`src/layout/Cms/dashboard.blade.php`): mantém usuário/versão e acrescenta o grid
  de cards com `<x-cms::card>` (título `h2` — o `h1` é do shell), ícone Lucide, valor escapado e
  `data-metric-card`/`data-metric-value` para o contrato de teste. Card com destino vira `<a href>`
  com nome acessível (valor + `sr-only` do rótulo); card indisponível mostra “Indisponível”. Sem
  cards visíveis, a tela continua sendo o dashboard atual (degradação graciosa). Nenhum gráfico,
  tabela ou JS novo.
- **i18n**: labels são chaves (`core::cards.*`, `noticias::cards.*`, `dados_abertos::cards.*`,
  `lgpd::cards.*`) com os 4 locales; `core::cards.unavailable` cobre a falha. O namespace `core` é
  carregado no `CoreServiceProvider::boot()` (o caminho global do `useLangPath` continua igual).
- **Ajustes de contrato existente**: `MetricRegistryTest` usa registro próprio (o provider agora
  contribui cards de produção) e `MetricsBindingsTest` fixa a lista completa de cards registrados.

### Contrato (assinaturas)

```php
// Core\Area\Cms\Logic\Actions\ResolveDashboardCards
public const int CACHE_TTL = 60;
/** @return list<ResolvedMetricCard> */
public function execute(): array;

// Core\Area\Cms\Logic\DTOs\ResolvedMetricCard
__construct(MetricCard $card, bool $available, int|string|null $value, ?string $url);
public function id(): string;            // delega a `$card->id()`
```

### Arquivos/superfícies para revisar

- `src/core/Area/Cms/Logic/Actions/ResolveDashboardCards.php`
- `src/core/Area/Cms/Logic/DTOs/ResolvedMetricCard.php`
- `src/core/Area/Cms/Logic/Cards/{ActiveUsersCard,ActiveModulesCard,AuditEventsCard,PortalRequestsCard}.php`
- `src/core/Area/Cms/Logic/Controllers/CmsDashboardController.php`
- `src/core/CoreServiceProvider.php` (registro dos cards Core + namespace de tradução `core`)
- `src/core/Lang/{pt_BR,en,es,fr}/cards.php`
- `src/core/Area/Cms/Test/Support/{ThrowingMetricCard,CountingMetricCard}.php`
- `src/layout/Cms/dashboard.blade.php`
- `src/modules/Publicacoes/Noticias/{ModuleProvider.php,Logic/Cards/*,Lang/*/cards.php}`
- `src/modules/Portal/DadosAbertos/{ModuleProvider.php,Logic/Cards/*,Lang/*/cards.php}`
- `src/modules/Portal/Lgpd/{ModuleProvider.php,Logic/Cards/*,Lang/*/cards.php}`
- `src/core/Internal/Metrics/{README.md,Test/MetricRegistryTest.php,Test/MetricsBindingsTest.php}`,
  `src/core/Contracts/Ports/README.md`
- `tests/Feature/DashboardCardsTest.php`,
  `src/modules/Publicacoes/Noticias/Test/NoticiasMetricCardsTest.php`,
  `src/modules/Portal/DadosAbertos/Test/DadosAbertosMetricCardTest.php`,
  `src/modules/Portal/Lgpd/Test/LgpdMetricCardTest.php`

### Pontos de revisão (D1–D14)

- **D1 — Simplicidade/PHP 8.4**: `final readonly`, DI no construtor, DTO único, sem trait/abstract/
  generic; cards são classes pequenas de uma responsabilidade; nenhuma abstração nova além da Action.
- **D2 — Laravel 12**: controller invokável com injeção de Action; sem closure de rota; sem
  `$request->all()`; `Gate`/`Repository` (contratos) injetados; `env()` não aparece no código novo.
- **D3 — Determinismo**: janela de 24 h/7 dias a partir do `Clock` injetado; `COUNT` sem
  `now()` no SQL; ordenação vem do registry; testes usam `FixedClock` onde a janela importa.
- **D4 — Fronteiras**: Core não importa módulo; cada módulo só registra a própria classe no Core e
  importa `Core\Contracts\Ports\MetricCard`; `arch-check` verde.
- **D5 — Dependências**: nenhuma nova; ícones/vendor já existentes.
- **D6/D7 — CSS/HTML/a11y**: grid com utilitários Tailwind já usados; `<section>` do
  `<x-cms::card>` + `h2` (sem pular nível a partir do `h1` do shell); link só quando há destino,
  com texto acessível; “Indisponível” é texto (não só cor); nenhum gráfico/tabela/JS.
- **D8 — i18n**: todos os labels são chaves com 4 locales (teste dedicado), nenhuma string visível
  hardcoded na view; `unavailable` traduzido.
- **D9 — Grep-friendly**: tokens `metric`/`Metric`/`cards` nos arquivos, README e testes; nenhum
  nome genérico novo.
- **D10 — Dados**: nenhuma migração/tabela nova; consultas por `COUNT`/índice existente; nenhuma
  coluna de PII lida.
- **D11 — Segurança/LGPD**: permissão deny-by-default; módulo inativo invisível; valores são
  agregados (sem nome/e-mail/protocolo); log de falha sem PII; URL não aceita esquema externo.
- **D12 — Observabilidade**: `metrics.card_failed` só em falha (sem ruído por request), com
  escalares; sem span por render de dashboard (ADR-047 §6).
- **D13 — Testes/evidência**: `DashboardCardsTest` (10 casos) cobre ordem, permissão, módulo
  desativado, exceção isolada + log, cache de dois requests + permissão não cacheada, dashboard sem
  permissão (sem vazar número), render com valores reais, i18n dos 4 locales e as janelas de 24 h/7
  dias; os 3 testes de módulo cobrem registro, `COUNT` por status e soft delete.
- **D14 — Documentação**: README de métricas/ports, esta ficha, fila e inventário.

### Riscos e observações

- `ActiveModulesCard` resolve `ModuleDiscoveryService` no `register()` do Core (antes de o provider
  de descoberta ligar o singleton): a instância do card é própria, mas lê o mesmo manifesto/cache
  de ativação — sem estado divergente. Se o registro mudar de ordem, manter o card depois do bind de
  `Telemetry`/`Metrics`.
- `painel.ver` só resolve para a equipe de suporte (ADR-033), então os cards de estado do sistema
  são naturalmente da equipe; `core.active_users` usa `acesso.ver` e aparece para quem vê Acesso.
- A janela de 7 dias inclui bots (`requests` do agregado); únicos (HLL) **não** entram no card.
- O cache é por card e TTL de 60 s: não há invalidação por evento de conteúdo nesta fatia (o
  requisito permite TTL); a S4 deve reutilizar o mesmo `MetricsFilter`/cache por período.
- O card de requisições (`painel.ver`) **não** aponta para `/estatisticas` (`estatisticas.ver`):
  um link fixo daria 403 a parte dos perfis. Mantido `null` de propósito (P4.FIX), com teste.

### Comandos úteis

```bash
docker compose exec -T php-fpm ./vendor/bin/phpunit \
  --filter='DashboardCardsTest|NoticiasMetricCardsTest|DadosAbertosMetricCardTest|LgpdMetricCardTest|MetricRegistryTest|MetricsBindingsTest'
docker compose exec -T php-fpm ./vendor/bin/phpstan analyse --memory-limit=1G --no-progress
docker compose exec -T php-fpm ./vendor/bin/pint --test
docker compose exec -T php-fpm php tools/arch-check && docker compose exec -T php-fpm php tools/migration-check
```

## S4 — módulo `Infraestrutura/Estatisticas` e as quatro telas (`task_01426ie7`)

### O que foi implementado

- **Módulo** `src/modules/Infraestrutura/Estatisticas` (token `estatisticas`, grupo `Infraestrutura`,
  posição **70** entre Acesso/Configurações, ícone `lucide-bar-chart`, `permissions => [VIEW]`,
  `route => 'estatisticas.cms.index'`, 4 subitens de navegação). Rotas **sem closure** e nomes
  canônicos `estatisticas.cms.{index,paginas,modulos,conteudo}` em `/estatisticas[/paginas|/modulos|/conteudo]`;
  `auth` vem da área (ADR-037) e cada controller chama
  `Permission::authorize('Estatisticas', Permission::VIEW)` **antes** de qualquer consulta.
- **Filtros fechados** (`Logic/Support/EstatisticasFilterParser` → `Logic/DTOs/EstatisticasFilter`):
  presets `7/30/90` via `MetricsPeriod::lastDays(..., Clock)` e `custom` com `de`/`ate` `Y-m-d`;
  `locale`/`modulo` passam por `Core\Metrics\MetricsFilter` (allowlist de locale e token de módulo).
  A regra final (`from <= to`, ≤365 dias, dia UTC) é do `MetricsPeriod` do Core — o módulo não
  reimplementa. **Entrada inválida** (preset, data, ordem invertida, janela >365, locale/módulo,
  tentativa de injeção) nunca monta SQL: o `Validator` roda sobre os **cinco campos conhecidos** e o
  parser cai num **fallback determinístico** — últimos 7 dias, sem módulo/locale — com a mensagem
  i18n `estatisticas::cms.filters_invalid` no topo da tela (HTTP 200, sem erro de formulário em
  `GET` de leitura).
- **Telas** (todas com `<x-layout::cms>`, só `<main>`; `<h1>` é do shell):
  - **geral** (`estatisticas-index`): cards resolvidos por `ResolveDashboardCards`, série diária em
    tabela semântica (`caption`, `th scope="col|row"`, dia UTC), top 10 rotas, top 10 módulos e
    distribuição por classe de status (4 linhas canônicas, participação inteira);
  - **páginas** (`estatisticas-paginas`): ranking de **rotas nomeadas** (requisições, duração média,
    `4xx / 5xx`, bots), teto de **100 rotas** e **20/página**, ordenação explícita por link
    (`requests`/`duration`/`errors`/`route`) com `aria-sort`; paginação nativa preservando
    filtro/ordem; página fora da faixa fixada na última; nunca path/query;
  - **módulos** (`estatisticas-modulos`): requisições, participação (%) e tendência percentual
    contra a janela anterior de mesmo tamanho (sem base = “sem base anterior”);
  - **conteúdo** (`estatisticas-conteudo`): contadores dos cards de conteúdo via
    `ResolveDashboardCards` (permissão do módulo dono + ativação), sem os cards de estado do Core;
    sem card visível = estado vazio (nenhum número de módulo que o perfil não vê).
  Nenhum PII: a dimensão exibida é nome de rota, token de módulo, classe de status, contadores,
  duração e bots. Sem gráfico obrigatório — a tabela é a leitura canônica.
- **Cache** (`Logic/Support/EstatisticasCache`): TTL **60 s**, chave versionada
  `estatisticas.report.v1.{sha256(report, preset, from, to, module, locale[, sort, dir, page])}`;
  invalidação só por TTL; **permissão não é cacheada** (o `authorize` roda por request). O cache
  guarda apenas arrays de escalares (política `serializable_classes => false`). Sem ETag (CMS
  privado).
- **i18n**: namespace `estatisticas` com 4 seções (`menu`, `cms`, `validation`, `modules`) nos 4
  locales; rótulos de módulo traduzidos (`estatisticas::modules.*`, fallback = token para módulo
  novo), status traduzidos (`status_2xx`…), navegação interna com `route()`.
- **Observabilidade**: cada Action de relatório injeta `Telemetry` e emite span + `log_event` com o
  mesmo nome, apenas no **cálculo** (cache miss): `estatisticas.report`, `estatisticas.pages`,
  `estatisticas.modules`, `estatisticas.content`; contexto só com escalares (sem PII/path).

### Contrato (assinaturas)

```php
// EstatisticasFilter (DTO)
public function __construct(MetricsFilter $metrics, string $preset, ?string $notice = null);
public function fromDay(): string; public function toDay(): string; public function days(): int;
public function module(): ?string; public function locale(): ?string; public function isCustom(): bool;
/** @return array<string, string|int> */
public function query(array $overrides = []): array;

// EstatisticasFilterParser
public const array PRESETS = ['7', '30', '90', 'custom'];
public function parse(Request $request): EstatisticasFilter;   // nunca lança: fallback com notice

// Ações de relatório (TTL 60 s, chave versionada)
/** @return array{series: list<...>, routes: list<...>, modules: list<...>, status: list<...>, totals: array{...}} */
public function execute(EstatisticasFilter $filter): array;                       // Overview
public function execute(EstatisticasFilter $filter, string $sort, string $direction, int $page): LengthAwarePaginator;
/** @return array{total: int, rows: list<array{module: string, requests: int, share: int, trend: int|null}>} */
public function execute(EstatisticasFilter $filter): array;                       // ModuleTrends
/** @return list<ResolvedMetricCard> */
public function execute(): array;                                                 // ContentCards
```

### Arquivos/superfícies para revisar

- `src/modules/Infraestrutura/Estatisticas/ModuleProvider.php`, `Routes/cms.php`, `MODULE.md`.
- `src/modules/Infraestrutura/Estatisticas/Logic/{Actions,Controllers,DTOs,Support}/**`.
- `src/modules/Infraestrutura/Estatisticas/Layout/Views/cms/**` (4 telas + partial de filtro).
- `src/modules/Infraestrutura/Estatisticas/Lang/{pt_BR,en,es,fr}/{menu,cms,validation,modules}.php`.
- `src/modules/Infraestrutura/Estatisticas/Test/{EstatisticasCmsTest,EstatisticasI18nTest}.php`,
  `tests/Feature/EstatisticasContentTest.php`.
- Contratos consumidos (sem alteração): `src/core/Contracts/{Metrics,Clock,Telemetry}.php`,
  `src/core/Metrics/*`, `src/core/Area/Cms/Logic/Actions/ResolveDashboardCards.php`.

### Pontos de revisão (D1–D14)

- **D1 — Simplicidade/PHP 8.4**: `declare(strict_types=1)`, `final readonly`, `match`, DTO único de
  filtro; sem trait/abstract/generic/`Result`; DI no construtor (Clock, cache, Telemetry, validator);
  nenhum `new` de serviço em corpo de domínio.
- **D2 — Laravel 12**: 4 rotas `GET` sem closure, controller invokable, `Permission::authorize` no
  controller; `env()` não aparece; filtro em `GET` via `Validator` (não recria matriz de permissão).
- **D3 — Determinismo**: período/fallback derivados do `Clock` injetado; `MetricsPeriod::lastDays`;
  ordenação total com desempate `route_name` + `module`; página fora da faixa fixada; `usort` com
  comparador total (sem depender de estabilidade); testes com `FixedClock`.
- **D4 — Fronteiras**: o módulo importa só `Core\*` (`Permissions`, `Metrics*`, `MetricsFilter`,
  `ResolveDashboardCards`, `Locales`); teste de conteúdo com models de outros módulos ficou em
  `tests/Feature` (um módulo nunca importa outro); `arch-check` verde.
- **D5 — Dependências**: nenhuma nova; ícones Lucide já vendorizados (`bar-chart`, `chart-line`,
  `table`, `trending-up`, `layers`).
- **D6/D7 — CSS/HTML/a11y**: só utilitários/componentes existentes; `caption` + `th scope` em todas
  as tabelas; `aria-sort` nas colunas ordenáveis; estado vazio textual; status/tendência em texto
  (não só cor); filtro com `label`/`help` dos componentes da casa; `h1` único (shell).
- **D8 — i18n**: 4 locales com paridade (`menu`, `cms`, `validation`, `modules`); nenhuma string
  visível fixa; rótulos de módulo/status traduzidos; links internos por `route()`; teste HTTP em 4
  locales sem chave crua (`EstatisticasI18nTest`).
- **D9 — Grep-friendly**: token `estatisticas`/`Estatisticas` em ModuleProvider, Actions,
  Controllers, DTO/Support, views, testes e chaves; `name-check` verde.
- **D10 — Dados**: nenhuma migração/tabela nova; leitura apenas de `core.core_metrics_daily` com
  binding (`day`/`module`/`locale`); nenhuma coluna de PII lida.
- **D11 — Segurança/LGPD**: `estatisticas.ver` deny-by-default antes da consulta; filtros fechados
  (allowlist); tentativa de injeção cai no fallback e não muda o SQL; cards de conteúdo mantêm a
  permissão do módulo dono; nenhum path/query/IP/UA exibido ou cacheado; cache não guarda permissão.
- **D12 — Observabilidade**: span + `log_event` com mesmo nome por relatório calculado; contexto
  apenas escalares, sem PII; leitura de painel continua na trilha (`audit.access`), métrica não é
  auditoria.
- **D13 — Testes/evidência**: `EstatisticasCmsTest` **19 casos** (403/404 de módulo inativo, 4
  telas, série/top/status, presets com Clock, custom inclusivo, 7 entradas inválidas + injeção,
  locale/módulo, paginação/ordenação/`aria-sort`/teto de 100, participação/tendência, cache de dois
  requests + chave por filtro, shell único, únicos do dia (HLL) e parâmetros em array),
  `EstatisticasI18nTest` **3** e `tests/Feature/EstatisticasContentTest` **1**; sem `sleep`;
  `ModuleI18nTest`/`IconTest`/`LayoutContractTest`/`DocsReferencesTest`/`ModulesTelemetryTest`
  verdes. (O número antigo — “13” — era o estado anterior à S5; o fechamento real está em
  `## P4.FIX`.)
- **D14 — Documentação**: `MODULE.md` (rotas, filtros, cache, eventos, a11y), esta ficha, fila e
  inventário. P4.FIX atualizou `wiki/implementation-phases/{P4-estatisticas,README,INVENTARIO}.md`
  (P4 passou a `em revisão`, antes ainda `planejado`).

### Riscos e observações

- **Fallback × 422**: o contrato da fatia aceitava “mensagem i18n **ou** 422”; escolhemos o fallback
  (200 + aviso) para um `GET` de leitura — um `FormRequest` devolveria 302/422 **antes** da
  autorização, invertendo a ordem “autorização → validação”. O parser valida depois do `authorize`.
- **Teto de 100 rotas**: a ordenação por duração/erros é aplicada sobre as 100 rotas **mais
  requisitadas** do período (limite do contrato `Metrics::topRoutes`). Ordenar por outra coluna não
  alcança rotas fora desse teto — comportamento documentado no `caption` (“até 100 rotas”).
- **Dia em ISO (`Y-m-d`)**: a série diária exibe o dia UTC sem `LocalizedDate` (é dado de relatório,
  não data editorial); o locale não muda o significado do bucket. `period_summary` deixa claro que a
  janela é UTC.
- **Participação/tendência inteiras**: arredondamento explícito para inteiro (sem separador
  decimal dependente de locale); tendência sem base anterior é `null` (não 0%).
- **Rótulos de módulo** vêm de `estatisticas::modules.*` (mapa local, 4 locales) com fallback para o
  token: um módulo novo aparece com o token até ganhar rótulo — sem importar `ModuleDiscovery`.
- **Cache por página**: a chave inclui ordenação/página; cada combinação paga uma agregação no
  primeiro acesso após o TTL (as 100 rotas são lidas uma vez por combinação, não por item).
- **Conteúdo sem filtro**: os cards de conteúdo são contadores **atuais** (não janelados), por isso
  a tela não oferece filtro de período — evita um filtro que não muda nada.

### Comandos úteis

```bash
docker compose exec -T php-fpm ./vendor/bin/phpunit \
  --filter='EstatisticasCmsTest|EstatisticasI18nTest|EstatisticasContentTest'
docker compose exec -T php-fpm ./vendor/bin/phpunit \
  --filter='ModuleI18nTest|DocsReferencesTest|ModulesTelemetryTest|LayoutContractTest|IconTest'
docker compose exec -T php-fpm ./vendor/bin/phpstan analyse --memory-limit=1G --no-progress
docker compose exec -T php-fpm php tools/arch-check && docker compose exec -T php-fpm bash tools/name-check.sh
php artisan core:cache-modules   # o manifesto precisa incluir o módulo novo
```

### Evidência (2026-10-25)

- **Testes:** 18 casos verdes (`EstatisticasCmsTest` 14 · `EstatisticasI18nTest` 3 ·
  `tests/Feature/EstatisticasContentTest` 1), 1.330 asserções; `ModuleI18nTest`, `IconTest`,
  `LayoutContractTest`, `BladeStaticCallsTest`, `DocsReferencesTest` e `ModulesTelemetryTest`
  verdes junto.
- **Cobertura do módulo** (produção, sem `Test/`): **812/818 linhas = 99,3%** — as 6 restantes são o
  `throw` defensivo de `EstatisticasFilterParser::date()`, inalcançável pela validação.
- **Gates:** PHPStan max `0`; Pint `PASS`; `arch-check`, `migration-check`, `name-check`
  (`--grep estatisticas`) e `docs-check` OK.
- **`make check` 2×** verde no estado final (24 OK / 0 FAIL; cobertura global ≥90, a11y OK) e
  **`make check E2E=1`** verde (e2e 135 s + perf 62 s, 0 FAIL) — UI nova exercitada pelo axe/shell
  existentes; a jornada métrica→relatório fica no S5 (P4 §3/S5).
- Manifesto recacheado (`core:cache-modules`) — 11 módulos; o clone limpo descobre o módulo pela
  convenção (sem manifesto).

## S5 — export CSV, retenção, LGPD e E2E (`task_015t3ggc`)

### O que foi implementado

- **Export CSV** (`GET /estatisticas/exportar`, nome `estatisticas.cms.export`, permissão
  `estatisticas.ver`): `EstatisticasExportController` (autoriza → `EstatisticasFilterParser` →
  `EstatisticasCsvExport` → `response()->streamDownload`). Allowlist `relatorio` =
  `paginas|modulos|serie` (valor desconhecido cai em `paginas`) e **a mesma** validação de filtros
  do S4 (presets/custom, locale e módulo allowlist; entrada inválida → fallback de 7 dias, sem erro
  de formulário em `GET`). As telas de período linkam o próprio relatório (a visão geral oferece os
  três, `data-export-report` no link).
- **Streaming e cabeçalhos**: `Content-Type: text/csv; charset=UTF-8`,
  `Content-Disposition: attachment; filename="estatisticas-{relatorio}-{de}-{ate}.csv"` — o nome usa
  só relatório + datas do período, nunca a query string. Corpo gerado por
  `EstatisticasCsv::lines()` (`Generator`) e escrito linha a linha com `fputcsv` (sem montar o
  arquivo em memória).
- **CSV injection:** `EstatisticasCsv::cell()` neutraliza **toda célula de texto** iniciada por
  `=`, `+`, `-` ou `@` com o prefixo `'`; número (inclusive negativo, ex.: tendência) é dado e
  preserva o valor — a mesma regra do export de Dados Abertos (ADR-051). Coberto por teste com as
  quatro formas.
- **Sem PII/path/query:** as linhas carregam nome de rota, token de módulo, dia UTC e contadores;
  nenhum path, slug, parâmetro, IP, user-agent ou locale por linha (o locale só escolhe o cabeçalho
  traduzido). O `paginas` reusa `EstatisticasRouteRanking::rows()` (extraído de `build()`), então a
  tela paginada e o arquivo nunca divergem.
- **Ação sensível auditada:** o nome `estatisticas.cms.export` casa `*export*` em
  `config('audit.sensitive')`; o middleware `audit.access` da área CMS grava
  `AuditEntry::access('estatisticas', 'estatisticas.view', 'estatisticas.cms.export')` — provado por
  `EstatisticasExportTest` (sem código de auditoria no controller).
- **Retenção agendada:** `Schedule::command('metrics:expunge')->daily()` em `routes/console.php`,
  além do `retention:expunge` mensal (que já inclui o alvo `metrics-daily`); `audit:expunge` mensal,
  `queue:prune-failed` e `media:backup` intactos. `--dry-run`/relatório e “não toca a auditoria”
  provados em `MetricsRetentionTest` (novo caso: `metrics:expunge` real com linha de auditoria
  presente → contagem inalterada).
- **LGPD/transparência:** seção “Medição e cookies” da política (`Portal/Lgpd`) ganhou três
  parágrafos factuais nos **4 locales**: métricas agregadas (rota nomeada, idioma, classe de status,
  duração, rótulo de robô), **sem** IP/user-agent/cookie de analytics/parâmetros de URL/identificador
  entre dias; retenção de agregados **400 dias** e HLL **90 dias**; GA de terceiros **só opt-in**
  (o texto de terceiros continua condicional a `lgpd.analytics.third_party`). A chave antiga
  genérica (`analytics_privacy_first`) foi removida (substituída pelo texto verificável) —
  `ModuleI18nTest` cobre a paridade e `LgpdTest` cobra o texto renderizado nos 4 locales.
- **E2E nova** (`histories/24-estatisticas.md` + `tests/24-estatisticas.spec.ts`, projeto
  `cms-desktop`): visita ao portal → abre a visão geral e o ranking, confere `lgpd.privacy` com
  contagem ≥ 1, filtra o período pela UI (`periodo=30` + resumo) e um período personalizado no dia
  UTC corrente; baixa o CSV pelo link da tela e valida nome/cabeçalho/linha do relatório; axe A/AA
  na visão geral e no ranking. Sem mutação global: `fullyParallel: false` mantido e nenhum projeto
  serial novo.
- **Escolha registrada (E2E × feature):** “módulo desativado → histórico permanece, não cresce” e
  “retenção remove” ficaram em **PHPUnit** (`EstatisticasCmsTest` +
  `MetricsRetentionTest`/`RetentionScheduleTest`) — a `23-suporte-painel` já cobre ativação em
  runtime no E2E, e envelhecer/expurgar agregados numa jornada de UI exigiria atalho de banco.
  O E2E fica com o caminho métrica → relatório → export.
- **Seed demo:** o perfil `administrador` deriva as permissões do manifesto
  (`CmsUserSeeder::declaredPermissions()`), então `php artisan db:seed` (pré-condição já documentada
  no README do E2E) passou a conceder `estatisticas.ver`; nenhuma permissão é hardcoded.

### Contrato (assinaturas)

```php
// EstatisticasCsvExport
public const string DEFAULT_REPORT = 'paginas';
public const array REPORTS = ['paginas', 'modulos', 'serie'];
public static function resolve(string $report): string;
/** @return array{report: string, filename: string, columns: list<string>, rows: list<list<string|int>>} */
public function execute(EstatisticasFilter $filter, string $report): array;

// EstatisticasCsv
public const string MIME = 'text/csv; charset=UTF-8';
/** @param list<string> $columns @param list<list<string|int>> $rows @return Generator<int, list<string>> */
public static function lines(array $columns, array $rows): Generator;
public static function cell(string|int $value): string;   // prefixo ' em texto = + - @

// EstatisticasRouteRanking (extraído do build)
/** @return list<array{route: string, module: string, requests: int, bots: int, average_duration_ms: int, requests_4xx: int, requests_5xx: int, errors: int}> */
public function rows(EstatisticasFilter $filter, string $sort, string $direction): array;
```

### Arquivos/superfícies para revisar

- `src/modules/Infraestrutura/Estatisticas/Logic/Actions/EstatisticasCsvExport.php`
- `src/modules/Infraestrutura/Estatisticas/Logic/Actions/EstatisticasRouteRanking.php` (`rows()`)
- `src/modules/Infraestrutura/Estatisticas/Logic/Controllers/EstatisticasExportController.php`
- `src/modules/Infraestrutura/Estatisticas/Logic/Support/EstatisticasCsv.php`
- `src/modules/Infraestrutura/Estatisticas/Routes/cms.php`,
  `Layout/Views/cms/partials/estatisticas-filter.blade.php` (links de export),
  `Lang/{pt_BR,en,es,fr}/cms.php` (`export_*`), `MODULE.md`
- `src/modules/Infraestrutura/Estatisticas/Test/EstatisticasExportTest.php`
- `routes/console.php` (agendamento diário), `src/core/Internal/Metrics/README.md`
- `src/core/Internal/Metrics/Test/MetricsRetentionTest.php` (auditoria intocada),
  `tests/Feature/RetentionScheduleTest.php`
- `src/modules/Portal/Lgpd/Lang/{pt_BR,en,es,fr}/privacy.php`,
  `src/modules/Portal/Lgpd/Layout/Views/privacy.blade.php`,
  `src/modules/Portal/Lgpd/Test/LgpdTest.php`
- `src/tests-e2e/{playwright.config.ts,histories/24-estatisticas.md,tests/24-estatisticas.spec.ts,README.md}`,
  `src/tests-e2e/tests/17-cms-configuracoes.spec.ts` (locator `exact`), `wiki/specs/04-acessibilidade-ux.md`,
  `AGENTS.md` (contagem de histórias)

### Pontos de revisão (D1–D14)

- **D1 — Simplicidade/PHP 8.4**: Action `final readonly` com DI (Actions de relatório + Telemetry);
  `match` para o relatório; `Generator` tipado; sem trait/abstract/generic/`Result`; nenhum
  `new`/`resolve` no corpo.
- **D2 — Laravel 12**: rota `GET` sem closure; `streamDownload` nativo; `Schedule::command` no
  `routes/console.php` (não há `Kernel`); `env()` não aparece.
- **D3 — Determinismo**: filtro/período do `Clock` injetado; ordem do CSV = ordem da tela
  (`rows()` com comparador total); nome do arquivo só com datas validadas; nenhum `now()` no
  export (datas vêm do `EstatisticasFilter`); teste do schedule verifica a expressão cron real.
- **D4 — Fronteiras**: só o módulo (export) e o Core (agenda/retention test); o E2E usa rotas
  públicas; `arch-check` verde.
- **D5 — Dependências**: nenhuma nova; ícone `download` já vendorizado.
- **D6/D7 — CSS/HTML/a11y**: export é anexo (não há tabela nova); os links usam `btn btn-outline`
  dos componentes da casa, com ícone `aria-hidden` e texto visível; axe E2E A/AA nas duas telas.
- **D8 — i18n**: 4 locales para `export_csv`/`export_paginas`/`export_modulos`/`export_serie`; o
  cabeçalho do CSV segue o locale ativo; a política de privacidade tem o texto factual nos 4
  locales (teste de paridade + texto renderizado).
- **D9 — Grep-friendly**: tokens `estatisticas`/`Estatisticas` nos arquivos novos; `name-check`
  verde (`--grep estatisticas`).
- **D10 — Dados**: nenhuma migração/tabela nova; leitura via portas do Core; nenhuma coluna de PII.
- **D11 — Segurança/LGPD**: permissão antes da geração; filtros fechados (mesmo parser); CSV
  injection neutralizado em toda célula de texto; nenhum path/query/PII no arquivo ou no nome;
  auditoria de acesso registrada; política de privacidade factual (sem promessa não cumprida).
- **D12 — Observabilidade**: `estatisticas.export` (span + `log_event` com escalares
  `report`/`days`/`module_filter`/`locale`/`rows`), sem PII; leitura na trilha via `audit.access`.
- **D13 — Testes/evidência**: `EstatisticasExportTest` (11 casos: convidado/403, anexo+colunas,
  filtros iguais aos das telas + fallback + allowlist, neutralização das 4 formas, célula numérica,
  módulos/série, cabeçalho por locale, auditoria, módulo inativo, links das telas);
  `EstatisticasCmsTest` (histórico de módulo desativado que não cresce e permanece no relatório);
  `MetricsRetentionTest` (auditoria intocada); `RetentionScheduleTest` (3 casos: diário, mensal e
  jobs preservados); `LgpdTest` (política nos 4 locales); E2E `24-estatisticas` (2 casos).
- **D14 — Documentação**: `MODULE.md` (rota, relatórios, streaming, neutralização, retenção),
  README de métricas (agenda), `AGENTS.md`/spec 04/README do E2E (história 24), esta ficha, fila e
  inventário.

### Riscos e observações

- **`-` em número**: a neutralização preserva `-20` (tendência) e prefixa texto `'-1+1`. É a regra
  do export de Dados Abertos e a única que não corrompe valor; o teste fixa os dois lados.
- **`fputcsv` cita campos com espaço**: cabeçalhos como `"Duração média (ms)"` saem entre aspas —
  é CSV válido; o teste do arquivo compara o cabeçalho exato (o E2E também).
- **Cache × export**: o export usa as Actions cacheadas (`modulos`/`serie`) e `rows()` sem cache
  para páginas; um arquivo baixado dentro dos 60 s vê o mesmo recorte da tela (proposital).
- **Schedule mensal + diário**: os dois tocam o alvo `metrics-daily`; é idempotente (DELETE por
  `day < corte`), não duplica efeito e mantém o mensal como rede de segurança.
- **`retention:expunge` mensal** continua o único caminho dos demais alvos — o diário é só métrica.
- **Seed/precondição do E2E**: sem `db:seed` o perfil demo não tem `estatisticas.ver` e o spec falha
  em 403 (pré-condição documentada); não se cria permissão por atalho no teste.
- **E2E `--no-deps`**: ao rodar só a spec 24 fora do `make check`, o projeto dependente
  `cms-serial` não roda; no `make check E2E=1` a ordem normal (serial antes) é mantida.
- **Regressão exposta pelo seed**: o perfil `administrador` passou a ver o subitem “Visão geral”
  (Estatísticas) e a spec 17 usava `getByRole('link', { name: 'Geral' })` **sem** `exact` — o nome
  acessível “Visão geral” casa por substring. Corrigido no locator da spec (`exact: true`); a UI
  não mudou. É o tipo de acoplamento que o E2E deve evitar, não um defeito de produto.

### Comandos úteis

```bash
docker compose exec -T php-fpm ./vendor/bin/phpunit \
  --filter='EstatisticasExportTest|EstatisticasCmsTest|EstatisticasI18nTest|MetricsRetentionTest|RetentionScheduleTest|LgpdTest'
docker compose exec -T php-fpm ./vendor/bin/phpstan analyse --memory-limit=1G --no-progress
BASE_URL=http://localhost:50700 npx playwright test tests/24-estatisticas.spec.ts --project=cms-desktop
make check && make check && make check E2E=1
```

### Evidência (2026-10-25)

- **Testes alvo (PHPUnit):** `EstatisticasExportTest` **11** casos / 70 asserções (P4.FIX elevou a 13) ·
  `EstatisticasCmsTest` **15** (S4 + o caso novo de módulo desativado) ·
  `EstatisticasI18nTest` **3** · `EstatisticasContentTest` **1** ·
  `MetricsRetentionTest` **7** (novo: auditoria intocada) · `RetentionScheduleTest` **3** (P4.FIX elevou a 4) ·
  `LgpdTest` **21** (novo: política de métricas nos 4 locales) — todos verdes.
- **Suíte completa:** `OK (1332 tests, 22879 assertions)`; **cobertura `src/` = 93,88%**
  (17.276/18.402) — piso ≥90%. Módulo `Estatisticas` (produção, sem `Test/`): **942/946 = 99,6%**;
  as 4 linhas restantes são defensivas/inalcançáveis (3 no `throw` de `EstatisticasFilterParser::date()`
  e 1 no guard de `fopen` do controller de export).
- **Gates estáticos:** PHPStan `max` **0**; Pint `PASS`; `arch-check`, `migration-check`,
  `name-check`, `docs-check`, `optimize-check`/`optimize-verify` e `check-gates` OK.
- **`make check` 2×** verde (22 OK · 0 FAIL · 11 SKIP — SKIPs são os passos caros já frescos) e
  **`make check E2E=1`** verde (**24 OK · 0 FAIL · 9 SKIP**; `e2e` 140 s, `perf` 62 s, `a11y` OK).
- **E2E:** 132 testes — **131 passed + 1 skipped** (`07-cms-shell`, opt-in condicional); a spec
  nova `24-estatisticas` passa os 2 casos (portal→relatório/filtro e export como anexo) no
  `cms-desktop`; nenhum flaky observado (uma rodada anterior reprovou o locator ambíguo da spec 17,
  corrigido com `exact: true` e revalidado nas duas rodadas seguintes).
- **Manifesto** recacheado (`core:cache-modules`, 11 módulos) e `db:seed` executado (perfil demo
  passou a receber `estatisticas.ver` pelo manifesto).

## P4.FIX — correções da revisão paralela (`task_01m2zeqa`)

Revisão paralela (8 recortes: dados, cards, ingestão, export, testes, docs, transversal) concluída
em 2026-10-25. Cada correção abaixo tem teste de regressão; o estado final é 🟡 **não revisado**.

| # | Achado (sev.) | Correção | Teste |
|---|---|---|---|
| 1 | `pfadd` com hash **escalar** — phpredis 6 exige array; HLL morto em produção (P1) | `ValkeyUniqueCounter` chama `pfadd($key, [$hash])`; o fake passou a validar a assinatura do cliente (`pfadd`/`expire`/`pfcount`) e rejeita escalar | `UniqueCounterTest` (3 novos) + prova real com `metrics.uniques.driver=valkey` |
| 2 | `url()`/`label()`/`icon()` fora do `try` → card com URL que lança derrubava `/cms` (P2) | `ResolveDashboardCards` resolve **todos** os metadados dentro da isolação; `ResolvedMetricCard` carrega `label`/`icon` já resolvidos e a view nunca chama o card | `DashboardCardsTest` (`ThrowingUrlCard`, `ThrowingLabelCard`) |
| 3 | Cards de Notícias/Dados Abertos contavam `status=publicado` ignorando `published_at` futuro (P2) | Cards usam os escopos canônicos `publicadas()`/`publicados()` | fixture com data futura em `NoticiasMetricCardsTest`/`DadosAbertosMetricCardTest` |
| 4 | Falha de cache descartava valor calculado e emitia `metrics.card_failed` (P2) | Cache **best-effort** (`get` falha → calcula; `put` falha → mantém) + evento próprio `metrics.card_cache_failed` (`operation=get|put`) | `DashboardCardsTest` com repositório de cache que lança |
| 5 | `media.stream` dominava o ranking e pagava upsert/HLL por imagem (P2) | Allowlist configurável `metrics.ignored_routes` (`media.*`, `seo.*`) avaliada no `shouldCollect` | `MetricsIngestionTest` (asset excluído / página continua) |
| 6 | Home (`Route::view` → `ViewController`) gravava `module=core`; guard `/up` inalcançável e teste de falso verde (P2) | Fallback pelo nome da rota também para controller **não** de módulo (`portal.home` → `home`); guard `/up` removido; teste de health trocado pelo de asset real | `MetricsIngestionTest` (`/` → `home`, `/build/*`, allowlist) |
| 7 | Fallback GET transformou `POST/PUT` em 405 e `OPTIONS` em 200 em path inexistente (P2) | **Semântica aceita e documentada** (README de métricas + MODULE/ficha): corrigir exigiria trocar o fallback por catch-all, que quebra o 405 de rota viva. Testado | `MetricsIngestionTest` (`405`/`200` sem coleta; 405 de rota viva) |
| 8 | HLL coletado sem superfície de leitura (P2) | Visão geral e CSV `serie` ganharam “únicos (estimativa)” **por dia/global** (sem dimensão de rota); fora do TTL (90 d) ou sem pepper → **indisponível** (célula vazia), nunca `0` | `EstatisticasCmsTest` (2), `EstatisticasExportTest` (1) |
| 9 | `MetricsRetentionTest` dependia do relógio do host; dry-run não provava a contagem (P2/P3) | `Clock` fixo no `setUp`; asserção `1 registro(s) (dry-run)`; schedule prova ausência de `--dry-run` | `MetricsRetentionTest`, `RetentionScheduleTest` |
| 10 | DNT `withHeader` vazava para o request Sec-GPC (P3) | `flushHeaders()` antes do GPC | `MetricsIngestionTest` |
| 11 | E2E dependia da página 1 do ranking global e do relógio na virada UTC (P3) | Filtro `modulo=lgpd` + `utcToday()` capturado **antes** da visita | `tests/24-estatisticas.spec.ts` |
| 12 | `?ordenar[]=`/`?direcao[]=`/`?relatorio[]=` → 500 (P3) | Leitura escalar + allowlist (`EstatisticasSortLinks::fromRequest`/`directionFrom`, `EstatisticasCsvExport::fromRequest`) | `EstatisticasCmsTest`, `EstatisticasExportTest` |
| 13 | CSV não neutralizava TAB/CR (P3) | Regex `/^[=+\-@\t\r]/`, igual ao export de Dados Abertos | `EstatisticasExportTest` |
| 14 | Linhas 81–83 da fila fora da tabela F7+ (P3) | Movidas para dentro da tabela, após o item 80 | revisão do `README.md` |
| 15 | P4 do wiki ainda `planejado`; `INVENTARIO` dizia “não existe métrica” (P2/P3) | `em revisão` + ficha; inventário `FEITO (P4, em revisão)` | — |
| 16 | Ficha com contagens erradas, comentário obsoleto do card, riscos não registrados (P3) | Este bloco; `PortalRequestsCard` documentado (sem link por divergência `painel.ver` × `estatisticas.ver`) | — |
| 17 | Política publicava 400/90 literais enquanto a config é ajustável (P3) | View deriva os prazos de `config('metrics.*')`; os 4 locales usam `:aggregate_days`/`:unique_days` | `LgpdTest` (4 locales, config-driven) |

### Decisões e riscos aceitos

- **Únicos por dia, não por rota:** a ADR-047 não tem dimensão de rota para HLL; expor “únicos da
  rota” exigiria um HLL por rota (custo e cardinalidade) e uma emenda de ADR. Decisão: coluna
  diária global na visão geral/série, com nota explícita na tela e no CSV.
- **Fallback × métodos ≠ GET:** mantido 405 (`POST`/`PUT`) e 200 (`OPTIONS`) para path inexistente,
  documentado e coberto. Trocar por catch-all quebraria o 405 de rota viva; um renderer de
  exceção cobriria só parte dos verbos. Risco residual: cliente de API que envie `OPTIONS` para URL
  desconhecida recebe 200 `Allow: GET, HEAD` em vez de 404.
- **Retenção/LGPD:** os prazos publicados acompanham `config()`; se o deploy mudar
  `METRICS_RETENTION_DAYS`/`METRICS_UNIQUE_TTL_DAYS`, o texto muda junto (sem literal congelado).
- **Cache de relatório sem `throttle:`** (achado transversal F2): chave de cache cresce com
  `de`/`ate`/`modulo`/`ordem`/`página`; só usuários com `estatisticas.ver` alcançam. **Não
  implementado nesta correção** — vira task de dívida (throttle/reuso do payload do export).
- **`BotClassifier`:** marcadores genéricos (`bot`, `monitor`, `uptime`) foram estreitados —
  aparelho **CUBOT** e UA de navegador com a palavra “Monitor” deixam de ser bot; Googlebot,
  bingbot, crawlers e monitores conhecidos (UptimeRobot, Pingdom) continuam cobertos.
- **Export `paginas` sem cache** (O1): mantido (decisão documentada da S5, linhas limitadas).

### Evidência (2026-10-25, após P4.FIX)

- **Prova do HLL real** (`metrics.uniques.driver=valkey`, phpredis 6.3.0, Valkey 9, via
  `bootstrap/app.php`): `driver=Core\Internal\Metrics\ValkeyUniqueCounter`, três `add()` com dois
  hashes distintos → `count=2`, `ttl=7776000` (90 dias). O fake `RecordingRedisConnection` passou a
  validar a assinatura do cliente e rejeita `pfadd` escalar.
- **Suíte completa:** `OK (1363 tests, 23077 assertions)`; **cobertura `src/` = 93,92%**
  (17.350/18.474), piso 90%.
- **`make check` 2×** verde (24 OK · 0 FAIL · 9 SKIP e 22 OK · 0 FAIL · 11 SKIP) e
  **`make check E2E=1`** verde (**24 OK · 0 FAIL · 9 SKIP**; `e2e` 141 s, `perf` 62 s, `a11y` OK).
- **E2E:** 132 testes — 131 passed + 1 skipped (`07-cms-shell`, opt-in); os dois casos da spec
  `24-estatisticas` verdes com o filtro `modulo=lgpd` no `cms-desktop`.
- **Gates estáticos:** PHPStan `max` 0; Pint `PASS`; `arch-check`, `migration-check`, `name-check`,
  `docs-check`, `optimize-check`, `check-gates` OK.

## P4.FIX2 — `de[]`/`ate[]` não podem gerar 500 (`task_00epcj24`)

A verificação independente do P4.FIX (recorte export/testes) encontrou que a regra cruzada
`after_or_equal:de` faz o framework parsear o valor de `de`: com `?periodo=custom&de[]=x&ate=…` o
`DateTime::createFromFormat()` do vendor recebia um array e o `TypeError` escapava do `parse()` —
**HTTP 500** nas telas de período e no export, em vez do fallback 200.

| Achado | Correção | Teste |
|---|---|---|
| A1 (Baixo) `de[]`, `ate[]` e ambos, com e sem `custom`, respondiam 500 (o contrato “entrada inválida → fallback 200” era violado) | `EstatisticasFilterParser::refuseNonScalarDates()` roda **antes** de montar as regras: `de`/`ate` só passam como texto (ou ausente/`null`); tipo inesperado vira `ValidationException` localizada (`date_format`) e cai no fallback determinístico de 7 dias | `EstatisticasCmsTest::non_scalar_date_filters_never_break_the_screens` (6 formas × 4 telas) · `EstatisticasExportTest::non_scalar_date_filters_fall_back_instead_of_failing` (6 formas) |

- **Precedência preservada:** a autorização (`estatisticas.ver`) continua no controller **antes**
  do parse; a recusa acontece dentro do `try` existente, então o `GET` nunca levanta erro de
  formulário.
- **As 5 rotas:** `/estatisticas`, `/paginas`, `/modulos` e `/exportar` caem no fallback com aviso;
  `/conteudo` não usa o parser (contadores atuais, sem filtro de período) e simplesmente ignora a
  query, seguindo 200 — o teste cobre as cinco para provar que nenhuma responde 500.
- **Vermelho→verde:** antes da correção o teste novo falhava com
  `DateTime::createFromFormat(): Argument #2 ($datetime) must be of type string, array given`
  (`Tests: 2, Assertions: 4, Failures: 2`); depois, `OK (2 tests, 56 assertions)`.
- **Docs:** `MODULE.md` §Filtros fechados registra a recusa de data não escalar; contagens de teste
  atualizadas abaixo.

### Evidência (2026-10-25, após P4.FIX2)

- **Testes alvo:** `EstatisticasCmsTest` **20** casos · `EstatisticasExportTest` **14** ·
  `EstatisticasI18nTest` **3** · `MetricsRetentionTest` **7** · `RetentionScheduleTest` **4** ·
  `LgpdTest` **21** — verdes.
- **Suíte completa:** `OK (1365 tests, 23133 assertions)`; **cobertura `src/` = 93,92%**
  (17.357/18.481), piso 90%.
- **`make check` 2×** verde (24 OK · 0 FAIL · 9 SKIP e 22 OK · 0 FAIL · 11 SKIP) e
  **`make check E2E=1`** verde (24 OK · 0 FAIL · 9 SKIP; `e2e` 143 s, `perf` 63 s, `a11y` OK).
- **E2E:** 132 testes — 131 passed + 1 skipped (`07-cms-shell`, opt-in); a spec
  `24-estatisticas` (tela e export) verde nas duas rodadas.
- **Gates estáticos:** PHPStan `max` 0; Pint `PASS`; `arch-check`, `migration-check`, `name-check`,
  `docs-check`, `optimize-check`, `check-gates` OK.

## Notas da revisão

| Data | Revisor | Resultado | Observações |
|---|---|---|---|
| 2026-10-25 | revisão paralela (7 recortes) + P4.FIX | ✅ aprovado | 17 achados corrigidos com teste (seção `P4.FIX`); riscos aceitos registrados (únicos por dia global, 405/200 do fallback, cache de export sem throttle, `BotClassifier` estreitado). Verificação independente: nenhum achado sobrevivente. |
| 2026-10-25 | verificação independente do export + P4.FIX2 | ✅ aprovado | A1 (`de[]`/`ate[]` → 500) corrigido com recusa de data não escalar antes das regras (seção `P4.FIX2`); teste vermelho→verde nas telas e no export; verificação final sem achados. |
