# P2 — Gestão de perfis e usuários

| Campo | Valor |
|---|---|
| **Fase** | P2 — Gestão de perfis e usuários (F7+) |
| **`kd` épico** | `epic_00plo89p` |
| **`kd` fatias** | `task_00lb846d` (S1) · `task_01s0w337` (S2) · `task_00iin9xp` (S3) · `task_002alac7` (S4) · `task_00f074wc` (S5) · `task_01pmtfn3` (S6) |
| **Âncoras** | `wiki/implementation-phases/P2-usuarios-perfis.md` §3, §5–§8 · `wiki/implementation-phases/TECNICO.md` §2/§5/§9/§10/§11 · ADR-033, ADR-037, ADR-035, ADR-020, ADR-021, ADR-025, ADR-017 · `wiki/specs/11-acesso-e-ativacao.md` · `wiki/specs/04-acessibilidade-ux.md` |
| **Status da revisão** | ✅ revisado (S1–S6 + correções da revisão 1 + verificação independente; `make check` com `a11y` verde) |

## Correções da revisão 1 (`task_00wl811i`)

> Rodada de correção após a revisão profunda (S1–S6 + fase). Status permanece **🟡 em revisão**;
> nada aqui declara a fase revisada. Cada linha tem teste que falha sem a correção.

| # | Achado (revisor) | Disposição | Teste / evidência |
|---|---|---|---|
| 1 | **Bloq.** qualquer operador podia reivindicar `suporte@maxima.inf.br` pelo `/perfil` (e criação/edição admin) e herdar `Gate::before` | **Corrigido**: `AcessoUserEmail::assertIdentityEditable(?User, email)` virou regra **bidirecional** e é chamada na criação, na edição administrativa e no perfil próprio; o provisionamento legítimo continua só no CLI `core:user:create --role=suporte` | `AcessoUsersCmsTest::no_web_write_can_claim_the_reserved_support_email` (422, e-mail inalterado, `isSupport()===false`, `Gate` nega) · `AcessoProfileCmsTest::an_operator_cannot_claim_the_reserved_support_email_from_the_profile` · `CmsUserCommandTest::the_support_role_is_reserved_for_the_team_email` (CLI recusa o e-mail reservado com outro perfil/sem perfil e cria só com `--role=suporte`) |
| 2 | **Bloq.** detentor de `acesso.editar` redefinia senha/editava com senha/desativava a conta `suporte` e herdava o passe livre | **Corrigido**: `AccessPolicy::assertUserNotProtected` (403/422; `isSupport()` passa) chamada no reset, na edição com senha e na desativação; a linha esconde essas ações para quem não é a equipe | `AcessoUsersCmsTest::the_support_account_cannot_be_taken_over_by_a_manager` (hashes iguais, `deactivated_at` nulo, login com a senha injetada falha) · `the_support_actor_may_edit_the_own_team_account` · `the_support_row_hides_the_destructive_actions_from_a_manager` · `AccessPolicyTest::test_a_manager_cannot_write_the_support_account` |
| 3 | **Alto** `AcessoUserUpdate` usava `Role` carregada **antes** da transação; snapshot velho pulava a guarda de último administrador | **Corrigido**: a Action relê o perfil sob lock e a `AccessPolicy` **também** relê o perfil de destino sob lock (`lockedRole`); o snapshot do request não é autoridade | `AccessPolicyTest::test_a_stale_role_snapshot_cannot_bypass_the_last_administrator_guard` (vermelho no código antigo) · `test_the_administrator_guard_reads_the_role_permissions_under_lock` |
| 4 | **Alto** ordem de lock invertida (alvo antes do conjunto) → ciclo/`40P01` | **Corrigido**: ordem global única **perfis admin (`id`) → usuários admin ativos (`id`) → alvo** em `lockUserForWrite`/`lockRoleForWrite`; update/desativar/ativar/reset e update/delete de perfil não pré-travam fora da ordem | `AccessPolicyTest::test_user_writes_lock_administration_before_the_target_row` e `test_role_writes_lock_administration_before_the_target_row` (prova determinística da ordem dos `FOR UPDATE`) |
| 5 | **Bloq.** `tools/sniff-gate.sh` aprovava sem a linha de resumo (0/0/0) | **Corrigido**: `parse_summary` fail-closed (sem linha, contadores inválidos ou total 0 → `rc=1`; snapshot 0 byte → `rc=1`); `sniff capture` tem timeout `SNIFF_CAPTURE_TIMEOUT` (default 120 s) e falha explícita em vez de travar o `make check`; autoteste offline | `tools/gates-selftest.sh` §`sniff-gate (parser offline)`: resumo válido OK; sem resumo/vazio/zerado **falham** (4 casos) · timeout de 1 s contra página estática → `rc=1` em 2 s |
| 6 | **Alto** login sensível a caixa enquanto a identidade é case-insensitive | **Corrigido**: `LoginRequest::prepareForValidation()` faz `mb_strtolower(trim())`; o texto/CLI “não diferencia caixa” passou a ser verdade | `CmsAuthTest::the_login_accepts_the_email_in_any_case` |
| 7 | **Médio** enumeração de usuário por tempo (ramo sem conta não roda Argon2id) | **Corrigido**: `AuthenticateController` roda `Hash::check` contra hash-sentinela Argon2id fixo quando não há conta ativa | `CmsAuthTest::an_unknown_email_still_pays_the_hash_cost_to_avoid_a_timing_oracle` |
| 8 | **Médio** operador só-`acesso.ver` via ações destrutivas (403 morto) | **Corrigido**: a listagem passa `canEdit`/`actorIsSupport` e esconde criar/editar/desativar/redefinir | `AcessoUsersCmsTest::a_viewer_sees_no_destructive_control_on_the_users_list` |
| 9 | **Médio** `<td>` aninhado na célula de ações (P2 + 4 telas legadas) | **Corrigido**: o `<td>` externo saiu de usuários, perfis, DadosAbertos, LGPD e Menus (índice + árvore); `x-cms::table-actions` é a única célula | `AcessoUsersCmsTest::p2_tables_have_exactly_one_cell_per_header_column` (contagem `<td>` = `<th scope="col">` em `/usuarios` e `/papeis`) |
| 10 | **Médio** specs 19/21/22 com decaimento (página 1) e sem limpeza/asserção real | **Corrigido**: 19/20/21/22 filtram por `?q=<email/nome>`; 21 prova senha antiga recusada e desativa o operador ao final; 22 prova **persistência** da permissão (coluna = 1) na lista filtrada | `src/tests-e2e/tests/{19,20,21,22}-*.spec.ts` · `tsc --noEmit` OK · `playwright test --list` = **123 testes** |
| 11 | **Médio** README do E2E dizia 121 (120+1) e a fase dizia “gates verdes” com `a11y` bloqueado | **Corrigido**: README = **123 testes (122 passed + 1 skip condicional)**; tabela da fase = “implementação verde, `a11y` pendente de host com `sniff capture` funcional” | `src/tests-e2e/README.md` · `wiki/implementation-phases/README.md` §8 |
| 12 | **Baixo** spec 11 sem as superfícies da P2 (claim do S6 era falso) | **Corrigido**: §3 (identidade reservada/conta protegida), §6 “Gestão de usuários e perfis (P2)” (rotas, estado, unicidade, políticas, ordem de locks, eventos), §7 (testes/gate fail-closed) | `wiki/specs/11-acesso-e-ativacao.md` §3/§6/§7 |
| 13 | **Médio** mutações privilegiadas só apareciam como `core.updated` | **Corrigido**: eventos explícitos `acesso.user_role_changed`, `acesso.user_password_reset` e `acesso.role_permissions_changed` (ids/tokens, sem PII/senha), além da trilha ambiente | `AcessoUsersCmsTest::privileged_mutations_emit_explicit_access_audit_events` · `an_administrator_resets_the_password_and_the_audit_has_no_password_value` |
| 14 | **Baixo** perfil protegido aparecia em `/papeis` contra a ADR-033 | **Corrigido**: a listagem usa `Role::assignable()` (o `suporte` não é visualizado); rota direta continua recusando | `AcessoRolesCmsTest::the_support_role_is_protected_on_every_route` (suporte ausente da lista) |
| 15a | **Baixo** `cms_users_active_idx` documentava “não excluída e não desativada” mas só filtrava desativadas | **Corrigido**: predicado `deactivated_at IS NULL AND deleted_at IS NULL` + comentário; asserção do `indexdef` | `MigrationIdempotencyTest::test_user_state_migration_is_idempotent_and_indexes_the_active_set` |
| 15b | **Baixo** nomes de perfil duplicados deixavam `<option>` ambíguos | **Corrigido**: unicidade sem caixa no request **e** na Action (`Role::nameTaken`), ignorando o próprio perfil na edição | `AcessoRolesCmsTest::a_duplicate_role_name_is_refused_regardless_of_case` |
| 15c | **Baixo** reset de senha não revoga sessões paralelas | **Aceito (risco MVP documentado)**: o kill switch é desativar a conta (`EnsureActiveUser`); revogação global exigiria store com índice por usuário — fora do escopo da fase | Risco em §S5 “Riscos residuais” (mantido) |
| extra 1 | **P3** ativar/desativar idempotente ainda emitia `log_event` | **Corrigido**: as Actions devolvem `changed` e só logam quando houve mutação | `AcessoUsersCmsTest::it_deactivates_and_reactivates_a_user_and_audits_the_change` (comportamento preservado; sem log duplicado no no-op) |
| extra 2 | **P2** teste do link “Meu perfil” não podia falhar (a própria tela satisfazia as asserções) | **Corrigido**: a prova agora busca o **dashboard** sem `acesso.*` e exige o `href` exato do link do shell | `AcessoProfileCmsTest::the_profile_link_is_available_without_the_access_permission` |

**Gates desta rodada:** ver “Evidência” abaixo (`make check`, PHPUnit, PHPStan, E2E e o bloqueio de host
do `sniff capture`). A fase **não** é declarada revisada.

## O que foi implementado

### S1 — Migração de estado e blindagem da autenticação (`task_00lb846d`)

- **`cms_users.deactivated_at timestamptz null`** (data, não flag `is_active`) + índice parcial
  `cms_users_active_idx (id) WHERE deactivated_at IS NULL AND deleted_at IS NULL`, adicionados por
  migração **aditiva e idempotente** (`hasColumn`/`CREATE INDEX IF NOT EXISTS`); `down()` é no-op deliberado (ADR-017:
  estado de domínio e contrato de unicidade não são revertidos por DROP).
- **Unicidade de e-mail sem diferenciar caixa:** índice único parcial
  `cms_users_email_lower_unique ON cms_users (lower(email)) WHERE deleted_at IS NULL`. A coluna
  `email` continua única (inclui linhas com soft delete), então a identidade excluída **não** é
  reciclada. Duplicatas pré-existentes só são possíveis por caixa: a migração **lista os ids**
  (sem PII), loga `warning` com contagem/ids e **aborta** — nada é apagado, renomeado ou fundido.
- **`AuthenticateController`**: a credencial passou a incluir `deactivated_at => null`
  (`Auth::attempt` vira `whereNull`), então conta desativada não autentica; a mensagem continua
  **genérica** (`layout::shell.invalid_credentials`) e a falha segue contabilizada no mesmo freio
  e-mail+IP (`ThrottlePanelLogin`, 429 + `Retry-After` preservados).
- **`Core\Internal\Http\Middleware\EnsureActiveUser`** registrado no **grupo da área CMS**
  (`AreaRegistrar`, ao lado de `audit.access`), o que cobre rotas base do painel, rotas de módulos
  (o `auth` injetado por `requireModuleRoutes`) e superfícies `/cms` de services. Sessão de conta
  desativada → `AuditEntry::event('core.session_deactivated')` + `log_event` (só id),
  `Auth::logout()`, `session()->invalidate()` + `regenerateToken()` e redirect ao login.
- **`core:user:create`**: criação e `--force` gravam `deactivated_at = null` (bootstrap sempre ativa;
  `--force` também reativa e desfaz soft delete). Sem flags `--activate/--deactivate` (o ciclo é da
  tela, P2.S3). `deactivated_at` **não** entra em `$fillable` (mass assignment de estado é da Action
  da tela), com default `deactivated_at = null` no modelo para o modo estrito
  (`Model::shouldBeStrict`).
- **Modelo `User`**: cast `datetime` para `deactivated_at` (ADR-007: `timestamptz`).

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

- Telas/rotas de usuários e perfis, perfil próprio, `acesso.*` span/log das mutações: S3–S5
  (a S2 entrega catálogo e políticas; não há caminho de escrita novo para instrumentar).
- Nenhum endpoint novo, nenhuma permissão nova, nenhuma string i18n nova (o guard não expõe
  mensagem: conta desativada volta ao login sem revelar estado).
- E2E: a S1 é backend/estado; a jornada “desativar → login falha” depende da tela da S3 e é provada
  no S6.

## Arquivos para revisar

- `src/core/Area/Cms/Database/Migrations/2026_10_24_000000_add_deactivated_at_and_email_uniqueness_to_cms_users_table.php`
- `src/core/Area/Cms/Models/User.php`
- `src/core/Area/Cms/Logic/Controllers/AuthenticateController.php`
- `src/core/Area/Cms/Console/CreateUserCommand.php`
- `src/core/Internal/Http/Middleware/EnsureActiveUser.php`
- `src/core/Internal/Routing/AreaRegistrar.php`
- `tests/Feature/CmsAuthTest.php` · `tests/Feature/CmsUserCommandTest.php` ·
  `tests/Feature/CmsUserEmailUniquenessTest.php` · `tests/Feature/MigrationIdempotencyTest.php`

## Testes e evidência

- `CmsAuthTest`: `a_deactivated_account_cannot_log_in` (senha correta + conta desativada → mesma
  mensagem genérica, `assertGuest`, `core.login_failed` na trilha);
  `an_open_session_of_a_deactivated_account_is_ended_on_the_next_request` (redirect ao login,
  `assertGuest`, `core.session_deactivated` com `entity_type`/`entity_id`, e o request seguinte
  continua convidado).
- `CmsUserEmailUniquenessTest`: e-mail único sem diferenciar caixa (`QueryException` na segunda
  gravação) e e-mail de conta com soft delete **não** recriável.
- `CmsUserCommandTest`: criação e `--force` deixam a conta ativa; identidade soft-deleted é
  restaurada (mesmo id, `deleted_at`/`deactivated_at` nulos, uma única linha), nunca duplicada.
- `MigrationIdempotencyTest`: migração idempotente (2×) com coluna `timestamp with time zone` e
  índices `lower(email)`/`deactivated_at IS NULL`; duplicatas de caixa abortam com relatório de ids
  e **sem** alterar/remover contas.
- `make check`: **0 FAIL** em duas execuções (25 OK · 8 SKIP; 24 OK · 9 SKIP — os SKIP são
  pré-condições idempotentes por staleness). `make check E2E=1`: **24 OK · 0 FAIL · 9 SKIP**,
  `e2e` **117 passed** (1,5m), `perf` 10 passed, `a11y` OK; cobertura `src/` = **92,95%**
  (piso 90%), PHPStan `max` **0** erros; `git diff --check` limpo.

## Pontos de revisão

- D1/D2: migração idempotente, `down()` não destrutivo, sem `env()`/closure/`new` em corpo.
- D3/D10: estado por data (`timestamptz`) em vez de flag; unicidade no banco (não só na app);
  soft delete preservado; nenhuma coluna de UI.
- D9/D11/D12: vocabulário `core.session_deactivated`; auditoria com ator snapshot e sem senha/PII
  (só id); `log_event` escalar.
- D13: red→green por comportamento; sem `sleep`/relógio real (o teste usa `now()` apenas para
  gravar o estado desativado, sem asserção temporal).
- D8: nenhuma string visível nova.

## Riscos residuais

- **Reuso de identidade com caixa diferente após soft delete:** o índice `lower(email)` é parcial
  (`deleted_at IS NULL`) por contrato; a coluna `email` (única, global) barra o reuso **exato**. O
  caso “mesma caixa diferente de conta excluída” é barrado no `core:user:create` (busca
  `withTrashed` + `lower(email)`) e **deve** ser barrado na Action da S3 (checagem + 422), nunca só
  pelo índice.
- **Duplicatas legadas:** a migração aborta de propósito; a resolução (renomear/arquivar) é humana e
  fica registrada nos ids do erro/log. Base nova (ADR-042) nasce sem duplicatas.
- **Sessões existentes em outro store:** o `SessionGuard` invalida a sessão do request corrente;
  com store compartilhado (Valkey) a invalidação vale para o próximo request de qualquer nó, que é
  exatamente o contrato do middleware.

## Notas da revisão

Preenchida no S6 (revisão da fase). Nada aqui declara a fase revisada.

## S2 — Catálogo de permissões e políticas de segurança (`task_01s0w337`)

### O que foi implementado

- **`Core\Access\PermissionCatalog`** (`final`, DI de `ModuleDiscoveryService` +
  `ServiceDiscoveryService`): devolve `{group, module, title, actions[]}` **somente** com ações
  declaradas no manifesto, na ordem canônica (`ver`, `editar`, `aprovar`, `arquivar`), agrupando os
  services com permissões em `Servicos` e mantendo o grupo `Suporte` fora (ADR-033). Ordenação
  determinística (`group`, `module`). `has()` é a consulta de existência e `orphans()` devolve os
  tokens que o manifesto atual não declara (para a UI avisar). O catálogo é **cacheado por instância**
  e não é usado pelo model.
- **`Role` continua validando só a sintaxe** (`saving`/`normalizePermissions`). A existência do token
  é da escrita, em `AccessPolicy::assertPermissionsWritable`: token declarado entra; token órfão já
  gravado no perfil pode ser mantido ou removido; token órfão **novo** é recusado.
- **`Core\Access\AccessPolicy`** (`final readonly`): guarda server-side, deny-by-default, para S3/S4.
  - `authorizeManagement()` → **403** (`AccessDenied extends AuthorizationException`) sem
    `acesso.editar`; a equipe de suporte tem passe livre.
  - `suporte`: `assertRoleEditable`/`assertRoleDeletable`/`assertRoleAssignable` recusam o perfil
    protegido; trocar o perfil do usuário que o detém também é recusado.
  - último administrador ativo: `assertUserCanBeDeactivated`, `assertUserCanBeDeleted`,
    `assertUserRoleCanChange` e `assertRolePermissionsKeepsAdministrator` recusam a escrita que
    deixaria zero usuários ativos com `acesso.editar`. A checagem trava, na **transação do
    chamador**, os perfis administradores e os usuários ativos administradores
    (`lockForUpdate`, ordem determinística) — sem transação é `LogicException` (erro de programa).
  - auto-lockout: `assertUserCanBeDeactivated` recusa desativar a própria conta;
    `assertUserRoleCanChange` e `assertRolePermissionsKeepsOwnAccess` recusam remover o próprio
    `acesso.ver`.
- **Exceções concretas** em `Core\Access\Exceptions`: `AccessDenied` (403 nativo) e
  `AccessRuleViolation` com `key`/`token`/`field` (a Action da S3/S4 converte em **422** i18n). O
  `Role` passou a lançar `AccessRuleViolation::ROLE_PROTECTED` em vez de `RuntimeException` genérica
  (mesma proteção, tipo concreto); `phpstan.neon` deixou de ignorar `Role.php` (o ignore ficou
  obsoleto).
- **Auditoria/log:** a S2 **não cria caminho de mutação**, então não emite `acesso.{action}` novo;
  as mutações de `User`/`Role` continuam passando pelo `GlobalAuditSubscriber` (ADR-035) e as Actions
  da S3/S4 emitem span + `log_event` com o mesmo nome. Nenhuma senha ou token de autenticação entra
  em exceção/log; os tokens de permissão são vocabulário público e podem aparecer no aviso da UI.

### Arquivos para revisar

- `src/core/Access/PermissionCatalog.php`
- `src/core/Access/AccessPolicy.php`
- `src/core/Access/Exceptions/AccessDenied.php`
- `src/core/Access/Exceptions/AccessRuleViolation.php`
- `src/core/Access/Models/Role.php` (exceção concreta)
- `src/core/Access/Test/PermissionCatalogTest.php`
- `src/core/Access/Test/AccessPolicyTest.php`
- `src/core/Access/Test/RoleProtectionTest.php`
- `phpstan.neon` (ignore obsoleto de `Role.php`)
- `src/core/Access/README.md`, `wiki/specs/11-acesso-e-ativacao.md`, `_to_revision/README.md`

### Testes e evidência

- `PermissionCatalogTest` (6): só ações declaradas (`Blueprint` = `ver`; `Noticias` = quatro),
  service `Layout` com `layout-service::cms.title`, módulo sem permissão fora (`Home`), ordem
  canônica/ordenada, `has()` declarado × não declarado, `orphans()` sem repetição.
- `AccessPolicyTest` (28): 403 sem `acesso.editar`; perfil `suporte` não editável/excluível/
  atribuível e usuário do `suporte` não troca de perfil; token declarado × órfão (novo recusado,
  existente preservado/removível); último administrador ativo (desativar, excluir, trocar perfil,
  remover `acesso.editar` da única função) e com segundo administrador liberado; auto-lockout
  (desativar-se, remover o próprio perfil, editar o próprio perfil sem `acesso.ver`); administrador
  inativo/soft-deleted não conta.
- `RoleProtectionTest` (4): `suporte` semeado/protegido, update/delete recusados com
  `AccessRuleViolation::ROLE_PROTECTED`, `assignable()` fora.
- `make check`: **0 FAIL** — 24 OK · 9 SKIP (SKIPs são pré-condições idempotentes por staleness).
  `coverage`: 941 testes / 14.903 asserções; `coverage-check`: **92,98%** (12.238/13.162; piso 90%);
  `static` (PHPStan `max`): 0 erros; `check-boundaries`/`check-migrations`/`docs-check` OK.
- **Sem E2E nesta fatia**: não há UI, rota ou HTML novo (a tela e a jornada chegam na S3/S4 e o
  `E2E=1` é exigido no S6). Os SKIPs de `e2e`/`perf` são o opt-in esperado.

### Pontos de revisão

- D1/D2: classes `final`/DI, `strict_types`, sem `new`/`resolve`/`app` em corpo; sem trait/abstract/
  generic/`Result`; sem `PermissionService`/herança.
- D3/D10: nenhuma coluna/tabela nova; a “verdade” do último administrador é consulta + `FOR UPDATE`
  na transação, não cache; catálogo é código (manifesto), não dado editável.
- D9/D11/D12: exceções concretas e mapeáveis (403/422), sem PII/segredo; nenhuma mutação nova,
  portanto nenhum evento de auditoria inventado.
- D13: testes determinísticos, sem `sleep`/rede; `RefreshDatabase` fornece a transação do lock.
- D8: sem string visível nova (as chaves i18n entram com o módulo `acesso` na S3).

### Riscos residuais

- **S2 sem HTTP:** a conversão `AccessRuleViolation` → 422 e o mapeamento de campo (`permissoes`/
  `role_id`/`deactivated_at`) é contrato para S3/S4; até lá a exceção é de domínio. Nenhuma rota
  atual chama a política, então não há 500 observável — o teste cobre o tipo/conteúdo da exceção e o
  403 pela herança de `AuthorizationException`.
- **`acesso.*` ainda não é declarado:** o módulo `Acesso` chega na S3; até lá os tokens
  `acesso.ver`/`acesso.editar` só existem em perfis criados por código/teste e são inertes no
  `Gate` (negação por padrão), como manda a ADR-033.
- **Concorrência:** o lock serializa as escritas do painel no conjunto de administradores; uma
  promoção concorrente a administrador não é travada, mas é conservadora (nunca permite zerar).
  Teste de concorrência real com duas conexões fica para o E2E/fechamento (S6), se necessário.
- **Cobertura da política:** `LogicException` (chamada fora de transação) e o ramo de suporte do
  catálogo (sem módulo no grupo `Suporte` hoje) não são exercitados; as linhas restantes ficam em
  ~93% da classe.

## S3 — Módulo `Infraestrutura/Acesso` e telas de usuários (`task_00iin9xp`)

### O que foi implementado

- **Módulo `Infraestrutura/Acesso`** (`ModuleProvider`) com token `acesso`, grupo `Infraestrutura`,
  ícone `lucide-user`, posição 60 e permissões `[acesso.ver, acesso.editar]` declaradas no manifesto
  (o `PermissionCatalog`/`AccessGate` passam a reconhecer os tokens).
- **Rotas CMS sem closure** (caminho relativo ao domínio próprio do painel, que já isola a área —
  não há prefixo `/cms` dentro do módulo): `acesso.cms.usuarios.{index,create,store,edit,update,
  activate,deactivate,password}` → `/usuarios`, `/usuarios/criar`, `/usuarios/{user}/editar`,
  `/usuarios/{user}/ativar`, `/usuarios/{user}/desativar`, `/usuarios/{user}/senha`. Mutação só
  `POST`/`PUT`; `whereUuid('user')` no binding.
- **Listagem** (`AcessoUsersIndexController`): busca `q` por `name`/`email` (`ilike`), filtro
  `status=todos|ativos|inativos`, `paginate(15)` com `withQueryString`, perfil em uma única consulta
  (`with('role:id,nome,slug')`). Ordenação por allowlist `name|email|created_at` + `direcao`,
  com as demais colunas e o `id` como desempate único (ADR-001). Cabeçalho ordenável com
  `aria-sort` válido (`ascending|descending|none`).
- **Formulário** (`acesso-usuarios-form`): nome (120), e-mail (255, normalizado para minúsculas),
  perfil (`Role::assignable()`), senha de criação / nova senha opcional na edição + confirmação;
  `min:12|confirmed`, cast `hashed` → Argon2id. Conta com perfil protegido (`suporte`) mostra o
  perfil travado e o e-mail da equipe não muda (`support_identity`).
- **Ações** (`final readonly`, DI, uma por arquivo): `AcessoUserCreate` (span/log
  `acesso.user_created`), `AcessoUserUpdate` (`acesso.user_updated`), `AcessoUserActivate`
  (`acesso.user_activated`), `AcessoUserDeactivate` (`acesso.user_deactivated`, `Clock` injetado →
  `deactivated_at`), `AcessoUserPasswordReset` (`acesso.user_password_reset`). Span e `log_event`
  têm o mesmo nome; payload só com escalares (ids/ator/booleano de senha), sem PII.
- **Política** (S2, não alterada): `Permission::authorize('Acesso', …)` + `AccessPolicy`
  (`authorizeManagement`, `assertRoleAssignable`, `assertUserRoleCanChange`,
  `assertUserCanBeDeactivated`). `AccessDenied` → **403**; `AccessRuleViolation` →
  `AcessoRuleFailure::toValidation` (**422** com i18n), nunca 500. `AccessPolicy` roda dentro de
  `DB::transaction` (o lock do último administrador exige transação).
- **E-mail**: `AcessoUserEmail` normaliza e verifica `lower(email)` inclusive contra contas
  soft-deleted (a identidade excluída não é reciclada); colisão concorrente (`23505`) vira 422.
  O `role_id` só é consultado depois de `Str::isUuid` (UUID malformado não aborta transação).
- **Auditoria**: mutações de `User` entram na trilha pelo `GlobalAuditSubscriber` (ADR-035);
  `password` é mascarado pelo `AuditService`. Desativação/reativação e reset de senha têm teste
  dedicado; nenhuma senha/hash aparece no payload.
- **Layout/a11y**: um único `<x-layout::cms>` por view (sem `<h1>` no módulo), tabela `x-cms::data-table`
  com `caption`/`scope="col"`, situação em texto (badge), confirmação nativa `x-cms::confirm`
  (popover, sem `<form>` aninhado; campos de senha com ids únicos por linha), filtros `GET`,
  `form-errors` (`role="alert"`), `aria-invalid`/`aria-describedby`, `autofocus` no primeiro erro,
  alvos das primitivas (≥2.75rem).
- **i18n**: 4 locales (`pt_BR`, `en`, `es`, `fr`) com `menu.php`, `cms.php` e `validation.php` no
  namespace `acesso`; nenhuma string visível fixa nas views/controllers.
- **E2E**: `src/tests-e2e/tests/19-cms-usuarios.spec.ts` + `histories/19-cms-usuarios.md` (criar
  usuário → login → logout → desativar → login falha), projeto `cms-desktop`, com axe na lista, no
  formulário e no estado de erro; índices E2E/`AGENTS.md`/spec 04 atualizados.

### Arquivos para revisar

- `src/modules/Infraestrutura/Acesso/ModuleProvider.php` · `Routes/cms.php` · `MODULE.md`
- `.../Logic/Actions/AcessoUser{Create,Update,Activate,Deactivate,PasswordReset}.php`
- `.../Logic/Controllers/Cms/AcessoUsers{Index,Create,Store,Edit,Update,Activate,Deactivate,Password}Controller.php`
- `.../Logic/Requests/AcessoUser{Store,Update,Password}Request.php`
- `.../Logic/Support/{AcessoUserEmail,AcessoRuleFailure}.php`
- `.../Layout/Views/cms/acesso-usuarios-{index,form}.blade.php`
- `.../Lang/{pt_BR,en,es,fr}/{menu,cms,validation}.php`
- `.../Test/AcessoUsersCmsTest.php`
- `src/tests-e2e/tests/19-cms-usuarios.spec.ts` · `src/tests-e2e/histories/19-cms-usuarios.md` ·
  `src/tests-e2e/playwright.config.ts` · `src/tests-e2e/README.md` · `wiki/specs/04-acessibilidade-ux.md` ·
  `AGENTS.md`

### Testes e evidência

- `AcessoUsersCmsTest` (23 testes / 189 asserções): convidado → login; `acesso.ver` na listagem;
  `acesso.editar` nos formulários/rotas; criação com Argon2id; e-mail normalizado e duplicado por
  caixa recusado; forma inválida; `suporte` fora do select e recusado na criação; edição mantendo
  senha; troca de senha; busca/filtros/ordenação; paginação nativa (17 registros);
  desativar/reativar com trilha; self-desativação 422; último administrador 422; perfil `suporte`
  protegido (reatribuição e e-mail); reset pelo administrador com `password` mascarado; senha curta;
  rotas de mutação sem `GET`; sem N+1 (uma consulta de perfis); spans/logs das cinco ações; 4 locales;
  contrato de layout (`<main>`/`<h1>` únicos, caption/scope, `aria-sort`); tokens declarados.
- `ModulesTelemetryTest`, `ModuleI18nTest`, `LayoutContractTest`, `Adr004GrepFriendlyTest`,
  `E2eDocsTest`: OK; `tools/name-check.sh`: OK (9 módulos).
- E2E isolado: `tools/e2e.sh test tests/19-cms-usuarios.spec.ts --project=cms-desktop` — **5 passed**
  (4 do lote serial + o novo spec).
- `make check`: **0 FAIL** (23 OK · 0 FAIL na rodada rápida; 999 testes / 16.412 asserções,
  cobertura `src/` = **92,93%**, piso 90%; PHPStan `max` **0** erros; docs/boundaries/migrations/
  vendor/gates OK).
- `make check E2E=1`: **0 FAIL** (24 OK · 0 FAIL); `e2e` **120 passed + 1 skip condicional** (1,6m,
  projeto `cms-desktop` com `21-cms-perfil`), `perf` 10 passed (29 s), `a11y` OK.

### Pontos de revisão

- D1/D2: classes `final`/`readonly`, DI, sem `new`/`resolve`/`app` em corpo de domínio (só o model
  Eloquent de borda), sem trait/abstract/generic/`Result`; controllers finos sem regra de negócio
  (as listagens consultam via Eloquent com eager loading, como as telas irmãs); FormRequest valida
  forma e chama `Permission::authorize` **antes** da validação.
- D3/D10: ordenação com allowlist + desempate único; paginação nativa; `deactivated_at` (data, não
  flag); unicidade no banco + checagem de borda; sem N+1.
- D9/D11/D12: vocabulário `acesso.*`; span/log com o mesmo nome; auditoria via subscriber com senha
  mascarada; nenhuma PII/segredo em log/URL.
- D7/D8: HTML semântico, foco no erro, confirmação nativa, 4 locales com paridade.
- D13: testes por comportamento (HTTP), determinísticos, sem `sleep`; E2E com dados próprios.

### Riscos residuais

- **Conta `suporte`:** a política da S2 protege o **perfil**; a S3 acrescenta a recusa de troca do
  e-mail reservado, mas nome do usuário de suporte ainda é editável (não altera identidade de login).
  Deixar essa superfície fora do menu/listagem não foi pedido nesta fatia.
- **Sessões abertas do alvo:** desativar encerra no próximo request pelo `EnsureActiveUser` (S1);
  não há revogação imediata de sessão paralela (P2 fora de escopo — S5 discute).
- **Acumulação de contas E2E:** cada execução deixa uma conta `e2e-acesso-<timestamp>` desativada
  (não há exclusão física — trigger/LGPD). Não afeta outras histórias, mas a base de dev acumula.
- **Ordenação por `ilike`:** a busca não escapa `%`/`_` do operador (só amplia o match; sem injeção).
  Se a semântica de busca mudar (FTS), trocar o predicado e o teste juntos.
- **Perfil com token órfão:** a atribuição usa `Role::assignable()` + `AccessPolicy`; não revalida
  o catálogo token a token (a S4 valida a matriz). Um perfil antigo com token órfão continua
  atribuível, mas o token não vira ability (Gate nega por padrão).

## S4 — Telas de perfis e matriz de permissões (`task_002alac7`)

### O que foi implementado

- **Rotas CMS sem closure** (`acesso.cms.papeis.*`, binding `whereUuid('role')`): `GET /papeis`,
  `GET /papeis/criar`, `POST /papeis`, `GET /papeis/{role}/editar`, `PUT /papeis/{role}`,
  `DELETE /papeis/{role}`. Mutação só `POST`/`PUT`/`DELETE` com CSRF; nunca `GET` que muta. O menu
  do módulo ganhou subitens **Usuários** e **Perfis** (o item pai passa a “Acesso”).
- **Lista de perfis** (`AcessoRolesIndexController`): busca `q` por `nome`, paginação nativa,
  ordem determinística (`nome`, `id`) e contagem de usuários vinculados em **uma** consulta
  (`withCount('users')` — a relação inclui soft-deleted, coerente com o bloqueio de exclusão).
  `acesso.ver` lista; `acesso.editar` decide as ações de linha. Perfil protegido mostra badge
  “Protegido” e **não** oferece editar/excluir.
- **Matriz de permissões** (`AcessoPermissionMatrix`): derivada do `PermissionCatalog` (S2), linhas
  agrupadas pelo grupo do manifesto (módulos e services com admin surface), colunas = as quatro
  ações canônicas. Ação **não declarada** é renderizada **desabilitada, nunca omitida**; nenhum
  nível `none/read/write`, nenhuma herança e nenhum token inventado. Resumo via
  `trans_choice` (“N módulos com edição”), tabela semântica com `caption`/`scope="col|row|rowgroup"`,
  checkboxes nativos (operação por teclado, sem JS obrigatório) e alvos ≥ 2.75rem.
- **Criação/edição** (`AcessoRoleCreate`/`AcessoRoleUpdate`): `nome` `required|max:120`; `slug`
  derivado do nome na criação (sufixo determinístico em colisão) e **estável** na edição. O
  `AcessoRoleRequest` normaliza `permissoes[]` (trim + deduplicação preservando a ordem) e valida
  cada token contra o catálogo; a Action revalida pela `AccessPolicy` (autoridade final). A edição
  roda em transação com `lockForUpdate`, recusa o perfil protegido, preserva o último administrador
  ativo e o próprio `acesso.ver` do operador.
- **Token órfão:** já gravado no perfil aparece como **aviso** (alert com checkbox marcado) e pode
  ser mantido ou removido; token órfão **novo** é recusado (request + política). Nenhum órfão vira
  ability (Gate nega por padrão).
- **Exclusão** (`AcessoRoleDelete`): perfil protegido e qualquer usuário vinculado — inclusive
  **soft-deleted**, que mantém `role_id` e é restaurável — bloqueiam no servidor com **422** i18n
  (`role_in_use`/`role_protected`), na mesma transação e com a linha travada; o botão desabilitado
  nunca é a autorização. `DELETE` sem vínculo apaga e audita.
- **Auditoria/observabilidade:** Actions emitem **span + `log_event` com o mesmo nome**
  (`acesso.role_created|role_updated|role_deleted`, payload escalar sem PII). A trilha persistida do
  `Role` é do `GlobalAuditSubscriber`; teste dedicado prova `permissoes` em `payload_before` e
  `payload_after` (e `changes`), sem segredo.
- **i18n/layout:** 4 locales (`pt_BR`, `en`, `es`, `fr`) com as chaves de perfis, matriz, validação
  e menu; uma única `<x-layout::cms>` por view, só `<main>`, nenhuma string visível fixa, status
  (protegido/padrão) sempre em texto com badge.

### Arquivos para revisar

- `src/core/Access/Models/Role.php` (relação `users()` com `withTrashed`)
- `src/modules/Infraestrutura/Acesso/ModuleProvider.php` · `Routes/cms.php` · `MODULE.md`
- `.../Logic/Actions/AcessoRole{Create,Update,Delete}.php`
- `.../Logic/Controllers/Cms/AcessoRoles{Index,Create,Store,Edit,Update,Destroy}Controller.php`
- `.../Logic/Requests/AcessoRoleRequest.php`
- `.../Logic/Support/AcessoPermissionMatrix.php`
- `.../Layout/Views/cms/acesso-papeis-{index,form}.blade.php`
- `.../Lang/{pt_BR,en,es,fr}/{menu,cms,validation}.php`
- `.../Test/AcessoRolesCmsTest.php`
- `src/tests-e2e/tests/20-cms-papeis.spec.ts` · `src/tests-e2e/histories/20-cms-papeis.md` ·
  `src/tests-e2e/playwright.config.ts` · `src/tests-e2e/README.md` ·
  `wiki/specs/04-acessibilidade-ux.md` · `AGENTS.md`

### Testes e evidência

- `AcessoRolesCmsTest` (19 testes / 161 asserções): convidado → login; `acesso.ver` na lista;
  `acesso.editar` em todas as rotas; matriz exata do manifesto (contagem de inputs habilitados ×
  desabilitados e `blueprint.editar` desabilitado); resumo; criação com duplicatas normalizadas;
  token não declarado/malformado recusado; nome required/max; edição + auditoria
  before/after/`changes`; órfão exibido, preservado, removido e nunca concedido novo; `suporte`
  recusado em GET/PUT/DELETE e escondido na lista; exclusão bloqueada com usuário ativo e
  soft-deleted, liberada sem vínculo; rotas de mutação sem `GET`; uma consulta de contagem
  (sem N+1); spans das três ações; 4 locales; contrato de layout/matriz semântica e sem JS.
- `make check`: **resultado na mensagem de fechamento da task** (0 FAIL, PHPStan `max` 0,
  cobertura ≥90%).
- `make check E2E=1`: **resultado na mensagem de fechamento** (spec `20-cms-papeis` no
  `cms-desktop`, matriz por teclado + axe).

### Pontos de revisão

- D1/D2: classes `final`/`readonly`, DI; sem `new`/`resolve`/`app` em corpo de domínio (só models
  Eloquent de borda), sem trait/abstract/generic/`Result`; sem `PermissionService`/herança/níveis.
- D3/D10: `slug` único derivado e estável; `permissoes` jsonb continua a única verdade; contagem e
  bloqueio coerentes (inclui soft-deleted); nenhuma coluna/tabela nova.
- D9/D11/D12: vocabulário `acesso.role_*`; span/log com o mesmo nome; auditoria before/after de
  `permissoes` com teste dedicado; nenhum segredo/PII.
- D7/D8: HTML semântico (`caption`/`scope`), ação desabilitada com texto alternativo, foco nativo,
  4 locales com paridade; nenhuma string visível fixa.
- D13: testes por comportamento (HTTP), determinísticos, sem `sleep`; E2E cria e exclui o próprio
  perfil.

### Riscos residuais

- **Corrida exclusão × atribuição:** o bloqueio conta/`lockForUpdate` na transação, mas um `INSERT`
  concorrente de usuário com o mesmo `role_id` não é serializado pela contagem; o FK é
  `nullOnDelete`. Janela mínima, coberta pela UI; endurecer com `restrictOnDelete` exigiria
  migração (fora do escopo desta fatia).
- **Slug imutável:** renomear o perfil não re-deriva o slug (identidade estável). Se o slug virar
  superfície pública, definir a regra de rename em ADR.
- **Busca:** `ilike` não escapa `%`/`_` (apenas amplia o match; sem injeção), mesma semântica da
  lista de usuários.
- **`AcessoRoleRequest` × `AccessPolicy`:** a existência do token é validada na borda (para erro de
  campo) e novamente na Action (autoridade final). A duplicação é intencional; se a regra de órfão
  mudar, alterar as duas e os testes juntos.

## S5 — Perfil próprio (`task_00f074wc`)

### O que foi implementado

- **Rotas CMS sem closure** (`acesso.cms.perfil.*`, sem `whereUuid` porque não há alvo na URL):
  `GET /perfil`, `PUT /perfil` e `PUT /perfil/senha`. Sem `acesso.{ver,editar}`: qualquer operador
  **autenticado** edita apenas o próprio perfil — o alvo é sempre o ator da sessão, carregado
  dentro da Action. Mutação só `PUT` + CSRF; nunca `GET` que muta.
- **Dados de contato** (`AcessoProfileUpdate`): `name` `required|max:120`; `email`
  `email|max:255`, normalizado (`AcessoUserEmail::normalize`) e único sem diferenciar caixa,
  ignorando a própria conta e **incluindo contas soft-deleted** (regra reusada da S1/S3). Somente
  `name`/`email` são atribuídos: `role_id`, `deactivated_at` e `email_verified_at` não são expostos
  na tela nem aceitos no payload. A corrida do índice `lower(email)` (`23505`) vira **422**.
  O e-mail reservado da equipe de suporte continua imutável no perfil próprio — a regra foi
  extraída para `AcessoUserEmail::assertIdentityEditable`, agora compartilhada com a edição
  administrativa da S3 (sem duplicar política).
- **Troca de senha** (`AcessoProfilePasswordChange`): senha atual conferida com `Hash::check`
  contra o hash persistido; nova senha `min:12|confirmed` e o cast `hashed` grava Argon2id. A
  recusa é **422** de formulário no campo `current_password` (`acesso::validation.current_password`),
  sem revelar hash/segredo. `session()->regenerate()` no controller mantém o operador autenticado e
  renova o id da sessão; no MVP não há revogação global do store (decisão da fase).
- **Autorização/erros**: nenhuma permissão de módulo; `auth` da área (injetado por
  `requireModuleRoutes`) leva o convidado ao login e `EnsureActiveUser` (S1) encerra sessão de conta
  desativada. Nenhuma exceção de domínio nova; validação de forma é `FormRequest` (422).
- **Observabilidade/auditoria**: as Actions emitem **span + `log_event` com o mesmo nome**
  (`acesso.profile_updated`, `acesso.password_changed`), payload só com `id`/`actor`. A mutação de
  `User` entra na trilha pelo `GlobalAuditSubscriber` (ADR-035) com `password` mascarado — teste
  dedicado prova que nem a senha em texto nem o hash aparecem no payload.
- **Shell/perfil**: link **“Meu perfil”** no header do CMS (padrão do shell, `Route::has` evita link
  morto), disponível **sem** `acesso.ver`; a tela herda um único `<x-layout::cms>` (só `<main>`),
  com dois formulários independentes, `fieldset/legend`, rótulo por controle,
  `autocomplete="current-password"`/`new-password`, foco no primeiro erro e alvos das primitivas
  (≥2.75rem).
- **i18n**: 4 locales com as chaves de perfil em `acesso::cms.*`,
  `acesso::validation.current_password` e o rótulo do shell em `layout::shell.profile`.
- **E2E**: `src/tests-e2e/tests/21-cms-perfil.spec.ts` + `histories/21-cms-perfil.md` (criar
  operador → entrar → perfil → senha atual errada → troca correta → novo login), projeto
  `cms-desktop`, com axe na tela e no estado de erro; config/README/spec 04 atualizados.
- **Documentação do módulo**: `MODULE.md` com rotas, regras de escrita, eventos e layout do perfil
  próprio.

### Arquivos para revisar

- `src/modules/Infraestrutura/Acesso/Routes/cms.php` · `MODULE.md`
- `.../Logic/Actions/AcessoProfile{Update,PasswordChange}.php`
- `.../Logic/Controllers/Cms/AcessoProfile{Edit,Update,Password}Controller.php`
- `.../Logic/Requests/AcessoProfile{Update,Password}Request.php`
- `.../Logic/Support/AcessoUserEmail.php` (regra de identidade do suporte compartilhada)
- `.../Logic/Actions/AcessoUserUpdate.php` (reuso da regra compartilhada)
- `.../Layout/Views/cms/acesso-perfil.blade.php`
- `.../Lang/{pt_BR,en,es,fr}/{cms,validation}.php`
- `src/layout/Cms/partials/header.blade.php` · `src/layout/Lang/{pt_BR,en,es,fr}/shell.php`
- `.../Test/AcessoProfileCmsTest.php`
- `src/tests-e2e/tests/21-cms-perfil.spec.ts` · `src/tests-e2e/histories/21-cms-perfil.md` ·
  `src/tests-e2e/playwright.config.ts` · `src/tests-e2e/README.md` ·
  `wiki/specs/04-acessibilidade-ux.md`

### Testes e evidência

- `AcessoProfileCmsTest` (16 testes / 156 asserções): convidado → login nas três rotas; operador
  **sem permissão** abre e edita o próprio perfil; a tela não expõe `role_id`/`deactivated_at`;
  payload com perfil/estado/verificação é ignorado (`role_id`/`deactivated_at` nulos,
  `email_verified_at` intacto); unicidade de e-mail sem caixa e contra conta soft-deleted; forma
  inválida (vazio/limite 121/e-mail); e-mail do suporte reservado no perfil próprio; senha atual
  errada recusada sem alterar o hash; `min:12|confirmed`; troca com Argon2id + sessão regenerada +
  login com a senha nova; auditoria com `MASKED` e sem senha/hash; span + `log_event` com o mesmo
  nome; rotas de mutação só `PUT`; link do perfil sem `acesso.ver`; 4 locales; contrato de layout.
- `AcessoUsersCmsTest`, `AcessoRolesCmsTest`, `ModuleI18nTest`, `LayoutContractTest`,
  `ModulesTelemetryTest`: OK após a mudança (reuso da regra do suporte sem regressão).
- `make check`: **0 FAIL** (23 OK · 0 FAIL na rodada rápida; 999 testes / 16.412 asserções,
  cobertura `src/` = **92,93%**, piso 90%; PHPStan `max` **0** erros; docs/boundaries/migrations/
  vendor/gates OK).
- `make check E2E=1`: **0 FAIL** (24 OK · 0 FAIL); `e2e` **120 passed + 1 skip condicional** (1,6m,
  projeto `cms-desktop` com `21-cms-perfil`), `perf` 10 passed (29 s), `a11y` OK.

### Pontos de revisão

- D1/D2: classes `final`/`readonly`, DI, sem `new`/`resolve`/`app` em corpo de domínio; sem
  trait/abstract/generic/`Result`; controllers finos (autorização → request → action → redirect).
- D3/D10: nenhuma coluna/migração nova; unicidade reaproveita o índice `lower(email)` da S1;
  `role_id`/situação/verificação são imutáveis por construção (não só por validação).
- D9/D11/D12: vocabulário `acesso.profile_updated`/`acesso.password_changed`; span/log com o mesmo
  nome; auditoria sem segredo; nenhuma PII em log/URL.
- D7/D8: shell único, formulários semânticos, foco no erro, 4 locales com paridade.
- D13: testes por comportamento (HTTP), determinísticos, sem `sleep`; E2E cria a própria conta.

### Riscos residuais

- **Sessões paralelas do mesmo operador:** o MVP regenera só a sessão corrente; uma segunda sessão
  aberta continua válida (decisão registrada na fase). Revogação global exigiria store compartilhado
  com índice por usuário (fora do escopo).
- **E-mail é identidade de login:** a troca já é imediata (sem fluxo de verificação no MVP), então um
  erro de digitação pode deixar o operador sem acesso até o suporte/reset administrativo.
- **Conta do `suporte`:** nome editável no perfil próprio; e-mail permanece reservado (mesma regra da
  S3), coerente com a identidade de login da equipe.
- **Link no shell:** `Route::has` evita link morto se o módulo não estiver registrado; com o módulo
  **desativado** a rota responde 404 pelo guard da área (ADR-034), comportamento esperado.

## S6 — Fechamento da implementação: E2E, docs e evidência (`task_01pmtfn3`)

### O que foi entregue

- **Jornadas E2E novas** no domínio próprio do CMS: `19-cms-usuarios` (criar → login do
  operador → desativar → login falha), `20-cms-papeis` (matriz/teclado, ação não declarada,
  exclusão), `21-cms-perfil` (senha atual errada → troca → novo login) e `22-cms-acesso-mobile`
  (Pixel 7 nas quatro telas + matriz sem JavaScript).
- **Isolamento**: as specs 19–22 foram ligadas a `cms-desktop`/`cms-mobile`; o lote global
  (`18-estrutura-site`) continua no `cms-serial`, com `dependencies`, para nenhum projeto observar
  a janela de mutação de tema/menu.
- **Correção de layout medida no mobile**: `.cms-frame`/`.cms-main` passaram a usar
  `minmax(0, 1fr)`/`min-inline-size: 0` (tabela larga rola no contêiner, sem estourar o viewport) e
  os alvos do `<main>` ganharam `scroll-margin-block-start: 4rem` para o foco não ficar atrás do
  header sticky (WCAG 2.4.11). O teste sem JS usa teclado (Espaço/Enter) para provar o formulário
  nativo sem depender do empilhamento do `<label>` no viewport estreito.
- **Docs**: `AGENTS.md`, `src/tests-e2e/README.md`, `wiki/specs/04-acessibilidade-ux.md` e
  `wiki/specs/11-acesso-e-ativacao.md` refletem 22 histórias/superfícies da P2; esta ficha, a fila
  e os estados da fase foram atualizados.

### Arquivos para revisar

- `src/tests-e2e/tests/19-cms-usuarios.spec.ts` · `20-cms-papeis.spec.ts` ·
  `21-cms-perfil.spec.ts` · `22-cms-acesso-mobile.spec.ts` e as histórias correspondentes.
- `src/tests-e2e/playwright.config.ts` · `src/tests-e2e/README.md`
- `src/layout/Cms/Assets/css/shell.css` (minmax/scroll-margin) · `src/layout/Cms/partials/header.blade.php`
- `src/layout/Lang/{pt_BR,en,es,fr}/shell.php`
- `AGENTS.md` (contagem de histórias) · `wiki/specs/04-acessibilidade-ux.md` ·
  `wiki/specs/11-acesso-e-ativacao.md` · `wiki/implementation-phases/INVENTARIO.md`

### Evidência

- **Playwright/E2E completo**: `tools/e2e.sh test` → **122 passed, 1 skipped**, 6 projetos
  (`cms-serial`, `portal-desktop`, `portal-mobile`, `portal-no-js`, `cms-desktop`, `cms-mobile`);
  specs `19`–`22` verdes; axe sem violação A/AA nas telas da P2.
- **Performance**: `11-performance` → **10 passed** (CLS dentro do orçamento).
- **PHPUnit/cobertura**: relatório do `make check` da S6: **999 testes / 16.412 asserções**;
  cobertura `src/` **92,93%** (piso 90%); PHPStan `max` **0**; Pint/Prettier/ESLint OK;
  `git diff --check` e `docs-check` OK.
- **Bloqueio de ambiente no gate `a11y` (sniffCSS)**: a versão 0.5.1 do `sniff` no host passou a
  travar no `capture` **antes** de produzir o snapshot (0 bytes), inclusive contra uma página
  estática servida por `python -m http.server` em `127.0.0.1:8099`; testado com o Chromium do
  sistema (155), o `chrome-headless-shell` do Playwright (156), `SNIFF_CONNECT`, porta CDP fixa e
  a imagem Docker pinada `stallonels/sniffcss:v0.5.1`. O `sniff check` offline continua OK. Não é
  regressão da P2; o axe (juiz de contraste, `make check E2E=1`) cobre a a11y das telas. O
  `make check` completo fica **pendente de um host com o `sniff capture` funcional** — não marcar
  a fase como revisada antes disso.

### Pontos de revisão

- D7/D13: axe desktop/mobile e sem-JS; alvo ≥44 px da matriz; foco/teclado; ordem determinística
  dos specs e isolamento do lote global.
- D6/D10: a mudança de `minmax(0, 1fr)`/`min-inline-size` só corrige overflow; nenhuma unidade
  física ou `!important`; `scroll-margin` é `rem`.
- D13: a falha de `sniff` é **ambiente**, não teste; quem revisar deve reproduzir num host limpo
  ou registrar a evidência de indisponibilidade, sem “consertar” o gate desligando-o.

### Evidência da rodada de correção (`task_00wl811i`, 2026-10-25)

- **PHPUnit (suíte completa, banco de teste isolado):** **1.015 testes (~16,5 mil asserções) — OK**
  (antes: 999). Inclui os testes vermelho→verde dos achados 1–4, 6–9, 13–15a/b.
- **Vermelho comprovado por reversão temporária** das correções (e restauração antes do verde):
  identidade reservada 2 falhas; conta protegida 2; snapshot de perfil 1; ordem de lock 2; login
  sem caixa 1; custo constante no login 1 erro (mock de `Hash::check` não chamado). O gate
  `sniff` tem o autoteste offline com saída sem resumo/vazia/zerada reprovando.
- **`make check` (uma execução):** todos os checks antes do `a11y` verdes — Pint, ESLint, PHPStan
  `max` **0**, boundaries, migrations, vendor, `check-gates` (com o novo autoteste do parser do
  sniff), security, optimize, audit, SBOM, `docs-check`, PHPUnit; **cobertura `src/` = 92,98%**
  (13.543/14.566; piso 90%). O `a11y` continua **bloqueado no host** (`sniff 0.5.1` trava no
  `capture` antes de gerar o snapshot), exatamente como registrado no S6; o gate **não** foi
  desligado e agora é **fail-closed**.
- **E2E:** `tools/e2e.sh test` completo → **122 passed + 1 skip condicional (123 testes, 1,7m)**;
  specs `19`–`22` verdes (incluindo a asserção de persistência da matriz no 22 e a limpeza do 21).
  `11-performance` → **10 passed** (CLS no orçamento). `tsc --noEmit` OK.
- **Contagem corrigida:** `playwright test --list` = **123 testes em 22 arquivos** — o README e a
  tabela da fase foram alinhados (antes 121/120).
- **Sem commit/stage**; árvore de trabalho com as mudanças para o humano revisar.

### Verificação independente da correção (`VerifyP2Fix`, 2026-10-25)

- **Veredito: aprovado** para os dois bloqueadores e os achados altos/médios da rodada 1. Foram
  confirmados por leitura do diff e por execução de subconjuntos: 94 testes de
  `AcessoUsersCmsTest|AcessoProfileCmsTest|AccessPolicyTest|CmsAuthTest` e 32 de
  `CmsUserCommandTest|MigrationIdempotencyTest|CmsUserEmailUniquenessTest|RoleProtectionTest|PermissionCatalogTest`.
- Identidade reservada: regra bidirecional em create/update/profile; CLI exige
  `--role=suporte` para o e-mail e recusa o perfil com outro e-mail.
- Conta protegida: reset/edição com senha/desativação recusados para não-suporte; hash, estado e
  perfil inalterados; ações destrutivas ocultas na linha; teste de login com a senha injetada falha.
- Guarda de último administrador: perfil relido sob lock; ordem global
  **perfis admin → usuários admin → alvo**; testes provam a sequência de `FOR UPDATE`.
- Login: e-mail normalizado; `Hash::check` de custo constante no ramo sem conta (hash-sentinela
  Argon2id válido, ~200 ms medido).
- Gate a11y: fail-closed para snapshot vazio/sem resumo/zerado; `SNIFF_CAPTURE_TIMEOUT` transforma
  o travamento do host em falha explícita; nenhum caminho de verde falso.
- E2E: 19/20/21/22 filtram por identidade única, 19/21 deixam a conta desativada, 22 prova a
  permissão persistida; README = 123 testes.
- **Risco residual único:** `sniff 0.5.1 capture` trava neste host antes do snapshot (reproduzido
  em página estática); por isso a fase permanece `em revisão` e o `make check` completo não é
  declarado verde. O gate não foi desligado. Um host com `sniff capture` funcional deve rodar
  `make check` 2× + `E2E=1` e então marcar a ficha como `✅`.

### Fechamento da revisão (2026-10-25)

O host voltou a executar `sniff 0.5.1 capture`; `CHECK_QUIET=1 make check` fechou **24 OK · 0 FAIL**,
com `a11y` **OK (12 s)**, cobertura verde e `coverage-check` OK. A verificação independente da rodada
de correção foi aprovada. Fase marcada `revisado`; nenhum código foi commitado por agente.
