# P1.S1 — seções `theme`/`cms`/`seo`/`institution` e leitura com fallback

| Campo | Valor |
|---|---|
| **Fase** | P1 — Estrutura geral do site e do CMS |
| **Issue** | P1.S1 — schema das seções + `ThemeResolver`/`CmsShellResolver`/`SeoSettings` lendo a porta `Settings` |
| **`kd` task** | `task_00kqxzcw` (épico `epic_01vhakc0`) |
| **Âncoras** | `wiki/implementation-phases/P1-estrutura-site-cms.md` §3/S1, §5–§7 · `wiki/implementation-phases/TECNICO.md` §14 · ADR-046, ADR-016, ADR-036, ADR-027, ADR-013, ADR-012, ADR-022, ADR-002 |
| **Status da revisão** | ✅ revisado (correções do P1.FIX aplicadas) |

## O que foi implementado

- **`SettingsSchema` (P1)**:
  - `institution`: `social` (JSON lista de `{network, url}`, allowlist fechada em
    `SOCIAL_NETWORKS`), `dpo_email` e `ombudsman_email` (tipo `email`). `name` continua obrigatório
    e o fallback de `app.name` foi preservado.
  - `theme`: `portal`/`cms`/`widgets` seguem JSON de documento. A dívida `@debt [task_01uq1k4y]`
    do P0 foi **fechada**: a validação de token passou a existir na leitura (`ThemeResolver` +
    `ThemeSchema`, com teste); o comentário no schema agora documenta que a validação de escrita
    pertence ao editor de tema (P1.S2), que gera os campos do `ThemeSchema`. Core continua **sem**
    importar `Layout\` (ADR-013).
  - `cms`: `regions` JSON com forma fechada `{header|footer|sidebar: {enabled: bool}}`
    (`CMS_REGIONS`); chave desconhecida é descartada, `enabled` de tipo errado invalida o
    documento e região sem `enabled` não é persistida (herda). Posição/largura continuam em
    `sidebar_position`/`sidebar_width`, compatíveis com `config/cms.php`.
  - `seo`: `default_description` (alias de `site_description`), `default_image` (`media_id`, UUID),
    `analytics_id` (**sem default e sem fallback** — opt-in) e `verification` (JSON lista de
    `{provider, content}`, allowlist em `VERIFICATION_PROVIDERS`). `site_title`/`site_description`/
    `site_logo`/`google_analytics_id`/`...` seguem como campos e fallbacks.
  - Validação de forma para documentos JSON: `normalizeJson`/`normalizeShape` normalizam o
    documento (ordem declarada, chaves desconhecidas descartadas) e `accepts()` usa a mesma
    validação — leitura e escrita têm uma única verdade.
- **`ThemeResolver`**: recebe `Core\Contracts\Settings` no construtor (DI; nada de `app()` no
  corpo) e lê `theme.portal`. Precedência preservada: default → **config/design.php + settings** →
  preset → URL → instância. Só tokens aceitos pelo `ThemeSchema` entram; token inválido no banco
  não sombreia a camada anterior e gera `core.settings.invalid` (só `keys`/contagem). Sem linha no
  banco, `/theme.css` sai idêntico ao anterior (hash/ETag iguais).
- **`CmsShellResolver`**: recebe `Settings` no construtor e lê a seção `cms`. Precedência
  instância → **settings (banco → `config/cms.php` → default)**. Cada valor é validado antes de
  entrar na camada, então valor inválido **não** sombreia a configuração (correção de um caso de
  borda que antes deixava `title`/`logo` inválidos anularem `config/`). Regiões
  (`header`/`footer`/`sidebar`) vêm do shape do schema; posição usa
  `CmsSidebarPosition::tryFrom`; largura continua na escala de `config('cms.sidebar_widths')`.
  Valor inválido de settings é logado com `fields` (conjunto fechado) e sem valor.
- **`SeoSettings`**: `string()` lê a seção `seo` pela porta e usa `config/seo.php` como fallback das
  chaves sem schema (`pwa_icons`) ou de seção ausente. O acesso estático (views/`@graph`) resolve a
  porta num único ponto de fronteira (`section()`), sem `app()` espalhado. Novos acessores:
  `defaultDescription()` (alias), `defaultImageId()` (só UUID válido — nunca URL crua),
  `verification()`; `analyticsId()` passou a aceitar `analytics_id` e a manter
  `google_analytics_id` como alias, continuando `null` por padrão.
- **Formulário da seção (sem virar editor)**: campo JSON de uma seção editável (`institution.social`)
  é devolvido em `<input type="hidden">` e decodificado no `FormRequest`, para salvar a seção não
  apagar o documento que o editor genérico ainda não edita.

### Fora desta fatia (não implementado de propósito)

- Editores de tema (S2) e do shell (S3); menus (S4) e ação de layout (S4b); publicação de SEO (S5)
  — `default_description`, `default_image` e `verification` são lidos e testados, mas **não** são
  publicados no `<head>`.
- `theme.cms`/`theme.widgets`: declará-los é da S1; o consumo é dos editores/S2.
- Nenhum endpoint novo, nenhum editor chave/valor.

## Arquivos para revisar

- `src/core/Settings/SettingsSchema.php`
- `src/core/Lang/{pt_BR,en,es,fr}/settings.php`
- `src/core/Settings/Test/SettingsSchemaTest.php`
- `src/layout/Engine/ThemeResolver.php`
- `src/layout/Cms/Shell/CmsShellResolver.php`
- `src/services/Seo/SeoSettings.php`
- `src/modules/Infraestrutura/Configuracoes/Logic/Requests/UpdateConfiguracoesRequest.php`
- `src/modules/Infraestrutura/Configuracoes/Layout/Views/cms/form.blade.php`
- `src/modules/Infraestrutura/Configuracoes/Test/ConfiguracoesCmsTest.php`
- `tests/Feature/SettingsDrivenSiteTest.php`
- `tests/Feature/ThemeResolverTest.php` · `tests/Feature/ThemeOverrideTest.php` (resolver pelo container)
- `phpstan.neon` (triage de código morto: `ThemeResolver` é usado por Blade; `SeoSettings` deixou de
  precisar da exceção)

## Pontos de revisão

### D1 — Simplicidade / PHP 8.4
- [ ] `declare(strict_types=1)`, tipos em parâmetros e retorno; DI por construtor nos dois
      resolvers, sem `new`/`resolve`/`app()` no corpo.
- [ ] Sem trait/abstract/generic/`Result`; `match` fechado no `normalizeShape` (shape desconhecido
      falha fechado).
- [ ] Nenhuma abstração nova além do que o contrato pedia (`shape` no campo do schema, `section()`
      como fronteira estática de `SeoSettings`).

### D2 — Laravel 12
- [ ] Nenhum `env()` novo; `config()` apenas no código e `env()` continua só em `config/`.
- [ ] `Settings` é singleton do Core; nenhum provider/registro novo.
- [ ] `FormRequest` normaliza JSON antes da validação; `validated()` continua sendo a entrada do
      `put()`.

### D3 — Determinismo
- [ ] Ordem de seções/campos determinística; shapes normalizam na ordem declarada; nenhum
      `now()`/`rand()`; testes sem relógio/rede.
- [ ] Sem estado estático mutável em `SeoSettings`; a leitura de seção vem do cache da porta.

### D4 — Fronteiras
- [ ] `core` não importa `layout`; `layout` e `services/Seo` dependem só de `Core\Contracts\Settings`.
- [ ] `arch-check` OK.

### D7/D10 — Dados e apresentação
- [ ] Nada de HTML/CSS no documento: cores seguem `ThemeSchema`/`oklch()`, `default_image` é
      `media_id`, `social`/`verification` são listas fechadas.
- [ ] Nenhuma migração nesta fatia; a seção nova usa a tabela `core.core_settings` existente.

### D8 — i18n
- [ ] Toda label nova do schema existe em `pt_BR`, `en`, `es`, `fr` (teste
      `every_field_label_exists_in_the_four_locales`).
- [ ] Nenhuma string visível hardcoded; o formulário existente continua gerado do schema.

### D11 — Segurança / LGPD
- [ ] `default_image` recusa URL crua (teste de escrita inválida); `social` recusa rede fora da
      allowlist e `javascript:`.
- [ ] Logs de valor inválido têm só `module`/`action`/`section`/`keys` (+ `fields` do conjunto
      fechado do CMS), sem valor nem PII.
- [ ] `analytics_id` continua opt-in: sem ID, nenhuma meta/requisição externa.

### D12 — Observabilidade
- [ ] `core.settings.invalid` emitido nos dois resolvers quando um valor de settings é ignorado;
      nenhuma auditoria nova (auditoria continua na escrita da porta).
- [ ] Nenhum span novo — os resolvers são função de leitura por request.

### D13 — Testes
- [ ] Sem settings = comportamento atual (tema/CSS, shell e SEO) — coberto.
- [ ] Settings válidas vencem `config/`; inválidas são ignoradas e logadas; instância continua
      vencendo; aliases de SEO e opt-in do analytics cobertos.
- [ ] Round-trip do documento JSON no formulário e rejeição de documento inválido.
- [ ] Suíte completa e `make check` (evidência abaixo).

### D14 — Documentação
- [ ] Comentário do schema explica onde cada validação mora (leitura × escrita) e o `@debt` do P0
      foi encerrado apontando o dono da validação de escrita (P1.S2).
- [ ] Esta ficha + linha na fila do `README.md`.

## Evidência

- `docker compose exec -T php-fpm ./vendor/bin/phpunit --filter='SettingsDrivenSiteTest|SettingsSchemaTest'`
  — 27 testes, 308 asserções (vermelho antes da implementação: campos inexistentes).
- Suíte completa: `./vendor/bin/phpunit` → **808 testes, 12.471 asserções, OK**.
- `phpstan analyse --memory-limit=1G` → **0 erros** (nível max).
- `pint --test` → PASS; `tools/arch-check` → OK; `tools/name-check.sh` → OK;
  `tools/migration-check` → OK; `tools/docs-check.sh` → OK.
- `make check` → **24 OK · 0 FAIL · 9 SKIP** (e2e/perf opt-in), 102,69 s; cobertura OK.

## Riscos e observações

- **Resolvedores deixam de ser 100% puros**: passam a receber a porta `Settings` (I/O cacheado) e
  continuam determinísticos para a mesma configuração; é a exigência do P1.S1 (estrutura como dado),
  sem `app()` no corpo.
- **Leitura de seção por request**: o shell do portal chama `hasConfiguredOverrides()` e o
  `CmsShellResolver`/`SeoSettings` chamam `section()`. Desde a revisão do P1, o `raw()` do
  `DatabaseSettings` cacheia também o **miss** e o `SeoSettings::section()` é memorizado por request
  (`Core\Internal\Settings\SettingsRequestScope`): uma página lê `seo` **uma vez** e, sem linha no
  banco, paga no máximo um `SELECT` por seção (medido em `SeoPublishTest`).
- **Documento `theme` aberto**: `portal`/`cms`/`widgets` aceitam qualquer objeto no schema; a defesa
  é o `ThemeSchema` na leitura. A validação na escrita fica no editor (P1.S2, já no escopo do
  épico e registrada na nota `fact_01beq94x` do `kd`), sem risco de CSS/HTML arbitrário (o resolver
  só emite token validado).
- **`theme.cms`/`theme.widgets`** ainda sem consumidor; declarados para o editor e testados só por
  forma (JSON).
- **`cms.logo`** segue texto validado por forma (path same-origin ou `https://`). A variante
  “media id” prevista no P1 §3/S1 depende do catálogo e fica para o editor do shell (P1.S3), que
  resolve a mídia em `<x-service-media::picture>`; hoje um UUID no campo é ignorado (não quebra o
  shell, não vira `src` inválido).
- **SEO publica só o que já publicava**: `analytics_id`/`google_analytics_id` mudam a meta
  `gtag-id`; descrição/imagem/verificação padrão ficam para o P1.S5 (nada de URL crua em
  `og:image`).
- **Formulário genérico**: campos `json` de seções editáveis passam por input oculto até existir
  editor dedicado; se um dia houver upload/estrutura grande, o mecanismo deve dar lugar ao editor.
- `phpstan.neon`: triagem **por mensagem** do `shipmonk.deadMethod` para o que só o Blade consome
  (`ThemeResolver::hasConfiguredOverrides`, `SeoSettings::verificationMetaTags`/`defaultImageUrl`);
  nenhuma isenção por arquivo para esses casos.

## Notas da revisão

| Data | Revisor | Resultado | Observações |
|---|---|---|---|
| 2026-10-24 | agente revisor P1-S1 | 🔧 | **A (alto) `SeoSettings` × porta `Settings` no caminho quente:** o `<head>`/`@graph`/analytics leem a seção `seo` ~18× por página pública (8 em `SeoService::resolve`, 6 em `BuildSeoGraph`, 2 em `tags.blade`, 2 em `analytics.blade`) + 1× `theme` no shell; como `DatabaseSettings::raw()` não tem cache negativo, **sem linha no banco (estado default) cada leitura é um `SELECT core_settings`** — o “sem settings = comportamento atual” e o “a porta é cacheada (1 roundtrip/página)” da ficha não se sustentam. Corrigir: cachear o miss em `raw()` (`put()` já invalida) e memoizar a seção resolvida por request. **B (baixo) `phpstan.neon`:** `src/layout/Engine/ThemeResolver.php` entrou no ignore **por arquivo** do `shipmonk.deadMethod` (só `hasConfiguredOverrides()` é Blade-only), enquanto o mesmo patch usa ignore por mensagem para `SeoSettings::verificationMetaTags` (ADR-040, exceções estreitas; a ficha diz que `SeoSettings` “saiu” da triagem, mas há exceção nova). Estreitar para a mensagem do método. Verificado OK: leitura/escrita com a mesma forma (`accepts`/`normalize`), `cms.regions` × `config/cms.php`, instância > settings > config, valor inválido não sombra (com teste vermelho), aliases de SEO e opt-in do `analytics_id`, `default_image` só UUID, `media_id` nunca URL, labels nos 4 locales, logs só com `section`/`keys`/`fields` (sem valor/PII), sem core→layout, `@debt [task_01uq1k4y]` resolvido para o documento consumido (`theme.portal`), `theme.cms`/`widgets` ainda sem validador e sem consumidor (declarado na ficha). |
| 2026-10-24 | P1.FIX (agente) | ✅ | **A corrigido:** `DatabaseSettings::raw()` agora cacheia o **miss** (`MISSING`, TTL 5 min; `put()`/`flush()` esquecem a chave) e `SeoSettings::section()` é memorizado **por request** em `Core\Internal\Settings\SettingsRequestScope` (atributos da request, sem `static`; `put`/`flush` invalidam a seção). Testes: `SeoPublishTest::a_public_page_reads_the_seo_section_once_and_caches_the_missing_row` (1 leitura da seção, ≤1 `SELECT` de `seo`; 0 no request seguinte) e `a_write_after_a_cached_miss_is_visible_on_the_next_request`; `DatabaseSettingsTest::it_caches_a_missing_row_and_invalidates_it_on_write`. **B corrigido:** isenção do `shipmonk.deadMethod` **por mensagem** (`ThemeResolver::hasConfiguredOverrides`, `SeoSettings::verificationMetaTags/defaultImageUrl`); nenhuma isenção por arquivo nesses casos. Evidência: suíte `OK (901 testes, 14.686 asserções)`; `phpstan` nível max **0 erros**; `make check` 2× e `make check E2E=1` verdes (abaixo). |
