# ADR-046 — Configurações tipadas por escopo, em JSON

| Campo | Valor |
|---|---|
| **Status** | Aceito (2026-10-23) |
| **Data** | 2026-10-23 |
| **Plano de origem** | `wiki/implementation-phases/P0-fundacao-painel.md` |
| **Relacionadas** | ADR-001, ADR-007, ADR-016, ADR-017, ADR-024, ADR-035, ADR-036 |

## Contexto

Identidade, tema, shell do CMS e SEO vivem hoje em `config/design.php`, `config/cms.php` e
`config/seo.php`. A instância não consegue sobrepor esses valores sem deploy, e não existe uma
camada genérica de configuração. O legado usava chave/valor livre, `config.json` posicional e cache
por `pull`, o que produzia valor inválido em produção e N+1.

Ao mesmo tempo, tema e widgets precisam de **um JSON simples por escopo**: o tema do portal, o tema
do CMS, as particularidades dos widgets e a identidade da instituição são documentos, não dezenas de
chaves soltas. A configuração precisa ser tipada, validada, cacheada e auditada sem virar um motor
de settings arbitrário.

## Decisão

1. **Um documento JSON por seção.** A tabela `core.core_settings` guarda `section` (única),
   `payload jsonb NOT NULL DEFAULT '{}'`, `schema_version`, `updated_by`, `encrypted_payload`
   (texto cifrado para campos `secret`), `created_at`, `updated_at`.
   `CHECK (jsonb_typeof(payload) = 'object')`; índice por `section`. `down()` move para
   `backup_core_settings_<ts>` (ADR-017).
2. **Schema em código.** `Core\Settings\SettingsSchema` declara cada seção e cada campo
   (`string|text|bool|int|enum|color|url|email|phone|media_id|secret|json`), com `default`,
   `required`, limites, `values`, `encrypted`, `label` e `help`. Não há chave fora do schema.
3. **Escopos iniciais:** `institution`, `theme`, `cms`, `seo`, `system`, `support`.
   `theme` contém `portal`, `cms` e `widgets`; `widgets` é um mapa por id/tipo de widget com tokens
   semânticos validados pelo schema de tema/widget. Chave desconhecida é ignorada, não derruba o
   request.
4. **Contrato:** `Core\Contracts\Settings` com `get(section, key, default)`,
   `section(section, bool $maskSecrets = false): array`, `put(section, array, ?User $actor)`,
   `flush(?string $section)`. O flag de máscara existe para tela/comando; o código lê o valor real.
   A leitura de uma seção é **uma query**; a escrita é transacional por seção.
5. **Precedência:** banco → fallback declarado no `config/` → default do schema. Acessibilidade
   continua por último na cadeia de layout. Valor inválido no banco é ignorado com log estruturado
   (`core.settings.invalid`), nunca erro de request.
6. **Segredos:** campos `secret` são gravados em `encrypted_payload` (cast `encrypted`), nunca no
   `payload` público; a leitura para tela devolve máscara. Auditoria e log registram apenas
   `changed: true`/contagem, nunca o valor.
7. **Cache:** `settings.{section}` com invalidação síncrona na escrita; teste de dois requests.
   Não há cache negativo nem cache por campo.
8. **Auditoria:** `AuditEntry::event('core.settings.updated', ...)` com `section`, chaves alteradas
   e antes/depois **redigidos**; ator/request_id/trace_id vêm do contexto. A escrita usa
   `AuditWriter` (ADR-035).
9. **Sem motor de settings.** Não há endpoint de chave avulsa, JSON livre editável, herança entre
   seções ou settings por módulo. O painel edita uma seção por rota; o módulo que precisa de
   configuração declara uma seção no schema.
10. **Tema/widgets:** o editor de tema grava `theme` (tokens `oklch()`, escala de raio, fonte,
    `widgets`); `ThemeResolver`/`CmsShellResolver` leem settings com fallback para `config/`.
    O widget theme nunca vira CSS/HTML no banco; é token validado.

## Justificativa — alternativas descartadas

| Alternativa | Por que não |
|---|---|
| Uma linha por chave (`section,key,value`) | Muitas linhas, N+1 e dificuldade de versionar um documento de tema/widgets; o JSON por escopo é mais simples para o caso real. |
| Chave/valor livre com JSON schema em runtime | Repete o legado: valor inválido entra em produção e a UI não tem contrato. |
| Só `config/` + deploy | Não atende o painel nem a instância; qualquer ajuste de tema exige deploy. |
| Uma tabela de settings por módulo | Fragmenta a leitura, a auditoria e o cache; o Painel fica inconsistente. |
| Cifrar o documento inteiro | Impede leitura pública de tema/identidade e complica cache; campos secretos são a minoria. |
| Motor de settings/feature flags | Overengineering para single-tenant (ADR-001). |

## Consequências

**Positivas**
- Um documento por escopo é fácil de ler, versionar e cachear; tema/widgets cabem no mesmo modelo.
- Schema em código impede chave arbitrária e valor inválido.
- Auditoria e segredo têm caminho único.
- Precedência determinística com `config/` como fallback.

**Custos / riscos**
- O schema precisa ser mantido junto com o tema/widgets; campo novo exige código + teste.
- Escrita de seção inteira pode gerar lost update; usar `lock_version`/transação e validar antes.
- Se uma seção crescer demais, dividir por escopo — não criar chave avulsa.

## Exemplo mínimo

```jsonc
// core_settings (section = theme)
{
  "portal": { "color_primary": "oklch(0.55 0.14 250)", "radius": "md" },
  "cms":    { "color_primary": "oklch(0.45 0.10 250)" },
  "widgets": {
    "noticias.ultimas": { "variant": "compact", "show_image": true }
  }
}
```

## Verificação

- Escrever seção inválida → 422; valor inválido no banco → default/config, request 200 e log.
- `section()` faz uma query; escrever invalida `settings.{section}` no mesmo request.
- Segredo não aparece em payload, log, auditoria, resposta ou export.
- `theme` muda `/theme.css` e `ETag`; widget theme muda somente tokens, sem CSS/HTML no banco.
- `migrate:fresh` sem seed funciona; `down()` preserva backup.

## Emenda (2026-10-25) — tipo `phone` (P3.S6)

O contato do fornecedor precisa publicar telefone/WhatsApp como **link de navegação** (`tel:` e
`wa.me`), sem transformar o campo em texto livre (que aceitaria `javascript:` ou HTML) nem em
`string` sem forma. O tipo entra na allowlist normativa do item 2:

- **`phone`** é um tipo escalar de texto; além do `max` declarado, o valor normalizado (`trim`) só é
  aceito se: não contiver caracteres de controle; casar `^\+?[0-9 ().-]+$`; e tiver **8 a 15
  dígitos** (`\D` removidos), com `+` **opcional** apenas no início. Recusa letras, esquema/URL e
  qualquer outro separador visual — o `+` inicial é preservado no `tel:`.
- Valor fora da forma é **ignorado** na leitura (vence a ausência/fallback), como qualquer campo
  inválido do item 5; a escrita devolve validação (422), nunca 500.
- Campos que usam o tipo hoje: `support.contact_phone` e `support.whatsapp` (seção `support` do
  P3.S6). A apresentação é responsabilidade da view (texto escapado + link explícito), nunca do
  schema.
- Compatibilidade: telefone legado “de gente” (`(11) 4000-0000`) continua válido; o tipo **não**
  guarda número canônico E.164 — a normalização é só visual (`trim`).

**Verificação:** `SettingsSchemaTest` (allowlist de tipos e forma do telefone, incluindo recusa de
`javascript:`/HTML e o legado P0) e `PainelContactCmsTest` (`tel:`/`wa.me` a partir do valor
validado).

## Referências

- `wiki/implementation-phases/P0-fundacao-painel.md`, `P1-estrutura-site-cms.md`;
- `wiki/adr/ADR-016-tema-layout-como-dado.md`, `ADR-017-migrations-seguras-dados-desacoplados.md`,
  `ADR-035-trilha-de-auditoria-e-evidencias.md`.
