# P5.5 — Motor de dados abertos: fonte, padronização e serialização

| Campo | Valor |
|---|---|
| Programa | F7+ — operação, estrutura e conteúdo |
| Status | `revisado` — S1–S6.2 entregues e revisados em 2026-10-26; `make check` 2× + `E2E=1` verdes |
| Depende de | P0, P1 (sequência: **P5 → P5.5 → P6**) |
| Habilita | P6, P12, P22–P28 |
| Tamanho | **G** (12 sessões) |
| ADRs | **[ADR-049](../adr/ADR-049-motor-dados-abertos-fonte-pull.md) (aceita)** — fonte pull-based; emenda à ADR-018; ADR-015, ADR-001, ADR-013, ADR-030, ADR-034 |

## 1. Objetivo

Criar o **motor compartilhado de dados abertos antes do primeiro módulo de conteúdo**, para que
Notícias (P6) e todos os módulos seguintes publiquem dados por um contrato único — e não por CSV
manual, cópia de linhas ou evento carregando payload.

O motor tem três camadas: **porta/registry no Core**, **service `OpenData`** e **módulo
`Portal/DadosAbertos` como catálogo/CMS**. A fonte da verdade continua no módulo dono; o catálogo
referencia a fonte, não copia os dados.

**Resultado observável:** o export atual de `DadosAbertos` continua gerando os mesmos bytes/`ETag`;
um dataset **gerado** a partir de uma fonte registrada exporta CSV/JSON/XML; uma fonte implementada
e não registrada **falha o `make check`**; P6 liga `noticias.publicadas` sem alterar o núcleo do
módulo `DadosAbertos`.

## 2. Estado atual (reusar)

> Retrato **anterior à implementação** (abertura da fase); o que a fase entregou está em §4 e no
> ADR-049 §Implementação.

- `src/modules/Portal/DadosAbertos/**`: entidade `DadosAbertosDataset` com `columns`/`rows` em
  `jsonb`, export CSV/JSON/XML determinístico, checksum SHA-256, `ETag`/304, anti-CSV-injection e
  testes de contrato.
- `DadosAbertosExport` era **dono da serialização** e recebia o model do módulo — foi o código
  extraído, não reescrito.
- Padrão de registry já provado: `Core\Contracts\SitemapRegistry` + `Ports\SitemapSource` e
  `Core\Contracts\RetentionRegistry` + `Ports\RetentionTarget`, ambos com implementação em memória
  no Core e registro no `ModuleProvider::register()`.
- **Ordem de providers (decisiva):** `bootstrap/providers.php` registra
  `ModuleDiscoveryServiceProvider` **antes** de `ServiceDiscoveryServiceProvider`; o
  `ModuleDiscoveryServiceProvider::register()` executa o `register()` de todos os módulos. Um
  registry dentro de um service nasceria vazio. Por isso a porta e o registry ficam no **Core**,
  exatamente como `SitemapRegistry`.
- **Entregue pela fase:** `Core\Contracts\Ports\DatasetSource`,
  `Core\Contracts\DatasetRegistry` + `InMemoryDatasetRegistry`, `Services\OpenData`, dataset gerado
  por fonte no `DadosAbertos` e `DatasetIntegrationTest`.
- O P9 planejava `services/Export` e `ExportSource`. Este plano **absorve** essa capacidade no
  `OpenData`; o P9 não cria um segundo exportador.

## 3. Decisões de arquitetura (fechadas nesta fase)

| Decisão | Por quê |
|---|---|
| **Pull, não evento carregando dados** | Evento não é log durável: perde, duplica, desordena e exige outbox/replay. A fonte é consultada; o evento, se existir, só invalida cache. |
| **Porta e registry no Core** | O módulo registra antes do service existir; é o mesmo padrão de `SitemapRegistry`/`RetentionRegistry`. |
| **Service `OpenData` é motor; módulo é catálogo** | O catálogo/CMS continua em `Portal/DadosAbertos`; o service não tem UI nem rota pública. |
| **Um service, não dois** | `Export` e `OpenData` seriam dois donos da mesma serialização. O P5.5 entrega `OpenData`; o P9 consome, não recria. |
| **Dataset gerado referencia a fonte** | `source` + `source_options`; `rows` vazio. Copiar todas as linhas para `dados_abertos.datasets` cria segunda verdade e staleness. |
| **PDF é apresentação, não dado** | CSV/JSON/XML são dados; HTML/print resolve PDF. PDF server-side não entra no núcleo. |
| **Snapshot fica para volume real** | O contrato já nasce com `fingerprint()`; materializar no S3 só quando P12/P22 medir volume. |
| **PII é gate, não responsabilidade do serializador** | A fonte declara `containsPersonalData`; o service recusa publicação/export público. |
| **Ativação é runtime** | Fonte de módulo inativo não aparece nem exporta (ADR-034); o filtro é na leitura. |

## 4. Escopo fechado

### S1.1 — Core: porta `DatasetSource`, DTOs e contrato do registry

> **D16:** a fonte é pull-based e registrada no Core; um único serializador em `OpenData`. O módulo
> não empurra linhas para o serviço nem mantém cópia do dataset.

- `Core\Contracts\Ports\DatasetSource`:
  - `id(): string` — estável, namespaced (`noticias.publicadas`, `financeiro.despesas`).
  - `module(): string` — token do módulo dono (ex.: `Noticias`), para ativação.
  - `metadata(): DatasetMetadata`.
  - `columns(): array` — `list<DatasetColumn>`.
  - `rows(): iterable` — `array<string, scalar|null>`; ordem explícita.
  - `fingerprint(): string` — schema + contagem + `max(updated_at)` + `max(id)`.
- `Core\Contracts\Ports\Data\DatasetMetadata`: título, descrição, publisher, licença (default
  `CC-BY-4.0`), periodicidade, versão, `updated_at`, `containsPersonalData`, `publicationBasis`
  (`non_personal|legal_obligation|public_interest|none`), `personalDataFields` (allowlist),
  classificação, tema, tags, `schemaVersion`.
- `Core\Contracts\Ports\Data\DatasetColumn`: `key`, `label`, tipo (`string|integer|decimal|date|
  datetime|boolean`), nulabilidade, unidade.
- `Core\Contracts\DatasetRegistry`: `register(class-string)`, `sources(): array<string,
  DatasetSource>`, `find(string): ?DatasetSource`.

### S1.2 — `InMemoryDatasetRegistry`, `ModuleStatus` e bind no Core

- `Core\Internal\Datasets\InMemoryDatasetRegistry`: classes ordenadas, instância pelo container,
  mapa por `id` com `ksort`; **id duplicado lança exceção** (não “último vence”). O construtor da
  fonte é **leve** (sem query); `rows()` é lazy e só executa quando o export/preview pede.
- `Core\Contracts\ModuleStatus` + adapter no Core: `isActive(string $module): bool`. O service usa
  o contrato; não importa `ModuleDiscoveryService` diretamente.
- Bind dos singletons em `CoreServiceProvider`.
- Testes: ordem determinística, duplicidade, fonte resolvida pelo container, módulo ativo/inativo.

> Técnica: TECNICO §4.1/§11; skills `laravel-12`, `php-84-correct`.

### S2.1 — Service `OpenData`: provider, contrato e serializador

- `src/services/OpenData/ServiceProvider.php` implementa `Core\Contracts\Service`; `contracts()`
  liga `Contracts\DatasetExporter`; `config()` publica o catálogo do service.
- `Contracts\DatasetExporter`:
  - `byId(string $id, string $format): ExportedDataset` — resolve no registry.
  - `of(DatasetSource $source, string $format): ExportedDataset` — fonte já resolvida (curado/teste).
  - `sources(): array<string, DatasetMetadata>` — catálogo para o CMS (ativos).
- `Support\DatasetSerializer`: extrai a lógica de `DadosAbertosExport` (CSV/JSON/XML), mantendo:
  - ordem de `columns`/`rows`; UTF-8; `\n`; sem BOM;
  - CSV com `fputcsv` e neutralização de `= + - @`/TAB/CR, preservando números negativos;
  - JSON com `meta`, `columns`, `data`; XML com `XMLWriter`; datas ISO 8601; decimal como string;
  - checksum SHA-256 do corpo e `ETag` forte.
- `Support\ArrayDatasetSource`: fonte curada em memória (metadados + colunas + linhas), usada pelo
  módulo e pelos testes; `fingerprint` determinístico.

> Técnica: TECNICO §3.3/§5; skills `postgresql`, `laravel-12`.

### S2.2 — `ExportDataset`: gate de módulo/PII e testes de contrato

- `Actions\ExportDataset`: resolve → valida módulo ativo → valida PII/base legal/allowlist →
  serializa → checksum; span `open-data.export` + `log_event`; exceções de domínio para 404/422.
- Testes: bytes iguais para a mesma entrada; formatos parseáveis; injeção CSV; PII sem base legal
  bloqueada; PII com base legal + allowlist permitida; fonte inexistente; módulo inativo; `ETag`/`304`.
- **Fora:** snapshot, filtros, paginação de linhas, PDF, XLSX, eventos de invalidação.

> Técnica: TECNICO §9/§10; skills `web-security-review`, `laravel-12`.

### S3.1 — `DadosAbertos`: migração aditiva e model de dataset gerado

- Migração aditiva em `dados_abertos.datasets`: `source` text nullable + índice, `source_options`
  jsonb default `{}`. `down()` **não dropa** coluna com dado: no-op documentado (ADR-017).
- Model/DTO/form: `source`, `sourceOptions`; `isGenerated()`; validação de `source` contra
  `DatasetExporter::sources()`; `rows`/`columns` proibidos quando `source` presente.

> Técnica: TECNICO §5; skills `postgresql`, `laravel-12`.

### S3.2 — Consumo do motor: export, catálogo/DCAT e CMS

- `DadosAbertosExport` vira adaptador fino:
  - curado → `DatasetExporter::of(new ArrayDatasetSource(...))`;
  - gerado (`source`) → `DatasetExporter::byId($source)`.
- Catálogo, `show`, API/DCAT e schema usam os metadados da fonte quando gerado; `updated_at` do
  DCAT vem da fonte. Fonte inexistente/inativa ou PII sem base/allowlist → 404 na rota pública e
  erro de validação no CMS.
- CMS: select “Fonte” (ativo; PII só com base legal e allowlist), aviso de que as linhas são
  geradas; nenhum botão de upload de CSV para dataset gerado.
- Testes: dataset curado continua idêntico; dataset gerado exporta; fonte inativa 404; fonte some
  após desativar módulo; `rows` não é gravado.

### S4.1 — `DatasetIntegrationTest`: varredura e validações

- `src/services/OpenData/Test/DatasetIntegrationTest.php` (roda no `make check`):
  - varre `src/modules/*/*/**` por classes que implementam `DatasetSource` (exceto `Test/`,
    `Fixtures/` e `src/demonstration/`);
  - falha se a classe não estiver no `DatasetRegistry` (implementou e esqueceu de registrar);
  - valida `id` namespaced e único, `module()` existente no manifesto, metadados/licença; se
    `containsPersonalData=true`, exige `publicationBasis` válido e `personalDataFields` não vazio
    (dados sensíveis permanecem fora);
  - garante que o contrato é `Core\Contracts\Ports\DatasetSource` (sem import de `DadosAbertos`).

> Técnica: TECNICO §4.1/§11; skills `laravel-12`.

### S4.2 — Receita do módulo e limite do gate

- Receita do módulo (documentada no README e em `MODULOS.md`):
  ```php
  // ModuleProvider::register()
  app(DatasetRegistry::class)->register(NoticiasDatasetSource::class);
  ```
  A classe fica no módulo dono (`Logic/Datasets/...`), implementa a porta do Core e não importa
  `Modules\Portal\DadosAbertos` nem outro módulo.
- O gate não obriga um módulo sem dado público a criar fonte; obriga que **toda fonte existente**
  esteja registrada e seja publicável. A obrigatoriedade de publicar é do critério/PNTP de cada
  fase, não do gate.

### S5.1 — Confirmação da ADR-049

- Revisar e **confirmar** a **[ADR-049](../adr/ADR-049-motor-dados-abertos-fonte-pull.md)**
  (já aceita em 2026-10-23): fonte pull-based, porta/registry no Core, service `OpenData`, evento só
  invalidação, sem PDF no núcleo, dataset gerado sem cópia de linhas.

### S5.2 — Propagação do pré-requisito e dívida no `kd`

- Atualizar `P6`, `P9`, `P12`, `PNTP.md`, `MODULOS.md` e o README das fases para apontar P5.5 como
  pré-requisito e remover o `services/Export` duplicado.
- Registrar no `kd` a decisão e a dívida “snapshot/refresh” (P12/P22).

### S6.1 — E2E do CMS e correções de regressão

- E2E do select de fonte no CMS; corrigir as regressões achadas.

> Técnica: TECNICO §11; skills `playwright-ts-admin`.

### S6.2 — `make check` 2×, cobertura e evidência

- `make check` 2× + `E2E=1`; cobertura ≥90%; PHPStan max 0 — **verde em 2026-10-26**.
- Evidência: 1 757 testes/30 392 asserções; cobertura `src/` 94,19%; `make check` 2× (23 OK/0 FAIL)
  e `E2E=1` (25 OK/0 FAIL; e2e 154 passed + 1 skip, axe/perf OK); paridade de bytes do curado
  provada em `DadosAbertosExportParityTest`.

### Fora de escopo (explícito)

- Snapshot/materialização no S3 e `open-data:refresh` — o contrato nasce com `fingerprint()`, mas a
  implementação fica para P12/P22, quando houver volume medido.
- PDF server-side, XLSX/PhpSpreadsheet, filtros/paginação de linhas no export.
- Evento carregando linhas; central de dados (data lake), CKAN ou ETL de legado (ADR-042).
- `services/Export` separado; um segundo catálogo ou uma segunda cópia das linhas.
- Qualquer HTML/CSS/Blade no dataset (ADR-017/028).

## 5. Fatias de sessão

| Sessão | Entrega | Prova | Depende |
|---|---|---|---|
| **P5.5.S1.1** | portas Core (`DatasetSource`) + DTOs `DatasetMetadata`/`DatasetColumn` + contrato do registry | contratos sem import de módulo; PHPStan max | P0, P1 |
| **P5.5.S1.2** | `InMemoryDatasetRegistry` + `ModuleStatus` + bind no Core | duplicidade falha; ordem; módulo inativo | S1.1 |
| **P5.5.S2.1** | provider + `DatasetExporter` + serializador + `ArrayDatasetSource` | CSV/JSON/XML determinístico; bytes iguais na mesma entrada | S1.1 |
| **P5.5.S2.2** | `ExportDataset` + gate de módulo/PII + observabilidade | PII bloqueada; fonte inexistente; módulo inativo; `ETag`/304 | S2.1 |
| **P5.5.S3.1** | migração `source`/`source_options` + model/DTO/form | `rows` proibido com fonte; validação contra o catálogo | S2.2 |
| **P5.5.S3.2** | export delegado + catálogo/DCAT + CMS + 404 | bytes/`ETag` iguais aos testes atuais; inativa 404; sem cópia | S3.1 |
| **P5.5.S4.1** | `DatasetIntegrationTest` (varredura + validações) | fonte não registrada falha o check | S1.2–S3.2 |
| **P5.5.S4.2** | receita do módulo + docs do gate | receita no README/`MODULOS.md`; gate não obriga módulo sem dado | S4.1 |
| **P5.5.S5.1** | ADR-049 revisada/confirmada | decisão confirmada e aderente ao código entregue | S1.2–S4.2 |
| **P5.5.S5.2** | propagação do pré-requisito + dívida no `kd` | P6/P9/P12/PNTP apontam P5.5; sem `services/Export` | S5.1 |
| **P5.5.S6.1** | E2E do CMS (select de fonte) + regressões | fluxo E2E verde; regressões corrigidas | S1.2–S5.2 |
| **P5.5.S6.2** | 2× check + `E2E=1` + cobertura + evidência | evidência no `kd`; cobertura ≥90%; PHPStan max 0 | S6.1 |

## 6. Contratos e dados

- **Portas Core:** `DatasetSource`, `DatasetRegistry`, `ModuleStatus`; DTOs `DatasetMetadata`,
  `DatasetColumn`.
- **Service:** `Contracts\DatasetExporter`, `Contracts\ExportedDataset`; implementação
  `Actions\OpenDataExportDataset` + `Support\DatasetSerializer`.
- **Tabela:** `dados_abertos.datasets` ganha `source` (text, nullable, index) e `source_options`
  (jsonb, default `{}`); sem nova tabela nesta fase.
- **Sem rota nova:** as rotas atuais de catálogo/export/API continuam; o CMS ganha o select de
  fonte.
- **Eventos:** nenhum evento carrega dado. Se um dia houver invalidação, o evento leva **somente**
  `sourceId`; a fonte continua sendo consultada.
- **Permissões:** as do módulo `DadosAbertos`; a fonte não cria permissão própria.

## 7. DoD

- [x] ADR-049 aceita; decisão registrada no `kd`.
- [x] `DatasetSource`/`DatasetRegistry` no Core; id duplicado falha; ordem determinística.
- [x] `Services\OpenData` exporta CSV/JSON/XML com bytes determinísticos, checksum e `ETag`.
- [x] `DadosAbertosExport` curado produz **os mesmos bytes** de antes; testes atuais verdes.
- [x] Dataset gerado por fonte funciona; fonte inativa/inexistente/PII sem base/allowlist não publica.
- [x] PII legalmente pública (ex.: remuneração) publica com `publicationBasis` e allowlist; dados
      sensíveis permanecem fora.
- [x] `DatasetIntegrationTest` falha se uma fonte não for registrada; receita documentada.
- [x] P6 consegue registrar `noticias.publicadas` sem tocar no núcleo de `DadosAbertos`.
- [x] `make check` 2× + `E2E=1`; cobertura ≥90%; PHPStan max 0 — verde em 2026-10-26.

## Fechamento — ficha de revisão

> **Obrigatório antes de fechar:** criar `_to_revision/P5.5-motor-dados-abertos.md` e seguir
> `_to_revision/README.md` + `_to_revision/PROTOCOLO.md` (v2.0). A ficha não é gate de build; é a
> entrada da fila de revisão profunda.

- Cabeçalho: fase, `kd` épico/tasks/slices, âncoras (este arquivo, ADRs e código) e status
  `⬜ pendente` → `🟡 em revisão` → `✅ revisado` / `🔧 com correções`.
- Conteúdo: o que foi implementado, arquivos/superfícies a revisar, checklist D1–D14 aplicável,
  riscos e observações.
- Fechamento: preencher “Notas da revisão” com data/revisor/resultado e achados (`kd write
  --anchor`), atualizar a fila em `_to_revision/README.md` e `_to_revision/_inventario.md`.
- Achado com correção exige teste vermelho→verde; a fase só é `closed` no `kd` com a ficha
  registrada.

## 8. Riscos

| Risco | Mitigação |
|---|---|
| Abstração sem consumidor | P6 é o primeiro consumidor real; P12/P22 são os seguintes; nada de snapshot/PDF/filtros especulativos |
| Provider order esvaziar o registry | porta/registry no Core, como `SitemapRegistry`; teste de boot |
| Duas fontes com mesmo `id` | registry lança; teste dedicado |
| Evento virar transporte de dado | contrato proíbe payload; evento, se existir, só invalida |
| PII escapar no CSV/log | `containsPersonalData` + `publicationBasis` + allowlist; span/log sem linha; teste |
| Bytes do curado mudarem | goldens do export atual antes da extração; comparar corpo/`ETag` |
| Módulo inativo expor dataset | `ModuleStatus` no service; teste de desativação |
| `Export` duplicado no P9 | P9 consome `OpenData`; o `Export` separado é removido do plano |
| Escopo crescer para “data lake” | sem cópia central, sem CKAN, sem ETL (ADR-018/042) |

## 9. Retomada

```bash
kd prime
kd ask "dados abertos dataset source open data"
kd task list --ready
```

Ler: este arquivo → `src/modules/Portal/DadosAbertos/Logic/Actions/DadosAbertosExport.php` →
`src/core/Internal/Seo/InMemorySitemapRegistry.php` → `src/core/Contracts/Ports/README.md` →
`bootstrap/providers.php` → ADR-018 → ADR-049.
