# F5.I2.T1 — Dataset DCAT/INDA + export CSV/JSON/XML com ETag/checksum

| Campo | Valor |
|---|---|
| **Fase** | F5 — Borda |
| **Issue** | F5.I2 — Dados abertos e LGPD (plano 17) |
| **kd task** | `task_004qy1kx` (`closed`) |
| **Âncora** | `wiki/implementation/05-borda.md` |
| **Status da revisão** | 🔧 com correções |

## O que foi implementado

- Módulo `Portal/DadosAbertos` (`dados_abertos.datasets`, dados em `jsonb`); metadados DCAT/INDA + `schema.org/Dataset` (`Seo/DadosAbertosSchema`).
- Export CSV/JSON/XML determinístico com `checksum` SHA-256, `ETag`/304 e URL estável; catálogo `/dados-abertos` + detalhe; API `/api/v1/datasets` (paginada, `throttle:open-data`, sem PII); CRUD no CMS.

## Arquivos para revisar

- `src/modules/Portal/DadosAbertos/**`
- `src/modules/Portal/DadosAbertos/Database/Migrations/2026_10_21_000000_create_dados_abertos_datasets_table.php`
- `src/modules/Portal/DadosAbertos/Logic/Actions/{DadosAbertosExport,DadosAbertosSave,DadosAbertosStatus}.php`
- `src/modules/Portal/DadosAbertos/Seo/{DadosAbertosSchema,DadosAbertosSitemapSource}.php`
- `src/modules/Portal/DadosAbertos/Logic/Controllers/**`
- `src/modules/Portal/DadosAbertos/Layout/Views/**`
- `src/modules/Portal/DadosAbertos/Lang/**`
- `src/modules/Portal/DadosAbertos/ModuleProvider.php`
- `src/modules/Portal/DadosAbertos/MODULE.md`
- `src/modules/Portal/DadosAbertos/Test/DadosAbertosTest.php`
- `config/layout.php` + `src/layout/Lang/*/shell.php` (nav)
- `Makefile` (`demo-data`)
- `src/tests-e2e/tests/12-dados-abertos.spec.ts`

## Pontos de revisão

### PHP 8.4
- [ ] `declare(strict_types=1)`; tipos nativos (union/enum/`readonly`); sem `mixed` desnecessário.
- [ ] Dependências **injetadas** (nenhum `new`/`app()`/`resolve()` no corpo); sem traits/abstract/generics.
- [ ] `match` em vez de cadeias de `if`; `?->`/`??`; sem código morto; PHPStan `max` limpo.

### Laravel 12
- [ ] Nativo primeiro (Eloquent casts/scopes, `FormRequest`, `Gate`, scheduler, events) — sem reimplementar.
- [ ] Sem closure em rota; resources/rotas nomeadas; `config()` só lê, `env()` só em `config/`.
- [ ] Migração **idempotente**; `down()` → `backup_*` (nunca `DROP`); sem `orderBy` cru quando `latest/oldest` serve.

### HTML semântico + a11y (eMAG/WCAG 2.2 AA)
- [ ] Um `<main>`, headings em nível correto, landmarks; só ARIA quando necessário.
- [ ] Alvos ≥ 24 px (≥ 44 px em ponteiro grosso); foco visível; rótulos em todos os campos; contraste (axe).
- [ ] Funciona **sem JS**; `sniffCSS` sem `fail`/`warn` fora do ledger.

### CSS moderno
- [ ] `oklch`, `clamp`, `rem`/`fr`/`dvh`/`%`; `@layer` (a11y por último); `@property`; subgrid com fallback.
- [ ] Sem `!important`, sem `style="…"` inline, sem hex/rgb (exceto justificado); sem CDN.

### Decisões do projeto (ADRs)
- [ ] Respeita a ADR do escopo (ver cabeçalho) e não escapa das demais (001, 002, 004, 013, 015…).
- [ ] Determinismo: relógio/id injetados, ordem explícita, dados idempotentes (ADR-001).

### i18n (ADR-012)
- [ ] Nenhuma string visível fixa; 4 locales com paridade de chaves; `route()`/`localized_url()` (nunca path literal).

### Observabilidade/segurança/testes
- [ ] Span + `log_event('{module}.{action}')` com atributos `scalar|null`, **sem PII** (ADR-024).
- [ ] Teste que falha sem a mudança (vermelho→verde) + evidência no `kd`; `make quality` 2×.

### Específicos desta task

- [ ] Export determinístico + neutralização de CSV injection; sem PII.
- [ ] `@graph` do dataset e DCAT corretos; checksum/`ETag` batem com o corpo.
- [ ] `whereIn/whereNull/orderBy` evitados (Larastan) sem crescer o baseline.

## Comandos úteis

```bash
make quality            # lint + PHPStan max + gates + testes
make e2e                # Playwright + axe (se a task toca UI)
make check-boundaries   # fronteiras core/services/layout/modules
```

---

> Revisar contra `AGENTS.md`, `wiki/adr/` e o plano citado. Registrar achados no `kd` (`kd write --anchor`) e, se houver correção, teste de regressão.

## Notas da revisão

| Data | Revisor | Resultado | Observações |
|---|---|---|---|
| 2026-10-18 | agente | 🔧 | **A** (**alto**) a neutralização de *CSV injection* **corrompia número negativo** (`-42` virava `'-42` ✗ — OWASP manda marcar só o que **não** é número) e **não cobria o cabeçalho** ✗ (nome de coluna começando com `=` viraria fórmula) → `neutralizeCsv` pula `is_numeric` e o cabeçalho passa pela mesma função, com teste novo **vermelho→verde** (o CSV saía `'=cmd,saldo` / `'-42,…`) (`fact_00t6jhbl`); **B** (médio → `task_00skpinc`) **toda resposta do portal manda `Set-Cookie`** (`XSRF-TOKEN` + sessão ✗ — medido em `/`, `/robots.txt` e no próprio CSV, que é `Cache-Control: public, max-age=3600`) enquanto a área `api` manda **zero** → é exatamente a "sessão anônima" que a ADR-022 evitou ao tirar a meta CSRF do HTML; e o HTML **varia por cookie** (`data-a11y-*` ✗) **sem `Vary: Cookie`** ✗ → cache compartilhado serviria a variante errada (`risk_01okkydv`); **C** (médio → `task_00vimpys`) o predicado de **publicação** está **copiado em 5 pontos** (4 controllers + o sitemap ✗) com `now()` fora do `Clock` injetado ✗ e o `isPublished()` do model **discorda** (ignora `published_at <= now()` → um dataset com data futura é "publicado" para o model e invisível para as queries) → candidato: `scopePublicados()` + o relógio injetado, como o `scopePublicadas()` de Notícias (`risk_01npkvew`); **D** (médio → `task_012jlp5n`) a coluna **`metadata`** (jsonb) é **gravada e nunca lida** ✗ — e o seeder guarda ali um campo **DCAT** (`dcat:contactPoint` ✗) → o registro DCAT/INDA sai **incompleto** (`risk_00amq05u`); **E** (médio, corrigido) o `signIn` **duplicado em 3 specs** era **flaky**: o clique esperava a navegação sob o `actionTimeout` de 10 s e o **primeiro** request do CMS depois de editar PHP paga OPcache frio + Argon2id — **o check falhou** (`10-cms-layout`, *waiting for scheduled navigations to finish*) → helper **compartilhado** `support/cms.ts` que espera o **destino** (`waitForURL` 30 s em `Promise.all`), com os 3 specs verdes (13 e2e) e o check de volta ao verde (`fact_01mk7wma`); **F** (baixo) salvar como **rascunho apaga `published_at`** enquanto a action de status **preserva** ✗ → `dct:issued` se perde e republicar gera data nova (duas semânticas para o mesmo campo) (`risk_00cibbhu`); **G** (baixo) **colunas duplicadas** não são recusadas ✗ (`associate()` colide → o JSON perde dado que o CSV mantém) e as linhas não são validadas contra o número de colunas (célula extra descartada em silêncio) (`risk_00swoasy`); **H** (obs) na API o `license` do envelope é fixo, não há `links`/`next`, as chaves `dcat:` vão **sem `@context`** (não é JSON-LD parseável) e o corpo é *pretty-printed*; o **export do portal não tem `throttle`** (a API tem); `status` sem `CHECK` na migração; `dcat:theme` entrega texto onde a DCAT-AP espera URI; o MIME está duplicado entre o export e o schema; o `@graph`/DCAT declara `inLanguage` por locale com conteúdo **não localizado**; `Rule::unique` substituiria o *closure* de slug feito à mão; `If-None-Match` é comparado por **string exata** (o service Seo usa `isNotModified()`); `fputcsv` com o `escape` legado; `toJson` sem `JSON_THROW_ON_ERROR`; a view de detalhe passa `?? ''` como descrição (meta description vazia). **Verificado OK:** export **determinístico** (mesmos bytes) com `X-Checksum-Sha256` = `sha256(corpo)` = `ETag` e **304** por `If-None-Match`; CSV/JSON/XML na ordem de `columns`; só **publicados** no catálogo/detalhe/export/API/sitemap; API paginada, somente leitura, **sem PII** e com `throttle:open-data`; `schema.org/Dataset` + `DataDownload` no `@graph` e registro DCAT; CRUD com `dados_abertos.{ver\|editar\|aprovar\|arquivar}` (negação por padrão); i18n nos 4 locales; span + `log_event` nos 3 actions; migração idempotente com `down()` → `backup_*`. `make check E2E=1`: 20 OK · 0 FAIL · 1 SKIP, e2e **91 passed**, cobertura **91,37%**. |

> Resultado: `✅ revisado` · `🔧 com correções` · `⏭️ delegado` (com justificativa).
