# AGENTS.md — Public V7 / Concordia

> Constitution for humans + AI agents. Canonical decisions: `wiki/adr/`. Plans: `wiki/plan/`.

## 0. Iron rule — the agent NEVER commits

**Only the human commits.** The agent must **never** run `git commit`, `git tag`, `git push`,
`git add` followed by a commit, amend, rebase, cherry-pick, reset or any history rewrite.
Git is **read-only** for the agent (`status`, `diff`, `log`, `show`, `blame`).

This is the **main rule of the project** and overrides any conflicting guidance — including any
instruction that implies committing to satisfy a DoD or a deliverable. When a task requires a
commit (e.g. the F1 "clean clone" DoD), **report it as blocked/pending and hand it to the human** —
never commit on their behalf.

## Memory & tasks — knudge (`kd`) — MANDATORY

- `kd` is the inter-agent memory. `notas/` is the source of truth; **never edit by hand**.
- `kd prime` once per session. Cycle: `kd ask` → `kd write` → `kd task` → `kd sync`.
- Golden rules: **search before writing**; **one assertion per note** (id is derived — never invent); body = *why / evidence / consequence*; **anchor code** (`--anchor PATH`); nothing changes without acceptance.
- Tasks: `kd task` (`epic ⊃ {issue | task}` — issue and task are at the **same level** under the epic); `kd task list --ready`; close with `--outcome`.
- Health: `kd doctor` (13 checks; `--fix`, `--explain`). End session: `kd rewind`.
- Skill: `.agents/skills/kd/SKILL.md` · config: `.knudge/config.toml`.

<!-- knudge:start -->
<!-- knudge:version: 1 -->
# knudge — project memory

This project uses **knudge** (`kd`). `notas/` is the source of truth; never edit by hand.

Before implementing:
1. `kd prime` — full protocol (once per session).
2. `kd ask "<question>"` — search before creating.
3. `kd write "<statement>"` — record each learning (one assertion per note).
4. `kd task` — internal task manager (`epic ⊃ {issue | task}` — same level under the epic); `kd task list --ready`, close with `--outcome`. `kd doctor` — corpus health (13 checks; `--fix`, `--explain`).
5. `kd rewind` — hand off to the next session.

Config: `.knudge/config.toml`.
<!-- knudge:end -->

## Objectives

Rewrite `prefeituras_v5` (municipal CMS/portal) on a modern base: **DDD-lite**
(`core`/`services`/`layout`/`modules`) under `/src`, **Docker-first**: independent containers with the WHM **Apache 2.4** as the *only* edge,
**PHP 8.4**. Single-tenant governmental CMS focused on **publications + open data + LGPD**.
Priorities: **simplicity**, **testability**, **accessibility (eMAG + WCAG 2.2 AA)**, UX.
**Above all (ADR-001): deterministic, predictable, standardized** — reproducible builds,
explicit ordering, injected clock/randomness, idempotent data, **no flaky tests** — so the project
is easy to integrate, update and materialize as effective code with exhaustive final tests.

## Stack / hard constraints

- PHP 8.4 · Laravel 12 · **PostgreSQL 18** (container, no exposed port) · **Valkey 9**.
- Tailwind 4 + daisyUI 5 + Lucide (vendored SVG) · Vite 7 (Rolldown/Oxc).
- Content font default: **Atkinson Hyperlegible Next** (vendored WOFF2, user-adjustable via `--font-content`).
- **Zero CDN** — everything vendored/built locally (gate `tools/vendor-check`). **Only two external
  requests are accepted:** **VLibras** (official gov.br a11y service, loaded on demand) and **Google
  Analytics** (off by default; emitted only when the **Support panel** configures the ID).
  `tools/vendor-check` enforces a **closed allowlist** (`vlibras.gov.br`, `googletagmanager.com`,
  `google-analytics.com`); any other external asset fails the build (allowlist changes need an ADR).
- Licenses: **permissive only** (MIT/ISC/Apache-2.0/BSD/OFL). Gates: `tools/licenses-check.php`
  (composer) and `tools/assets-licenses-check.php` (npm + vendored assets + **runtime binaries**),
  both writing their own section of the single report `THIRD_PARTY_LICENSES` (one artifact, two
  owners, staleness-checked). Strong copyleft (AGPL/GPL) is **blocked** in code and images; weak/build-only copyleft (LGPL `libvips` invoked as a separate CLI, MPL
  `lightningcss` build-only) is an **explicit, narrow exception** — see ADR-002 amendment.
- i18n: pt_BR default + en/es/fr; native `lang/` + `__()`; translatable content in JSONB (native cast); locale middleware in Core (ADR-012).
- **Native first**: exhaust Laravel/PHP before any own abstraction.
- **No traits, no abstract classes, no generics** — concrete, functional PHP.

## Layout

```
/src: core | services | layout | modules/{Group}/{Module} | tests-e2e   (nothing else)
/wiki: plan (00..19) | adr (001..054) | implementation (F0..F6) | specs | backlog
root: typical Laravel 12 + docker/ + Makefile + VERSION
```

## Always do / always test

- **Do:** native Laravel; **deterministic by default** (injected clock/randomness, explicit
  ordering, idempotent data); inject dependencies (no `new`/`resolve` in bodies); structured JSON
  logs (`module`,`action`,`id`,`user`,`ts`,`duration_ms`) — domain events via
  `log_event('{module}.{action}', ['module'=>…, 'action'=>…])` (scalar|null, **no PII**; ephemeral);
  `declare(strict_types=1)`; semantic HTML; a11y on every screen; content decoupled from UI.
- **Test:** PHPUnit strict per layer; Playwright + axe + sniffCSS (gate); **`make check`** é o caminho
  único (boundaries via `tools/arch-check`; grep-friendly via `tools/name-check` +
  `Adr004GrepFriendlyTest`; `tools/migration-check`; `tools/vendor-check`; `php artisan optimize`).
- **Bug:** red test → fix → green. **Feature:** structural tests during build. DoD per plan.

## Docker/Podman + Make (run everything in the container)

- **Runtime padrão = Docker** (dev e produção); **podman é opt-in de dev** (`make podman-prepare` / `make runtime RUNTIME=podman`) e **nunca** em produção — ADR-045. Quem não escolhe explicitamente roda Docker; o CI e o deploy recusam podman.

- `make up` — sobe os 6 containers dev (**web**/Caddy + **php-fpm** 8.4 + **queue** worker + **PostgreSQL 18** + **Valkey 9** + **storage**/RustFS S3); **não roda `make check`** (alvos distintos). O provisionamento (migrate, assets, seed demo, SBOM) é pré-condição do `make check`; WHM **Apache 2.4** is a dumb proxy (`ProxyPass`).
- **Prod:** `scripts/init_prod.sh --execute` (primeira instalação) e `scripts/update_prod.sh --execute`
  (atualização) — **sem `make`**, dry-run por padrão, fases em `scripts/{init,update}` (spec 17). O
  `up_prod.sh` foi removido; a adoção da stack anterior é feita pelo update (ADR-055).
- Standard WHM layout (`public_html` + `git clone --depth=1`); the container **ignores** `.user.ini`/`php.ini`/`.htaccess`/`error_log` by config; storage local, path configurable.
- **Proxy chain** (Apache → Caddy → FPM): `TrustProxies`/`TrustHosts` + Caddy `trusted_proxies` set (spec 16) — required for real IP, HTTPS and rate limiting.
- `make shell` / `make exec cmd='...'` — inside the container (**never on host**).
- `make reload` — reload do php-fpm (limpa OPcache) após editar código.
- `make optimize` — limpa e aplica **todos** os caches (config/rotas/views/eventos + módulos).
- `make reset` — DESTRUTIVO: recria o banco + seeds demo e recarrega o FPM.
- `make destroy` — DESTRUTIVO: containers, volumes, redes, imagens locais e `DATA_PATH`
  (movido para `.pi/trash/`) — instalação limpa no próximo `make up`.
- `make create:module MODULE=Grupo/Modulo` / `make create:service SERVICE=Nome` — scaffolding
  canônico (substitui o antigo `stub`); o teste de estilo é corrigido **pelo próprio `make check`**.
- `make podman-prepare` — prepara o host para rodar todo o ciclo no **podman rootless** (socket,
  provider de Compose, subuid/subgid, `.env`); `make ports` mostra runtime/projeto/porta/URL e
  `make env INSTANCE=1` isola uma stack paralela (containers/volumes/porta próprios, ADR-044).
  O runtime é resolvido em **um** lugar (`tools/runtime.sh`) e o `make check` cobra o contrato
  (`tools/podman-check.sh`).
- **`make check` — o ÚNICO comando de validação** (`tools/check.sh`): **corrige preventivamente** o
  estilo (Pint + Prettier/ESLint `--fix`, logando **antes/depois**) e executa os checks **sem parar na
  1ª falha**, devolvendo o **relatório acumulado com o tempo de cada check** (pré-condições →
  normalização+estilo/docs → estático/arquitetura → audit → testes/cobertura → a11y).
- **`make check E2E=1`** (ou `ARGS=--e2e`) acrescenta **e2e Playwright + axe + perf** — opt-in porque
  só é afetado por UI/HTML/CSS/JS/rota; o **CI roda sempre com `E2E=1`** e o DoD exige ≥1× ao fechar
  cada escopo. Env: `CHECK_QUIET=1` (só resumo+falhas), `CHECK_ASSETS=1`, `CHECK_FORCE_PREP=1`,
  `CHECK_LOGDIR`, `BASE_URL`, `E2E_WORKERS` (default 4), `COVERAGE_WORKERS` (default 4),
  `SNIFF_A11Y_STRICT`; logs por check em `build/check/`.
- **Contrato de ciclo (feedback rápido — ADR-001/ADR-038):** alvo **`make check` quente ≤ 100 s** e
  **`make check E2E=1` ≤ 3 min**. O projeto cresce para 50+ módulos e dezenas de services em poucos
  meses: um ciclo lento deixa de ser usado, e o que não é usado não valida nada.
  1. **Passo caro e idempotente** (autoload, `migrate`, `optimize`, build de assets, seed demo) entra
     **gated por staleness** (stamp por mtime, `SKIP` visível no relatório) — nunca "porque sempre
     rodou". Qualquer arquivo novo invalida o stamp, então pular **só pode causar falha, nunca
     falso verde**.
  2. **Velocidade vem de paralelizar trabalho independente e remover repetição — nunca de pular
     verificação.** Cobertura ≥90%, gates, a11y e o e2e do DoD não são negociáveis; cortar check para
     ganhar tempo é violação, não otimização. O PHPUnit paraleliza por arquivo
     (`COVERAGE_WORKERS`, default 4) com banco isolado por worker e cobertura mesclada — o gate
     continua sendo o `coverage-check` sobre o clover final.
  3. **E2E paraleliza por arquivo** (`E2E_WORKERS`, default 4, `fullyParallel: false`): cada spec cria
     os próprios dados e a ordem **dentro do arquivo** é contrato. `fullyParallel: true` **não pode**
     ser ligado — dois testes do lote da galeria mutam o mesmo catálogo (medido: 99/100).
  4. **Regressão do ciclo é bug.** `tests/Feature/CheckPipelineTest.php` cobra os gates de staleness,
     o seed em um único `artisan`, o `npm ci` condicional e o paralelismo por arquivo; ao adicionar um
     passo novo, diga o custo medido e por que ele é necessário (ou torne-o gated).
  5. **Medir antes de otimizar:** o relatório do `make check` mostra o tempo por check; um passo acima
     de ~10 s precisa de justificativa. Baseline medido (2026-10-20, B9): `coverage` ~23–28 s
     (ParaTest 4 workers; era ~45 s), `e2e` ~67 s, `static` ~8 s, `seed demo` ~8 s, o resto ≤ 8 s.
- Os gates (`tools/arch-check`, `tools/name-check`, `tools/migration-check`, `tools/vendor-check`,
  `tools/gates-selftest.sh`) rodam **dentro** do `make check`.
- One checkout = one instance = one Compose project (unique name per installation, ADR-055);
  same image dev/prod.

## Rules (condensed constitution)

0. Deterministic, predictable, standardized (base); native first. 1. Concrete functional PHP + DI; no traits/abstract/generics; duplication is acceptable.
2. Testability is design (small Actions). 3. Pint + Prettier/ESLint enforced. 4. A11y/UX are requirements.
5. Observability is infra (JSON + RFC 7807). 6. Physical module boundaries. 7. Dev = prod.
8. PHPStan `level: max`, **sem baseline** (`bleedingEdge`, migrations/factories, código morto) + **cobertura ≥90%** (e2e/não-regressão > unidade). 9. Grep-friendly single token per feature.
10. Structured logs always. 11. Data decoupled from UI (`jsonb`; never HTML/CSS/UI flags; `down()` never drops → `backup_*`).
12. Always optimized (`optimize`, OPcache, strict types). 13. Modern web (semantic, minimal JS, modern CSS, PWA).
14. Security in depth (strict CSP + Trusted Types, TLS 1.3/HSTS, Argon2id, SBOM, STRIDE).
15. Minimal dependencies; vendor everything.
16. i18n pt_BR/en/es/fr, native (ADR-012). 17. Open data first-class + LGPD program (ADR-018/019).
18. Rate limiting day-zero (ADR-021). 19. Response cache + ETag; CSRF out of cacheable HTML (ADR-022).
20. Media/Gallery single image flow (ADR-026). 21. SEO service + `@graph` + root files (ADR-027).

## Decision index (ADR-001..055) — never escape these

- **001** **Fundamentals:** deterministic, predictable, standardized (reproducible builds, explicit order, injected clock/randomness, idempotent data, no flaky tests); native-first concrete functional PHP + DI; no traits/abstract/generics/`Result`; duplication OK; **simplicity above all**.
- **002** Minimal dependencies: vendor everything, **zero CDN** (only VLibras + opt-in Google Analytics), permissive licenses only.
- **003** Root = typical Laravel 12; `/src` = domain only; `/wiki` = docs.
- **004** Grep-friendly single domain token across all artifacts.
- **005** Standard WHM layout (`public_html` + `git clone`); WHM Apache = **dumb proxy** (`ProxyPass` → `web`/Caddy → php-fpm); container **ignores** `.user.ini`/`php.ini`/`.htaccess`/`error_log` by config.
- **006** Repo bind-mount at `/var/www/html` from `public_html`; storage local, path configurable (`STORAGE_PATH`); PG data outside quota.
- **007** **PG 18** in container, no exposed port; `uuidv7()`/generated cols/`INCLUDE`/async IO allowed; safe `down()`/backfills.
- **008** Native optimization mandatory (`optimize`, OPcache, `strict_types=1`).
- **009** Native-first Blade + Tailwind4/daisyUI5/Lucide; own CMS shell; **no Livewire/Filament/jQuery/CDN** (reactive UI needs a new ADR). TipTap is kept as the rich editor (ADR-028).
- **010** Modern web: semantic, minimal JS, modern CSS, PWA, zero CDN.
- **011** Content typography default Atkinson Hyperlegible Next (vendored, `--font-content`).
- **012** i18n pt_BR/en/es/fr: native `Lang/{locale}/{section}.php`+`__()`; locale from the URL; content in jsonb (native cast, F4).
- **013** DDD-lite 4 layers, physical boundary; `modules/*` never import `modules/*`.
- **014** Module autodiscovery + folder grouping; single `ModuleProvider.php`; cached manifest.
- **015** Services agnostic/headless/always available; shared-UI may expose admin surface (`/cms` only); root `ServiceProvider.php` (`Core\Contracts\Service`: `contracts()`+`config()`) absorbed by Core; **contract lives in the service** (`src/services/{Name}/Contracts`).
- **016** Theme/layout are data (jsonb); precedence default→global→preset→URL→instance; a11y last; slots = ordered stack.
- **017** Safe migrations + data decoupled from UI (jsonb, `backup_*`, no HTML/CSS); seeds = demo only.
- **018** Open data first-class: dataset entity, DCAT/INDA, CSV/JSON/XML, CC-BY.
- **019** LGPD as program: classification, DSAR, retention, incidents, privacy-first analytics.
- **020** Security in depth (strict CSP + Trusted Types, TLS 1.3/HSTS, Argon2id, SBOM, STRIDE).
- **021** Rate limiting day-zero: server + app (Valkey) + forms; `429` RFC 7807.
- **022** Response cache + ETag/304; CSRF out of cacheable HTML; CSP without inline; no HTML minify.
- **023** PHPStan/Larastan level max + ratchet baseline. *(substituído parcialmente por 038/040)*
- **038** **Cobertura de testes ≥90%** e rigor de não-regressão (e2e/não-regressão > unidade); gate no fluxo dev.
- **039** **Mutation testing obrigatório** (Infection) — observabilidade → gate; **implementação pós-revisão**.
- **040** **Análise estática sem relaxamento**: baseline **zero**, `bleedingEdge`, dead-code, migrations/factories; exceção estreita só de framework. *(descartado o Mago)*
- **041** **Seeds são exclusivamente demonstração** (`src/demonstration/**`); dado de referência vem de migration idempotente.
- **024** Native JSON logs + RFC 7807 + `X-Request-ID`; **telemetry is first-class** (native OTLP/HTTP JSON exporter, fail-open, pervasive across core/services/modules). Span naming: core `{domain}.{op}` (`http.server`/`audit.persist`/`module.discovery`/`core.port`/`core.listen`/`widget.*`/`health.check`), services `{service}.{operation}`, modules `{module}.{action}`; inject `Core\Contracts\Telemetry` (never instantiate), attributes only `scalar|null` **and no PII**, tests use `RecordingTelemetry`; `Clock`/`IdGen` are **not** spanned per call.
- **025** a11y/UX (WCAG 2.2 AA + eMAG) as CI gate (Playwright + axe + sniffCSS).
- **026** Media+Gallery single image flow: AVIF/WebP, `<picture>`, catalog; no HTML-rewrite middleware.
- **027** SEO: headless service, JSON-LD `@graph`, dynamic root files, 404/301, **no GA by default** (opt-in via the Support panel, ADR-002).
- **028** **Rich text editor = TipTap only** (vendored); AST JSON in `jsonb` → PHP `ContentASTParser` renderers → semantic HTML; no raw-HTML node; images via Media `<picture>`; deterministic.
- **029** **BLAKE3** (PHP extension, pinned) for audit integrity hash and blind index; SHA-256 only for non-crypto integrity (e.g., IP anonymization).
- **030** **Object storage S3-like from day zero:** local `storage` service = **RustFS** (Apache-2.0); Laravel `s3` disk via `.env`; swap to client AWS S3 by `.env` only.
- **031** **Pinned base images** (public images + `tag@sha256` digests; non-root via Compose); `php-fpm`/`queue` use **`php:8.4-fpm-trixie`** with a thin Dockerfile (extensions + native `blake3`); no `latest`. Matrix in `wiki/adr/ADR-031-imagens-bases-pinadas.md`.
- **032** **`APP_ENV` is the single switch** (`dev`/`homolog`/`production`): profiles adjust debug, OPcache/assertions (entrypoint), Laravel caches (`optimize`), logs/observability and session cookies; minimal `.env` + `config/_OTHER_CONFIGS.md`.
- **033** **Access control:** per-module permissions `{module}.{ver|editar|aprovar|arquivar}`; a module **declares** its actions (`'permissions' => Permission::ACTIONS` in `ModuleProvider::config()`) and enforces them **its own way** (native `Gate`/`Permission::authorize()/allows()`, **deny-by-default**) using the common vocabulary. Roles = named permission sets stored as **`core_roles.permissoes` jsonb** (`NOT NULL DEFAULT '[]'`, `CHECK jsonb_typeof='array'`, valid tokens only), **one per user** (`cms_users.role_id`, FK `nullOnDelete`); **no `core_role_permissions` table**. The `suporte` role is **reserved/protected** and the `Suporte` module group is a **normal group logically hidden by the Core** for everyone but the founder (`suporte@maxima.inf.br`): invisible in the manifest/nav (`grouped()`) and **404** in routing (`EnsureSupportGroupAccess`), even for the top role. No `PermissionService`/sentinel; no role inheritance.
- **035** **Audit trail & evidence (rigorous on the CMS):** three layers — technical log (ADR-024, ephemeral), **audit trail** (persisted, immutable, BLAKE3 — forensic evidence) and public analytics (aggregate, no PII). Audit scope by area: **CMS = full** (every model mutation + sensitive reads + login/logout), **portal = none** per citizen, **api = per key/route**. Record shape: `area/module/origin/event({module}.{action})/target/actor{id,name,email}/before/after/request_id/trace_id/integrity_hash`; single write path `AuditWriter::record(AuditEntry)` (the `GlobalAuditSubscriber` auto-enriches every mutation); reading uses `audit.access` (CMS only). Actor is a **snapshot**. LGPD: retention/legal hold/DSAR. Not an APM.
- **037** **CMS authentication:** native **session** over `cms_users` (guard `web`, eloquent provider);
  the CMS area owns `GET/POST /login`, `POST /logout`, `GET /` on its **own domain** (isolated
  cookie/session); Argon2id, `session()->regenerate()`, **generic** failure message, `POST`+CSRF logout;
  login throttled per **e-mail + IP** on the cache store (Valkey in dev/prod, deterministic in tests) with
  **429 + `Retry-After`**; audit inherited from native `Login`/`Logout`/`Failed` events. No starter kit,
  no JWT; gov.br SSO/2FA/password reset/"remember me" are backlog.
- **036** **CMS shell as data:** identity (`title`/`logo`/`--cms-*` in `oklch()`), region presence
  (`header`/`footer`/`sidebar`) and sidebar position (`left|right|orbital`) come from data
  (`config/cms.php`, instance layer over it) → `CmsShellResolver` (invalid value ignored, never breaks the
  shell); position is CSS with logical properties (CLS 0) and the DOM order follows it (focus order =
  visual order); `orbital` = floating trigger + native Popover, usable without JS, never the only path;
  identity is published as same-origin CSS (`/cms-theme.css`, ETag) — no inline style.
- **034** **Real-time module activation (not a flags engine):** activation is **data** (`core_module_discovery_settings`, key `modules_activation`, `{module: bool}`); `ModuleActivation` resolves it in **Valkey (short TTL) + DB fallback, fail-open** (default = manifest). The runtime guard `CheckModuleActivation` (alias **`module.activation`**) returns **404** on **portal/cms/api** for inactive modules; routes/bindings/abilities stay registered and the guard cuts at runtime. Toggle = `php artisan core:module:activate {Nome} [--disable]` (support-only; UI in the CMS on F4) writes the data and **invalidates the cache instantly**; manifest/nav filter inactive modules — global and immediate, no FPM reload.

- **043** **Restricted areas are invisible:** only the portal is indexable. `cms.[base]`, `api.[base]`
  and any future staff/admin area are **RESTRICTED and INVISIBLE** to indexing/mapping: never in
  `robots.txt`, sitemap, manifest, canonical, `hreflang`, JSON-LD or feeds. Restricted `robots.txt`
  is `Disallow: /`; the portal never mentions them (the `/cms` bridge answers `X-Robots-Tag: noindex,
  nofollow, noarchive`); `BuildSeoSitemap` accepts only portal-host URLs and rejects `/cms`/`/api`.
  New areas are born with higher security by default (private cache, stricter CSP, auth, audit);
  promoting one to public requires an ADR.
- **044/045** **Runtime:** Docker is the **default** and the **only production** runtime
  (ADR-005/031); podman rootless is an **explicit dev opt-in** (`make podman-prepare`), never
  production — `scripts/init_prod.sh`/`scripts/update_prod.sh` recusam podman. Dev must not drift from prod: the default cycle
  (`make up`, `make check`, e2e, CI) runs Docker; the podman override exists only when chosen
  (ADR-044), and the podman path is still validated by the static `podman-check` gate.
- **044** **Podman rootless is a dev runtime (not a second deploy):** the runtime is resolved in
  **one place** (`tools/runtime.sh`: `CONTAINER_RUNTIME` from env/`.env`, default `docker`) and every
  entry point (`Makefile`, `tools/*.sh`) uses it; **podman** adds `docker-compose.podman.yml`
  (`userns_mode: keep-id` on every service with `user:`, `label=disable`; **not** on `postgres`),
  runs ad-hoc containers with the same flags and exports `MOUNT_LABEL=""` so the base Compose
  interpolates `${MOUNT_LABEL-:Z}` (empty means no SELinux relabel). `make podman-prepare` prepares
  the host (podman ≥ 5, `podman.socket`, provider, subuid/subgid) and persists the choice; `make env`
  derives the published port deterministically (`507xx` from the project name) and syncs
  `APP_URL`; every checkout is already isolated by name/port (ADR-055) and `make env INSTANCE=1`
  completes the suffix of a pinned/legacy name;
  `tools/podman-check.sh` is a `make check` gate. **Opt-in only (ADR-045):** Docker is the
  default in dev and the only production runtime; `scripts/init_prod.sh`/`scripts/update_prod.sh` recusam podman.
- **046** **Configurações tipadas por escopo:** `core_settings` guarda **um JSON por seção**
  (`institution`, `theme`, `cms`, `seo`, `system`, `support`), schema em código, cache por seção,
  segredos cifrados e auditoria redigida; tema/widgets usam o mesmo mecanismo (sem key/value livre).
- **055** **Isolamento do projeto Compose por instalação:** o nome derivado é
  `slug(APP_NAME)-<hash8 do caminho físico do checkout>` (um `COMPOSE_PROJECT_NAME` explícito
  vence e não recebe sufixo); containers/redes/volumes nunca colidem entre clientes no mesmo
  host, mesmo com o mesmo `APP_NAME`. `scripts/update_prod.sh`/`up-dev.sh` **adotam** a stack anterior do
  próprio checkout (`down` antes do `up`; o PGDATA é bind mount) e o deploy **aborta antes de
  qualquer efeito** se o nome pertencer a outro checkout ou se `WEB_PORT` já for de outra stack.
- **047** **Métricas privacy-first:** agregados diários em PG + HLL no Valkey; sem IP/UA/URL/query
  ou identificador entre dias; retenção/expurgo; não é APM nem a trilha de auditoria.
- **048** **Legislação versionada:** versão publicada imutável, consolidação derivada, revogação
  explícita e URL estável; sem sobrescrever o passado e sem Git de textos.
- **049** **Motor de dados abertos:** fonte pull-based (`DatasetSource` + registry no Core),
  service `OpenData`, um serializador e catálogo que referencia a fonte; evento não carrega linhas.
- **050** **Busca local-first:** FTS local em Notícias (P7); service `Search` só no segundo
  consumidor (P9), fonte pull e documentos separados `portal`/`cms`; integração opt-in e cutover
  de Notícias no P9 (uma verdade).
- **051** **Importação operacional CSV:** módulo `Importador` + porta `ImportTarget` no Core;
  preview/dry-run/apply, `key_hash`, CSV privado; nunca ETL de legado nem import no deploy.
- **052** **Solicitações de serviços digitais:** módulo `Servicos/Solicitacoes` com protocolo e
  estados explícitos, reusando P11; não é helpdesk, BPMN nem API pública no MVP.
- **053** **PDF pesquisável:** PDF gerado do dado bruto tem texto e alimenta a busca; anexo
  externo sem OCR (`pdf_pesquisavel` atestado); OCR exige ADR própria.
- **054** **VIAP/cotas:** P17 é o dono da escrita; P28 lê por porta read-only no Core ou
  referencia o widget/rota; nunca uma segunda tabela.
- **042** **No backwards compatibility:** the V7 is a **new project from zero** — nothing of the v5 is
  kept (no legacy model/table/route/`User`, no shadow auth). The **database is new** (born from
  `migrate`; reference data from idempotent migrations, ADR-017/ADR-041) and **content/users enter
  manually, case by case, through the CMS** (TipTap/`jsonb`, Media, Argon2id) — **no ETL**: no
  `sys_migration`, `legacy_mapping`, `config/migration.php`, `migrate:legacy` or per-module adapters.
  `301` is **curation, not an engine**: the few critical URLs go by hand into `seo.redirects`
  (ADR-027, console surface; the live route is never shadowed, `http.not_found` finds the hot ones).
  What remains of "migration" is **operation of the V7's own DB**: `pg_dump -Fc` **before** `migrate`
  (a failed dump aborts the deploy), restore into a clean base, PITR and retention (F6.I2). Amends
  ADR-007 (no PG 10.23 → 18 step).
- **041** **Seeds are demonstration only:** never bootstrap, never a prerequisite, **never imported by production code**; they live **only** in `src/demonstration/{Module}/{Seed}.php` (namespace `Demonstration\{Module}`, PSR-4 `Demonstration\` → `src/demonstration/`), outside the 4 layers. Orchestrator `Demonstration\CoreDatabaseSeeder` is the `db:seed` target (bind in `CoreServiceProvider`); `make check` seeds it idempotently (gated) and `make reset` recreates the demo base. `src/demonstration/` is **excluded from coverage** but still under PHPStan and exercised by tests.

## Conventions (functional guide)

- **Action template:** one Action per file, focused and testable; dependencies **injected** (no `new`/`resolve` in bodies); `declare(strict_types=1)`; header tags `@intent`/`@see`/`@debt`.
- **Contract-First:** concrete Action → test; interfaces **only** where a real port/DI exists.
- **Lint/fmt:** `make check` (Pint + Prettier/ESLint, corrigindo antes de validar) is mandatory before MR.
- **Determinism:** inject clock/id generators; explicit ordering; idempotent migrations/seeds; no free `now()`/`rand()`; no flaky tests (ADR-001).
- **Session protocol:** `kd prime` → work → `kd write`/`kd sync` → `kd rewind`; write `.STAGING.md` (raiz, dotfile) before context compaction; template in `wiki/SESSION_LOG.md`; technical debt as `// @debt [ID]`.
- **Workflow/DoD:** `CONTRIBUTING.md`; visual/a11y source of truth: `wiki/DESIGN.md`.

## Boundaries

- `core` never imports `modules` nor `services` (service discovery is by convention/string);
  `services` never imports `modules`; `layout` consumes `core`/`services`.
- `modules/*` never imports `modules/*`. Communicate via **native Laravel Events/Listeners** and **Core ports** (interfaces).
- Cross-module widget/banner: provider registered in the Core `WidgetRegistry`; destination references the `id` — **embed allowed, import forbidden**.
- Violation = red build (`make check` → `tools/arch-check`).

## Modules (ADR-014)

- `src/modules/{Group}/{Module}/`; the **group comes from the folder**.
- One root file: `ModuleProvider.php` (`register(): void` + `config(): array`).
- Core scans `*/*`, injects `group`, loads migrations/views/routes **by convention** (no per-module ServiceProvider), builds a cached manifest (zero DB on boot).
- Create: `make:module Group/Module`.

## Services (ADR-015)

- **Agnostic + headless**; **always available** to any module; standard implementation + guide.
- Optional **config/usage Blade component** (e.g. `<x-service-seo::input>`).
- **Entry point:** `src/services/{Name}/ServiceProvider.php` (root) implements `Core\Contracts\Service`
  (`contracts()`+`config()`); Core discovers it by convention (`ServiceDiscoveryService`) and absorbs
  the bindings + catalog — **no manual registration** in `bootstrap/providers.php`.
- Module depends on the **service contract (interface)**, never the implementation: `Services\{Name}\{Contracts,DTOs,Enums}\*`. Core keeps only the contracts it implements and **cross-module ports**.
- **Core path (F2):** `Media`, `Seo`, `RichText`, `Audit` (+ `Security`). **Backlog:** `Search`, `Theme`,
  `Notifications`, `LandingSections`, `FeedRSS`, `ExcelImporter`, `DigitalSignature` (see `wiki/backlog/`).
  Only promote a service with real demand and a decision — simplicity first.
- **Media + Gallery (ADR-026):** single image flow — `MediaInputer` (one entry, MIME-routed) →
  AVIF/WebP variants `[360,720,1200,1920,2560]` + `width/height` metadata (CLS 0) → `gallery_media`
  catalog → `<x-service-media::picture>`. **No HTML-rewriting middleware**; Flysystem + queue + jsonb;
  shared-UI services may expose an **admin surface** (`/cms` only, never public). **Lote** (emenda):
  20–50 imagens = **N requisições de 1 arquivo** (nunca multipart) com `batch_id`, orçamento
  50 arquivos/250 MiB, cota **2 GiB/dia**, rate 120/min e concorrência 3; o commit do Gallery
  exige alt por imagem e reporta falhas parciais.
- **SEO (ADR-027):** `services/Seo` headless + `metadata['seo']` (jsonb) + `<x-service-seo::tags>`
  — o componente é a **fonte da verdade do `<head>`** (o shell do portal só o inclui; o sufixo do
  `<title>` pertence ao service) — com JSON-LD **`@graph`** (`GovernmentOrganization`+`WebSite`/`SearchAction`+`GovernmentPage`
  eMAG + module schema incl. **`Dataset`**); dynamic root files (`robots.txt`/`sitemap.xml`/
  `security.txt`/manifest) with **`ETag`/304** — the **`sitemap.xml`** is fed by modules through the
  **`Core\Contracts\SitemapRegistry`** (`Ports\SitemapSource`, registered in `register()`); **real 404**
  (`X-Robots-Tag: noindex` + `http.not_found` log) **+ 301 redirects** (`seo.redirects`, portal-only,
  live route never shadowed); `hreflang`/canonical per locale;
  **privacy-first analytics** (HyperLogLog, no GA by default); native active indexing (IndexNow). **No trait.**

## Access, activation, telemetry, audit & i18n — conventions (ADR-012/024/033/034/035)

- **Permissions (ADR-033):** token `{module}.{ver|editar|aprovar|arquivar}`; a module **declares**
  (`'permissions' => Permission::ACTIONS` in `ModuleProvider::config()`) and **enforces**
  (`Permission::authorize()`/`allows()`, **deny-by-default**); an undeclared action never becomes an ability.
- **Roles (ADR-033):** `core_roles.permissoes` **jsonb** (valid tokens only, no duplicates;
  `CHECK jsonb_typeof='array'`), **one role per user** (`cms_users.role_id`); **no inheritance**;
  no `PermissionService`/sentinel.
- **`Suporte` group (ADR-033):** a **normal** module group, force-hidden by the Core for everyone but
  the founder (`suporte@maxima.inf.br`): invisible in the manifest/nav (`grouped()`) and **404** in
  routing (`EnsureSupportGroupAccess`), even for the top role.
- **Module activation (ADR-034):** state is **data** (`core_module_discovery_settings` →
  `modules_activation`); `ModuleActivation` (Valkey + DB, **fail-open**); guard `module.activation`
  → **404** on portal/cms/api; **routes always registered**; toggle `core:module:activate` +
  **instant cache invalidation**; manifest/nav filter inactive modules. **Never** a flags engine.
- **Telemetry (ADR-024):** inject `Core\Contracts\Telemetry` (never instantiate); spans
  `{service}.{operation}` / `{module}.{action}` (+ core `{domain}.{op}`); attributes `scalar|null`, **no
  PII**; wrap the **domain operation**; tests use `RecordingTelemetry`; **every** Action in
  `Logic/Actions/*` emits `{module}.{action}` — and each instrumented operation also emits a domain
  `log_event` with the **same** `{module}.{action}` (OTLP × stderr consumers); `Clock`/`IdGen` are not
  spanned per call.
- **Audit (ADR-035):** evidence lives in the **audit trail** (`core_audit_logs`, immutable + BLAKE3),
  **not** in the technical log. Write via `AuditWriter::record(AuditEntry)` (the `GlobalAuditSubscriber`
  auto-enriches every model mutation); event vocabulary `{module}.{action}`; actor is a
  `{id,name,email}` **snapshot**; **CMS = full** (mutations + sensitive reads + login), **portal = none**
  per citizen (`audit.access` is CMS-only). Never log a high-cardinality technical event into the trail.
  Explicit events use `AuditEntry::event(...)`/`access(...)` (no model needed) and are **always
  written** — the area policy gates only the **ambient** mutation trail (portal = none). Retention:
  `config('audit.retention.days')` + `audit:expunge` (partition-level, respects
  `core_audit_legal_holds`; scheduled monthly). **`{module}.{action}` is the single vocabulary**
  shared by permission · span · domain log · audit event.
- **i18n (ADR-012, spec 13):** the **owner of the fragment owns its translation** — module fragment
  strings in `src/modules/{Grupo}/{Modulo}/Lang/{locale}/{section}.php` (namespace = module token,
  loaded by convention; **the file name is the key section**: `Lang/{locale}/index.php` answers
  `token::index.title`), shell strings in `src/layout/Lang`, service strings in `src/services/*/Lang`;
  **locale comes from the URL** (`Core\Localization\Locales`: default in the root, prefix for
  alternates; no session/cookie on the portal) — the portal route group is registered **twice**
  (unprefixed + `{locale?}`) because a leading optional parameter never matches the bare path, and
  `URL::defaults` keeps `route()` on the active locale; the middleware drops the `locale` parameter
  from the route (params reach controllers **positionally**). Dates use native `intl`
  (`Core\Localization\LocalizedDate`), `hreflang` + `x-default` come from the shell, and the
  **manifest stores translation keys** (`token::menu.title`), never text. **Internal navigation uses
  `route()`** (it carries the locale via `URL::defaults`); a path **without** a named route uses
  `localized_url('/path')` — never `url('/path')`/`href="/path"`, which silently drop the language
  (`ModuleI18nTest` fails the build). The guarantee is **proved on the output**, not on discipline:
  `LocaleUrlInvariantTest` crawls the rendered HTML (no internal link leaves the locale) and every
  portal route (all go through the locale resolver and generate inside the locale) — future modules
  and config-driven links are covered automatically. **Content** per locale
  (`jsonb` + `LocaleCast`, CMS tab) is F4.I1. No package, no hardcoded visible string in a view;
  `ModuleI18nTest` enforces the 4 locales, key parity and namespace ownership.

## Layout & shells (ADR-016)

**Three layers, never mixed: structure ≠ content ≠ data.**

- **Structure (`src/layout`)**: shell + regions + grid + theme + a11y + slots. `Engine/` holds the
  concrete classes (`LayoutPreset`+`PresetConfig`, `SlotRegistry`/`SlotProvider`/`SlotConfig`,
  `ThemeResolver`/`ThemeTokens`, `A11yPreferences`/`A11yFontScale`); `Components/` is the public
  Blade API; `Portal/` and `Cms/` are internal partials/regions; `Assets/` is the design system;
  `Lang/` holds the shell strings.
- **Content (module)**: the module view uses **exactly one** shell component — `<x-layout::portal>`
  (public) or `<x-layout::cms>` (admin) — declares only the **standard fields** (`title`,
  `description`, `breadcrumbs`, `canonical`, `robots`, `image`, `schemas`) and fills **only the
  central `<main>`**. It never builds the shell, sidebars, header/footer or a11y bar, and never
  imports another module.
- **Data**: tokens/slot config live in `jsonb` — never HTML/CSS/classes (ADR-017).
- **Grid lives in CSS, not PHP**: the shell emits `data-preset`/`data-left`/`data-right` and CSS
  resolves the layout (mobile-first `fr`+`gap`); empty slot regions **collapse**
  (`SlotRegistry::has()`); subgrid is for cross-component alignment with an `@supports` fallback.
- **Accessibility is universal and last**: `@layer a11y` (declared last in `tokens.css`) overrides
  colours/typography/contrast/images for **any** screen **without touching the markup** — high
  contrast (B&W), dyslexia, increased spacing, grayscale images, reduced motion. State is rendered
  **server-side from cookies** (`data-a11y-*` on `<html>`, no FOUC, CSP-clean); the no-JS baseline
  uses `prefers-contrast`/`forced-colors`/`prefers-reduced-motion`. VLibras loads **on demand**.
- **Theme overrides are bounded by a schema** (`config/design.php` + `Engine/ThemeSchema`): only
  semantic tokens in **enums/scales and validated CSS colours** — a value outside the schema is
  **ignored** (the previous layer wins), so the Support panel can never break the layout. Precedence:
  `default → global → preset → URL profile → instance`, a11y always last. Delivery is
  **`GET /theme.css`** (same-origin, `ETag`/`304`, no inline `<style>` — ADR-022); with no overrides
  the page makes **no extra request**.
- **Extension point**: modules register `SlotProvider`s (ADR-016); `/` (home) is itself a module
  (`Modules\Portal\Home`). Widget/banner engines (`WidgetSource`, `LayoutProfile`) are **out of F3**.
- **Structural test is a gate**: every module page view uses one shell and contains no
  `<html>/<head>/<style>/style="…"`/`x-app-layout` (see `LayoutContractTest`).
- **No reactive UI** (ADR-009): the shell is native Blade + minimal vanilla JS (vendored).

## CMS (ADR-009)

- Own independent shell `src/layout/Cms` (`Layout\Cms`, views as `cms::`), AdminLTE4-inspired.
  **"Spaces, not control"**: the module uses **`<x-layout::cms>`** (mirror of `<x-layout::portal>`),
  informs the standard fields (`title`, `breadcrumbs`) and fills **only the central `<main>`**; extra
  spaces are named slots/stacks (`actions`, `styles`, `scripts`, `modals`). The shell provides header,
  sidebar, footer, accessibility and the grid.
- Sidebar from manifest (grouped); native Ctrl+K search. **No Livewire/Filament, no Bootstrap/jQuery/CDN** (reactive UI would need a new ADR).
- **Access (ADR-037):** native session auth over **`cms_users`** (guard `web` + eloquent provider) —
  **no** Fortify/Breeze/JWT; routes belong to the CMS area, served on its **own domain** (cookie/session
  isolated from the portal): `GET/POST /login`, `POST /logout`, `GET /` (minimal authenticated panel, the
  first screen the a11y/E2E gate can reach). Argon2id (ADR-020), `session()->regenerate()` on login,
  **generic** failure (never reveals whether the e-mail exists), logout via `POST` + CSRF, login throttled
  by **e-mail + IP** (`ThrottlePanelLogin`, 429 + `Retry-After` — ADR-021) and audited for free
  (`core.login`/`core.logout`/`core.login_failed` — ADR-035). Out of scope: gov.br SSO, 2FA, password
  reset, "remember me".
- **Panel navigation comes from the manifest** (`Core\Internal\Navigation\PanelMenu`): grouped by
  module folder, ordered by `position` (tie-break by name → deterministic), **deny-by-default**
  (`{module}.ver`), inactive modules and the `Suporte` group filtered out (ADR-033/034), and an item
  whose route **does not exist** or **belongs to another area** is omitted — never a dead link, never a
  link out of the panel (`PlatformArea::domain()` is the single source for an area domain). Group titles
  are translation keys (`layout::shell.group_{snake}`, asserted in the four locales). The panel shell
  owns the screen **`<h1>`** (from the module `title`); a module CMS view must not declare its own
  heading (`LayoutContractTest`). Quick navigation (**Ctrl+K**) serves the same data in a **native
  popover**: it opens and stays navigable **without JS** (JS only adds filtering, arrow keys and `Esc`
  with focus return).
- **The shell is data (ADR-036):** identity (`title`, `logo`, `--cms-*` colors in **`oklch()`**; `null`
  inherits the tenant theme), **region presence** (`header`/`footer`/`sidebar`) and **sidebar position**
  (`left|right|orbital`) come from `config/cms.php` (the instance layer overrides it) and are resolved
  into tokens by `CmsShellResolver` — the shell is a **pure function** of that data (`CmsShellTest`).
  Position is **CSS** (logical properties, no JS measurement → CLS 0) and the **DOM order follows it**
  (focus order = visual order, WCAG 2.4.3). `orbital` is a floating trigger + **native Popover** menu:
  usable **without JS** and never the only navigation path. Texts come from `layout::shell.*` (4 locales);
  user preference may change **state**, never structure.

## Rich text editor (ADR-028)

- **TipTap is the one and only rich editor** (vendored via npm+Vite, no CDN). Content is stored as
  **AST JSON** (`jsonb`) — **never HTML**; the portal renders it via `src/services/RichText`
  (`ContentASTParser`, **one method per node/mark**) to **semantic HTML**.
- Images inserted via the **Media** service render as `<picture>` (ADR-026); links are real `<a>`
  with `rel`/`target`/`aria-label`; cross-links use the **Search** service. No free-HTML node.
- Editor dialogs are native `<dialog>` applied by a `[data-dialog-apply]` button — **never a nested
  `<form>`** (invalid HTML); TipTap runs with `injectCSS: false` (ProseMirror base CSS imported into
  the bundle — no injected `<style>`, CSP without inline).

## Data (ADR-007 + ADR-017)

- **PG 18 (container, no exposed port)**: `text` (not `varchar(n)`), `numeric`, `timestamptz`, `NOT NULL`, `jsonb`; **`uuidv7()` nativo**.
- **Modern features allowed**: generated columns, `INCLUDE`, skip scan, async IO (`io_method=worker`), `MERGE` (sparingly).
- Column change: add nullable → backfill → `CHECK NOT VALID` + `VALIDATE` + `SET NOT NULL`.
- `down()` never drops → `backup_<table>_<ts>`. **Seeds = demo only** (ADR-041): they live **only** in `src/demonstration/{Module}/{Seed}.php` (`Demonstration\{Module}`), never inside `core`/`services`/`layout`/`modules`, never imported by production code; reference data from migrations (idempotent).
- **PostgreSQL is the only target — no driver branching** (never `$isPgsql`/`getDriverName()`); pure PG DDL.
  `config/database.php` keeps `env('DB_CONNECTION','pgsql')` (+ an **exceptional, one-off `mysql`** connection
  for a rare client; never in the main flow/tests).
- **Idempotent migrations:** `Schema::hasTable` guards, `CREATE INDEX IF NOT EXISTS`, `DROP TRIGGER IF EXISTS`,
  `ON CONFLICT` for reference data; `migrate:fresh` without seeder usable (spec 10).

## Storage (ADR-030)

- **S3-like from day zero.** Domain files (media, attachments, PDFs, exports) live on the Laravel **`s3`** disk — never local disk in the domain.
- Local backend = **RustFS** (`rustfs/rustfs`, Apache-2.0) in the `storage` service (internal, **no exposed port**, health `/health`); data at `${DATA_PATH}/rustfs/data`.
- Swap to the client's **AWS S3** by changing **`.env` only** (`AWS_*`, `AWS_USE_PATH_STYLE_ENDPOINT`; path-style is required for RustFS).

## Data governance (ADR-018 + ADR-019)

- **Open data:** a dataset is a domain entity; metadata **DCAT/INDA** + `schema.org/Dataset`;
  machine-readable export (**CSV/JSON/XML**) with stable URL/`ETag`/checksum; open license
  default **CC-BY 4.0**. Catalog `/dados-abertos` + public API. Module `Portal/DadosAbertos`
  (`dados_abertos.datasets`, data in `jsonb`); export deterministic (SHA-256 checksum → `ETag`/304);
  API `api.<base>/v1/datasets` read-only, rate-limited, no PII; sitemap source registered in the Core
  `SitemapRegistry` (like `Noticias`/`Home`).
- **LGPD:** data classification; **DSAR** (access/correct/delete/portability) as module
  `Portal/Lgpd` — personal data **encrypted** (`encrypted` cast), protocol + legal deadline, explicit
  audit, handling in the CMS; retention + scheduled expungement (`audit:expunge`, partition-level)
  + `legal hold` (`core_audit_legal_holds`, never `DROP` history) — domain retention is a Core port
  (`Core\Contracts\RetentionRegistry` + `Ports\RetentionTarget`, command `retention:expunge`);
  personal-data access audit; incident runbook (ANPD, `wiki/runbooks/`); DPO channel
  (`/politica-de-privacidade`); **privacy-first analytics** (no third-party by default;
  Google Analytics is the single opt-in exception, ADR-002).
- **Rate limiting (day zero, ADR-021):** 3 layers — server (Apache `mod_reqtimeout`/`mod_qos`/`mod_security`, `TrustProxies`), app (`RateLimiter` on **Valkey**, `throttle:`), forms (honeypot + time-trap + per-form/session limit; captcha last resort). `429` RFC 7807 + `Retry-After`; no DB on hot path.
- **Backlog (not MVP):** backup/DR of DB + assets is **priority**; ICP-Brasil signing, email deliverability, gov.br SSO, CDN/edge are future. See `wiki/backlog/`.
- **Response cache (ADR-022, from v6):** public `Cache-Control` + ETag/304 for content GET; **CSRF stays out of cacheable HTML** (form pages `private`); **CSP without inline** (cacheable, no per-request nonce in cached HTML); Vite assets `immutable`; optional Valkey FPC; **no runtime HTML minify**.

## Frontend (ADR-010)

- **Icons are Lucide only** (brand marks from Simple Icons), vendored SVGs rendered through
  `<x-ui::icon>` (`Layout\Design\IconSet`); both `name="home"` and `name="lucide-home"` resolve,
  and an unknown name renders nothing (never a broken CDN request). Gate: `IconTest` — every icon
  used in the layout **and** every module manifest icon must resolve to a vendored SVG.
- **Tailwind 4 + daisyUI 5** are the styling base (utilities + components); custom CSS only in
  `@layer components` for what utilities can't express (shell grid, skip links, subgrid alignment).
- **`oklch()` is the colour default** — `hex`/`rgb` only as a justified exception (e.g. PWA manifest).
- **Modern units always**: `rem`, `%`, `fr`, `dvh`, `clamp()`; **`px` is the last resort** (target: zero).
  Physical units (`pt`/`cm`/`mm`/`in`) are allowed **only in print media**. Gate: `DesignTokensTest`.
- **daisyUI is themed from our tokens** (`tokens.css` bridge, `data-theme="concordia"`): daisyUI
  components inherit `--tenant-*`, our radius and `--border: 0.0625rem` (rem, not px).
- **JavaScript is ES2025** (ADR-010; `.agents/skills/javascript-moderno`): ESM, `const`/`let` (never `var`),
  `===`, `??`/`?.`, `async/await`, `Promise.withResolvers`; assets live in `src/**/Assets/js` (the
  Laravel `resources/js` boilerplate was removed — no `axios`). Gate: `eslint.config.js` with
  `ecmaVersion: 2025` + Core Rules (`no-var`, `prefer-const`, `eqeqeq`, `prefer-template`,
  `no-throw-literal`).
- **Accessibility is a CI gate** (`make check E2E=1` + `SNIFF_A11Y_STRICT=1 make check`), not a report:
  see `wiki/specs/04-acessibilidade-ux.md` (checklist eMAG/WCAG 2.2 AA and the exception ledger).
- **Pagination is project-owned** (ADR-025; `Paginator::defaultView('portal::pagination.tailwind')`, shared by
  portal and CMS): semantic `<nav>`, `aria-current="page"`, `rel=prev/next`, labels from
  `layout::shell.pagination_*` (4 locales) and targets **≥ 2.75rem** (WCAG 2.2 SC 2.5.8). Gate:
  `06-alvos-minimos` (mobile ≥ 44px).

- Semantic HTML first; native elements (`<dialog>`,`<details>`,popover); ARIA only when needed.
- **Minimal JS** (progressive enhancement); public content works with **JS disabled**.
- Modern CSS: `@layer` (a11y last), `@property`, `oklch`, `clamp` (min/max in `rem`), subgrid + `@supports` fallback, container queries; no `!important`/inline styles.
- Content typography default **Atkinson Hyperlegible Next** (vendored WOFF2, `--font-content`, user-adjustable in global config).
- PWA (manifest + SW, degradable). Zero CDN (VLibras + opt-in Google Analytics only).
- **Performance budget no CI:** o build gera `.br`/`.gz` **determinísticos** (plugin `concordia:precompress`,
  `node:zlib`; o Caddy serve com `precompressed br zstd gzip`); `PerformanceBudgetTest` cobra o
  **orçamento de bytes** e o **custo de banco** (anti-N+1); `11-performance.spec.ts` cobra **CLS ≤ 0,1** e
  ausência de recursos bloqueantes. LCP/INP são de campo (RUM) — nunca teste de parede (ADR-001).

## Approval / test gates

- **`make check` green** — PHPStan max (**0 erros — sem baseline**, ADR-040), cobertura `src/`
  **≥90%** (ADR-038), todos os gates, e2e/a11y/perf.
- `make check` green **twice in a row** (no flaky — determinism, ADR-001).
- Boundaries (`tools/arch-check`), grep-friendly (`tools/name-check`), `tools/migration-check`,
  `tools/vendor-check` — todos **dentro** do `make check`.
- `optimize-check`/`optimize-verify` (strict_types · `env()` only in `config/` · no route closures ·
  caches) — dentro do `make check`.
- `make check E2E=1` — Playwright + `@axe-core/playwright` over 33 histories (`src/tests-e2e/histories` ×
  `tests`), 6 projects (desktop, mobile `Pixel 7`, **no JS**, CMS desktop, CMS mobile, CMS serial);
  the serial project runs **first** (`dependencies`) because spec 18 mutates global theme/shell/menu
  state; axe with **no A/AA violations**.
  Also proves **zero external requests** (ADR-002) and preferences surviving navigation.
- a etapa **`a11y`** do `make check` — sniffCSS gate (desktop 1366×768 **and** mobile 390×844); it **requires HTTP 200**
  before measuring (never a green over an error page). `SNIFF_A11Y_STRICT=1` fails on any `warn`
  **not** in the documented ledger (`tools/sniff-gate.sh` + spec 04 §6); a measured `fail` always
  blocks. **Contrast is arbitrated by axe** (it composites ancestors); sniff measures targets,
  uniformity and animation.
- `php artisan optimize` green (no `env()` outside `config/`; no route closures).
- **Structural tests** enforce the transversals — every Action/service emits span **and** domain
  `log_event`; every module with views ships the 4-locale `Lang/`. Breaking them fails the build.
- Cross-module import fails the build. An a11y violation blocks merge.

## Knowledge & tasks before execution

1. `kd prime` → 2. `kd ask`/`kd rewind` for context → 3. plan with `kd task` (epic/issue/task) →
4. implement + test → 5. `kd write` decisions/errors/risks (`--anchor`) → 6. `kd sync`.
Also: read the relevant ADR/spec/plan before editing; one ADR per relevant decision; write `.STAGING.md` (raiz, dotfile — não `_STAGING.md`/`STAGING.md`) before context compaction.

## Agent safety

- Never `rm -rf`; discard to `.pi/trash/`.
- **Git is read-only for the agent: NEVER commit/push/tag/rewrite** (iron rule §0); only the human commits.
- Run commands in the container, not the host.
- Never edit `vendor/` or `notas/` by hand. No secrets in the repo or in note bodies.

## Docs map

| What | Where |
|---|---|
| Constitution | `AGENTS.md` |
| Contributing flow / DoD | `CONTRIBUTING.md` |
| Decisions | `wiki/adr/` (ADR-001..055) |
| Plans + gates | `wiki/plan/00-index.md` (00..19) |
| **Implementation plan** | `wiki/implementation/README.md` (F0..F6, sequential, `kd task`) |
| Specs | `wiki/specs/` (index `README.md` · 06/08/09/10/11/12/16/17/18 · `make exec cmd='bash tools/docs-check.sh'` validates owner/status) |
| Backlog | `wiki/backlog/` |
| Design / visual tokens | `wiki/DESIGN.md` |
| Memory / tasks | `.knudge/` · `.agents/skills/kd/SKILL.md` |
| Reference skills | `.agents/skills/` — `laravel-12`, `php-84-correct`, `css-moderno`, `html-moderno`, `javascript-moderno`, `caddy`, `web-accessibility`, `web-performance`, `web-security-review`, `postgresql`, `vite`, `daisyui`, `pwa`, `playwright-ts-admin`, `sniff-css`, `mdn-web-api-docs`, `knudge`, `kd` (consult the relevant one before coding) |

<!-- sniff:start -->
<!-- sniff:version: 1 -->
# sniff — computed styles for LLMs

`sniff capture` is the source of truth for layout/style — never guess CSS, capture it.
Cycle: `sniff capture` → `sniff check` → `sniff diff`; the AI reads only the delta.

- Capture: `sniff capture -u <URL> -s <CSS> [--depth N] [--output summary|jsonl|json]`
(default `summary`, token-lean). Diff/check need `--no-summary` JSONL snapshots.
- Diff: `sniff diff <BASE> <HEAD> [--tolerance 0.5] [--stats-only]` (deterministic, offline).
- Checks: `sniff check -i <SNAP> [--rules] [--uniform]` (contrast, target size, focus, alt).
- Protocol: `sniff prime` (`--long` = full); `sniff self` = setup/completions/upgrade.

Common cases:
- Self-signed TLS: add `--ignore-certificate-errors` (or set `SNIFF_IGNORE_CERTIFICATE_ERRORS=1`).
- Auth: `--header "Name: Value"` on every request; persist a login with `--storage-state FILE`
(write it once with `--save-storage-state FILE`).
- Action-only UI (modal/dropdown): `--click SEL`, or ordered `--action "click:SEL:5000"`.
- Flutter/Dart app: `-u flutter://<device> --project DIR --depth N` (debug build only).
- Persist a snapshot: `--persist` writes `sniff/[domain]/[UTC]-[path]-[selector].jsonl`.

Contract: stdout = data (JSONL), stderr = logs; `--json` = {success, command, data?, error{code,message,retryable}, warnings?}.
Exit: 2 invalid, 3 not_found, 5 io, 70 internal.
<!-- sniff:end -->
