# Convenção da API — proposta para a Fase 2

Escrita em 29/08/2026. **As três decisões de fundo foram tomadas no mesmo dia**
e estão na seção final; o resto continua sendo proposta, aberta a revisão até a
primeira fatia.

O roadmap diz que as Fases 2 e 3 devem andar juntas, módulo a módulo, e que o
preço dessa fatia vertical é a consistência da API — com um antídoto barato:
*uma convenção escrita antes da primeira fatia*. Este é o documento.

Ele está escrito **antes** da primeira fatia de propósito. Depois dela, cada
regra aqui custaria retrabalho em vez de custar uma leitura.

---

## O que o sistema faz hoje, medido

Nada aqui é impressão. São contagens sobre os 63 controllers e as 99 rotas.

| | |
|---|---|
| Ações que devolvem JSON | **174** de 362 |
| Chave `sucesso` nas respostas | **179** ocorrências |
| Chave `mensagem` | **190** |
| Rotas que declaram verbo HTTP | **0** de 99 |
| Arquivos que chamam `setStatusCode` | **1** — e é o de documentação |
| Snapshots de contrato com HTTP 200 | **26 de 26**, inclusive nas falhas |
| Parâmetro vindo da query string | **330** ocorrências |
| Parâmetro vindo do corpo do POST | **1** |
| Ações que mutam estado | **190** |
| **Ações que mutam e são alcançáveis por GET** | **17** |

**A convenção de casa já existe, e é coerente:** toda resposta é
`{sucesso, mensagem, <recurso>}`, sempre com HTTP 200, com os parâmetros na
query string, numa rota que aceita qualquer verbo.

Não é uma convenção ruim por acidente — ela é consistente, e é ela que o
aplicativo instalado sabe ler. É por isso que a proposta abaixo **não a
substitui em bloco**.

---

## A regra que organiza todo o resto: dois mundos, fronteira explícita

Existe uma API que **não pode mudar** e uma que **ainda não existe**. Misturá-las
é o erro que custa caro, e ele é fácil de cometer sem perceber.

### Mundo 1 — o contrato do aplicativo: congelado

As 60 ações sob `/api`, `/api/v2` e `/api/v3` servem o aplicativo instalado nos
celulares dos clientes. Ele **não é atualizado junto com o backend**, e há
versões antigas ativas (D5). Os 28 testes de contrato existem para barrar
qualquer mudança de formato.

**Regra:** não se toca. Nem para "melhorar", nem para "padronizar". Um endpoint
que hoje responde 200 com `sucesso: false` continua respondendo 200 com
`sucesso: false` — mesmo que isso ofenda a estética REST.

O D56 mostra o tamanho do cuidado: corrigir um campo que sempre chegou nulo
mudou o contrato numa linha, e mesmo assim exigiu decisão de produto.

### Mundo 2 — a API do admin: a construir

O React do ERP não tem parque instalado. Nada consome esses endpoints hoje —
**as telas atuais são HTML gerado no servidor**. Então aqui há liberdade total,
e é onde a convenção nova se aplica.

### A fronteira

**Prefixo distinto, e visível na URL: `/adm/api/...`** para o mundo 2, deixando
`/api*` inteiramente para o mundo 1. **Decidido.**

Por que na URL e não numa camada interna: porque a fronteira precisa ser óbvia
para quem lê um log, um erro do navegador ou um trecho de código fora de
contexto. Uma fronteira que só existe na cabeça de quem escreveu não sobrevive
ao segundo desenvolvedor.

---

## As regras do mundo 2

### 1. Verbo diz a intenção, e a rota exige o verbo

`GET` lê. `POST` cria. `PUT`/`PATCH` altera. `DELETE` remove. E a rota
**declara** qual aceita — hoje nenhuma das 99 declara.

**Isto é a correção de um defeito real, não purismo.** Há **17 ações que mutam
estado e respondem a GET**, e a família `ativarRemover` está entre elas —
inclusive a `PesquisasController::ativarRemover` que, até o D58, derrubava a
página do cadastro de fidelidade.

O caso extremo dessa mesma forma já custou caro ao projeto: a rota
`/autenticacao/migrar-senhas` **sobrescreve o hash de todos os usuários ativos**,
e um GET a dispara. Um passo de verificação escrito para checar essa rota com
`curl` teria causado o incidente que pretendia detectar (ver F1.1).

GET que muta não é questão de gosto. É uma rota que qualquer prefetch,
pré-visualização de link ou clique duplo pode disparar.

### 2. Código HTTP diz o que aconteceu

`200` deu certo. `201` criou. `400` o pedido está errado. `401` não autenticado.
`403` autenticado e sem permissão. `404` não existe. `409` conflito de estado.
`422` validação falhou. `429` a mesma pessoa já tem um pedido igual em andamento.
`500` defeito nosso -- ou, com o código `erro_interno`, o armazenamento que não guardou o
arquivo, sem nada gravado (a regra 8). `502`, com o código `provedor_indisponivel`, o serviço de
fora de que o pedido depende falhou (a regra 11).

O `429` (código `em_andamento`, `RespostaApi::erroEmAndamento`) entrou em 13/10/2026,
com o D311: os clientes da rede e o melhor da rede do Profissional Parceiro andam um
de cada vez por pessoa, e o segundo, com o primeiro ainda no servidor, volta na hora.
Não é `409`: o pedido está certo e o recurso não tem estado que o impeça -- ele só
chegou cedo, e o mesmo pedido, depois que o primeiro terminar, dá `200`.

**A trava que é do recurso, e não da pessoa, é `409`** (23/10/2026, a fatia 31, o WhatsApp
da unidade): o outro pedido pode ser de outra pessoa ou de outra tela, e está mudando o
recurso -- o pedido seguinte pode dar outro `409`, como o `ja_conectado`. O `detalhes.motivo`
dela não é `em_andamento`, que é o código do `429`: no WhatsApp, `unidade_ocupada` (a regra 11).

Hoje **tudo é 200**, e quem chama precisa abrir o corpo para saber se funcionou.
Isso obriga cada tela a implementar seu próprio tratamento de erro, e faz
monitoramento por código de status não enxergar nada.

### 3. O corpo do erro tem forma fixa

```json
{
  "erro": {
    "codigo": "validacao",
    "mensagem": "O CPF informado já pertence a outro cliente.",
    "campos": { "cpf": "já cadastrado" }
  }
}
```

`mensagem` é para a pessoa. `codigo` é para o código. `campos` só aparece em erro
de validação, e é o que a tela usa para marcar o campo errado sem adivinhar.

`detalhes` só aparece no `409` que **pergunta**, e leva o número que a pergunta
precisa. O único hoje é o fechamento da agenda com agendamento no período:

```json
{
  "erro": {
    "codigo": "conflito",
    "mensagem": "Há 3 agendamento(s) no período. Reenvie com \"confirmar\": true para fechar mesmo assim.",
    "detalhes": { "agendamentosNoPeriodo": 3 }
  }
}
```

Até 23/09/2026 o 3 só vinha dentro da frase, e a tela o tirava de lá com
expressão regular (B10).

O `409` **também responde o conflito que não pergunta nada**: o estado do
recurso não deixa, e reenviar não muda a resposta. É o da remarcação de quem já
chegou, ou do agendamento que ocupa mais horários do que o serviço pede hoje
(D216, 23/09/2026). Esse vem **sem `detalhes`** — não há o que confirmar. Quem
chama sabe qual dos dois espera pelo endereço que chamou, e nunca pela frase.

Na agenda, as chaves de `campos` do `422` de marcar e de remarcar são de **dois
tipos**. Quando o pedido veio malformado, é o nome do campo (`data`, `horas`,
`observacao`), como em todo `422`. Quando o horário pedido não serve, é o
**código do motivo** (`horario-ocupado`, `horario-indisponivel`, `ferias`…): o
horário é um campo só, e o que a tela precisa dizer é por que ele não serve. Nos
dois, o valor é a frase para a pessoa. (A primeira versão deste parágrafo dizia
que eram sempre códigos; a revisão adversarial de 24/09/2026 mostrou o `horas` e
o `data` do pedido malformado.)

O **texto** que a API grava tem a regra da `ISBase\Regra\RegraDoTexto` (15/10/2026, o
D344): `conferir` para o de uma linha (o nome, o título), `conferirComQuebras` para o de
várias (o aviso, a descrição), que aceita `\n` e `\r`. O texto serve quando tem algo que
se vê (o que só tem espaço de qualquer tipo -- o U+00A0, o U+3000 -- ou caractere
invisível, como o U+FEFF, é vazio), tem de 1 a N caracteres depois do `trim`, não tem
caractere de quatro bytes (as tabelas são `utf8mb3` e o MySQL é estrito: o emoji dava
`500` lá e gravava no PostgreSQL), nem `<` ou `>`, nem caractere de controle. O controle
é o do Unicode (o C0, com a quebra de linha e a tabulação; o DEL; o C1, com o U+0085),
os separadores U+2028 e U+2029 e os que invertem a direção do texto (o `Bidi_Control`,
U+202E entre eles); é procurado antes do `trim`. A frase vai em `campos`, no nome do
campo. E a **chave que o recurso não altera** é `422` com o nome dela, e não o silêncio
(`RegraDoTexto::chavesQueNaoSeAlteram`): o `PUT` com a chave que o `GET` devolve, mas
que não se grava por ali, respondia `200` sem gravar nada; a chave que não é UTF-8
volta com `?` no byte inválido, para a resposta ter corpo. O formulário da tela antiga
do mesmo recurso usa a mesma regra (`RegraDoTexto::validadoresDoFormulario`) e grava o
`getData()`, e não o POST cru.

**Quem usa a peça hoje:** o catálogo, os pacotes, os convênios e os cupons, na API e no
formulário antigo, e a pesquisa, na tela antiga. **A única exceção ao `<` e ao `>`** são os
avisos do mural da agenda (`/adm/api/mensagens`, 25/10/2026): o título e o texto aceitam os
dois, porque saem escapados em toda tela que os imprime (o mural, a lista e o formulário) e
"Estoque < 5, pedir reposição" é aviso de verdade (a decisão 7 de
`docs/f3-telas-restantes/tela-mensagens.md`). Pela mesma peça, e não por uma cópia:
`RegraDoTexto::conferirComSinalDeTag` na API, e os `validadoresDoFormulario` sem o do sinal
no formulário antigo (`RegraDaMensagem::validadoresDoFormulario`). Recurso novo não entra
nesta exceção sem a mesma prova de que toda saída escapa. **Desde 07/11/2026 (o D495)** a usam
também a descrição da saída (`RegraDaSaida`), a observação do agendamento
(`RegraDaRemarcacao`, de várias linhas), o nome do amigo da fidelidade (`FidelidadeService`),
os textos do cliente na API (`RegraDoCadastroDeCliente`) e os motivos da comanda
(`RegraDoItemDaComanda`) e do fechamento (`RegraDeFechamento`). **O texto já gravado não trava a
edição:** quem altera e manda de volta o texto que está gravado passa, e só o novo ou mudado é
conferido contra o `<`, o `>` e o controle (o emoji e o teto valem sempre: a coluna não guarda
nenhum dos dois) -- quem chama passa o gravado à regra, e a regra o compara com o que chegou
como os dois são gravados (o texto de várias linhas com a quebra numa forma só: o `serialize()`
do jQuery manda `\r\n`; e sem aparar o controle das pontas, que o `trim` do PHP tiraria e a
coluna guardaria). Falta o cliente na tela antiga e nas etapas da fidelidade, e o motivo da
retirada que vira a descrição da sangria (o resto do D495). As regras novas chamam a peça, e não uma cópia; o
texto opcional (o nulo, e o vazio que vira nulo) é tratado antes, em quem chama.

**Nunca devolver só `mensagem`:** a tela acaba comparando texto para decidir o
que fazer, e aí uma correção de digitação vira defeito.

### 4. Sucesso devolve o recurso, não um envelope

```json
{ "id": 42, "nome": "...", "unidade": { "id": 7, "nome": "..." } }
```

Sem `sucesso: true` — o código HTTP já disse isso. Coleção devolve
`{ "dados": [...], "total": 1234, "pagina": 1, "porPagina": 50 }`.

O envelope da coleção é a única exceção à regra "sem envelope", e ela se paga: o
ERP já pagina de verdade, em **36 listagens**, e o total é o que a tela precisa
para desenhar a navegação. Sem ele, ou a tela pede tudo para contar, ou não sabe
quantas páginas existem.

*(Correção de 29/08: eu havia escrito aqui "99 usos de
`setFirstResult`/`setMaxResults`". **`setFirstResult` não existe no projeto** —
zero ocorrências. A paginação passa por `ISBase\Paginator\AdapterQuery`, que
delega ao paginador do Doctrine, e é ele que aplica o offset. Os 99 usos eram
todos `setMaxResults`, que é teto e não paginação.)*

### 5. Nomes de recurso no plural, em português — **decidido**

`/adm/api/clientes`, `/adm/api/clientes/42`, `/adm/api/clientes/42/pontuacoes`.

**Tudo em português, sem exceção** — inclusive o que for infraestrutura. Quando
a autenticação vier, é `/adm/api/sessao`, não `/adm/api/session`.

A razão é a que já valia: o domínio inteiro está em português — banco, entidades,
serviços. Uma API em inglês sobre um domínio em português obriga a traduzir
mentalmente a cada linha, e é onde nascem `comanda`/`order`/`sale` como se
fossem a mesma coisa. Não são: `vendas.status` distingue venda temporária,
comanda aberta, concluída e cancelada (D51).

A alternativa considerada era "infraestrutura em inglês, domínio em português".
Foi descartada, e bem: uma regra com exceção obriga a decidir, a cada recurso
novo, de que lado ele cai — e `pontuacoes` ou `comandas` não têm resposta óbvia.
Regra sem exceção não precisa ser lembrada.

### 6. Data e hora em ISO 8601 com fuso

`2026-08-29T14:30:00-03:00`.

Não é preferência. A carga para PostgreSQL já mostrou o preço de data sem fuso:
o dump trazia hora local, o contêiner nascia em UTC, e **13 milhões de linhas
ficaram três horas atrasadas** sem que teste nenhum acusasse (D49). O formato
brasileiro `29/08/2026` continua existindo — **na tela, não no protocolo**.

### 7. Dinheiro em decimal, nunca em float

As 85 colunas de valor são `double` nos dois bancos, e isso é dívida herdada. A
API **não propaga o problema**: valores viajam como string decimal (`"89.90"`),
que o JSON não arredonda.

A prova das somas na migração precisou ser reescrita exatamente por isso —
comparava em ponto flutuante com tolerância abaixo do que o tipo distingue, e
passava por sorte (D49).

### 8. O envio de arquivo — 20/10/2026

Nasceu com os banners do aplicativo (`/adm/api/banners`, a fatia 21 da fila das telas que
faltam; `docs/f2-fatia-banners.md`), e vale para os Arquivos e o VIP Class quando chegarem.

- **Multipart só em `POST`.** O PHP 8.1 só preenche `$_FILES` em `POST`: um `PUT` multipart
  chega com o corpo cru e o arquivo some. O cadastro é `POST` na coleção, com o arquivo e os
  campos de texto no mesmo `multipart/form-data`; a troca do arquivo é um **sub-recurso**,
  `POST .../:id/imagem`; e o `PUT` do recurso altera só os campos de texto, em JSON. O `PUT`
  que chega em multipart é `400`, com a frase que aponta o sub-recurso -- e não o "nada a
  alterar" de um corpo que pareceria vazio.
- **O teto é `422` no campo, e não `413`.** O arquivo acima do teto do recurso (5 MB nos
  banners), o envio que o PHP cortou pelo `upload_max_filesize` (`UPLOAD_ERR_INI_SIZE`) e o
  corpo inteiro acima do `post_max_size` -- quando o PHP entrega `$_POST` e `$_FILES` vazios,
  e a recusa diria "falta o arquivo" -- respondem `422` com o campo do arquivo e a frase do
  tamanho. A ação compara o `Content-Length` com o `post_max_size` antes de ler o corpo. O
  `413` só aparece quando o servidor web corta antes do PHP (o `client_max_body_size` do
  nginx), e esse não chega à ação.
- **O `error` do envio é lido.** Os códigos que não são `0` são recusa, cada um com a frase:
  o do tamanho, o do envio pela metade, o da falta.
- **O tipo pelo conteúdo**, e não pelo nome nem pelo `type` que o navegador manda: nos
  banners, `getimagesize` precisa dizer JPEG, com largura e altura. A extensão do nome gravado
  sai do conteúdo.
- **O nome físico é aleatório** (`bin2hex(random_bytes(20))` mais a extensão), gerado pelo
  servidor: o arquivo é guardado ANTES de a linha nascer, e a linha nasce com o nome final num
  `flush` só. O caminho é o lógico de sempre (`banners/<nome>`), pelo `Armazenamento`.
- **A ordem é fixa: guarda o novo, grava a linha, e só então apaga o antigo.** Guardar que
  falha não muda nada; gravar que falha apaga o novo e deixa o antigo; o antigo só sai depois
  do `flush`, e só se tem a forma de um nome gerado pelo servidor.
- **O armazenamento que falha é `500`, código `erro_interno`** (`RespostaApi::erroInterno`),
  com nada gravado: não há o que a pessoa corrija no formulário, nem estado que impeça.
- **A URL vem do `Armazenamento::url()`**, e não de um caminho escrito no código.
- **No cliente**, `FormData` -- sem `Content-Type` escrito à mão, que o navegador põe com o
  `boundary` -- e o proxy do Vite para o caminho público do arquivo. Desde a fatia 22
  (BANNERS-TELA), é o campo `formulario` do `pedir` (`frontend/src/api/cliente.ts`), no lugar
  do `corpo`; e o `413` do nginx, que volta em HTML, a tela o lê como o teto, no campo do arquivo.
  Com o envio no ar, o campo de arquivo fica desligado (a recusa que voltar é do arquivo que foi),
  e o foco que a recusa leva a ele espera o envio sair; a URL que a API devolve não garante o
  arquivo no disco, e a imagem que não carrega vira "sem imagem" (o `onError`). As duas lições
  da verificação da fatia 22, para os Arquivos e o VIP Class.
- **A barreira de origem do D290 cobre o multipart:** o formulário multipart de outro site é
  um pedido "simples", sem pré-voo, e ela confere todo verbo que escreve.
- **Nos Arquivos (22/10/2026, a fatia 29, ARQUIVOS-CADASTRO; `docs/f2-fatia-arquivos.md`, a F3):** o
  sub-recurso da troca é `POST .../:id/arquivo`; o envio é UM arquivo por pedido (`arquivo[]` é `422`,
  "Um arquivo por envio."), e a fila é da tela; o teto é o do servidor -- o menor entre o
  `upload_max_filesize` e o `post_max_size`, que `GET /adm/api/arquivos/categorias` informa em
  `limiteDoArquivo` para a tela recusar antes de enviar --, e não um número do recurso; o tipo pelo
  conteúdo é `%PDF-` no primeiro KB do PDF e o `PK\x03\x04` do ZIP na planilha (xlsx e xlsm). A
  extensão gravada é a do nome, depois de o conteúdo confirmá-la.

### 9. A planilha que se baixa — 21/10/2026

Nasceu com a planilha dos participantes do sorteio (`GET /adm/api/sorteios/:id/participantes`,
a fatia 25 da fila das telas que faltam, SORTEIOS-SORTEAR; `docs/f3-telas-restantes/tela-sorteios.md`).

- **Só o sucesso vem em outro formato.** O `200` é o arquivo, com o `Content-Type` dele
  (`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`), o `Content-Disposition:
  attachment` com o nome sugerido e `Cache-Control: no-store`. Toda recusa -- `401`, `403`, `404`
  -- é o corpo de erro da regra 3, em JSON, como em qualquer outra ação.
- **A guarda é a de quem escreve** quando o arquivo leva o contato das pessoas (o nome, o e-mail e
  o telefone de cada participante): o molde do preço de compra e do contato do ganhador.
- **Os bytes saem de um gerador que não termina o processo** (`ISMarketing\Xls\Sorteio::conteudo`):
  a ação os põe na resposta. A tela antiga também, desde 08/11/2026 (a fatia 122, o D115): o
  `gerarXls`, que ecoava os bytes sem `Content-Length` e saía, deu lugar ao
  `ISBase\Util\PlanilhaParaBaixar::naResposta` -- a mesma planilha nas duas portas, com o tamanho.
- **No cliente**, `baixar` (`frontend/src/api/cliente.ts`), e não o `pedir`: confere que o `200`
  veio no tipo prometido (o `200` em HTML é a tela de login, o D146) e lê o erro pelo mesmo corpo.
  A tela entrega o `Blob` ao navegador por um link com `download`.
- **O GET não grava nada**, como toda leitura.
- **Nos relatórios (26/10/2026, a fatia 43, REL-CONSULTA-E-EXPORTACAO; a C3 de
  `docs/f3-relatorios/spec-comum.md`):** `GET /adm/api/relatorios/<rota>/exportacao?formato=...`, com a
  mesma busca do `index` (o mês ou o intervalo, a unidade), na base `RelatorioAdmController::exportacaoAction`.
  Os formatos são os da política do relatório (`PoliticaDoRelatorio`, `formatos`), pela extensão real do
  arquivo de hoje: `pdf` (`application/pdf`, `inline` -- abre no navegador, como hoje), `xls`
  (`application/vnd.ms-excel`), `xlsx` e `txt` (`application/octet-stream`), os três `attachment`; sempre
  `Content-Length`, `Cache-Control: private, no-store` e `X-Content-Type-Options: nosniff`. O nome é
  `<rota>-<mês ou de_até>-<unidade-N ou rede>.<ext>`, e o TXT dos contatos, `clientes.txt`. As recusas, na
  ordem: as guardas da base (401, 403, 403 `papel`, 422 do período e da unidade), as do relatório
  (`guardasDoRelatorio`), o `404` do relatório que não exporta, o `422` no campo `formato` ("Um de: ...") e,
  no TXT sem contato com telefone, `404` -- e só então a sessão solta. O arquivo é montado da MESMA conta que o
  `index` responde (`documento()`/`contatos()`), e por isso é igual à tela. No cliente, o PDF é um link em
  aba nova (o `window.open` depois de um `await` cai no bloqueador), e o resto baixa por `baixarArquivo`
  (`frontend/src/api/arquivo.ts`, sobre o `baixarDoEndereco` do `baixar`). A família de rota nasce com o
  primeiro relatório (a fatia 47): até lá, nenhuma URL responde.
- **Nos pacotes (06/11/2026, a fatia 90, EXPORTAR-PACOTES; `docs/f2-fatia-pacotes.md`):** `GET
  /adm/api/pacotes/exportacao?estado=&nome=&unidade=`, a lista inteira em XLSX, com a mesma busca e o mesmo recorte do
  `index` (sem a página) e a guarda dele, o `ler` -- a planilha não leva contato nem nada que o `ler` já não abra (a validade e o total de sessões, desde a
  fatia 97, estão no `GET /adm/api/pacotes/:id`, com o mesmo `ler`). Os
  cabeçalhos são os dos relatórios; o nome, `pacotes-<ativos|inativos>-<dia>-<unidade-N|rede>.xlsx`. No cliente, o
  `BotoesDeExportacao` dos relatórios, sobre o `baixarArquivo`.

### 10. O arquivo que se abre — 22/10/2026

Nasceu com a biblioteca de arquivos (`GET /adm/api/arquivos/:id/conteudo`, a fatia 27 da fila das
telas que faltam, ARQUIVOS-SERVIDOR; `docs/f2-fatia-arquivos.md`), e vale para o VIP Class quando
chegar. É a regra 9 para o arquivo guardado, e não gerado.

- **Só pela API, com sessão.** O diretório do arquivo fica fechado no servidor web (`location ~*
  ^/arquivos/ { return 404; }`, sem caixa e a primeira regex, o D355): a URL pública do `Armazenamento::url()` NÃO serve para ele, e o
  `scripts/conferir-protecao-do-webroot.php` recusa o código que a pedir. A tela recebe, no recurso, o
  endereço da entrega (`conteudo`), e nunca o nome físico.
- **Só o sucesso vem em outro formato:** o `200` é o arquivo, com o `Content-Type` dele (pela extensão
  aceita, `RegraDoArquivo::tipoMime`), o `Content-Disposition` limpo com o nome que a pessoa vê
  (`RegraDoArquivo::disposicao`), o `X-Content-Type-Options: nosniff` e o `Cache-Control: private,
  no-store` -- com sessão, nenhum cache no caminho o guarda. Toda recusa é o corpo de erro da regra 3,
  MENOS AO PEDIDO DE NAVEGAÇÃO (08/11/2026, a fatia 123; o D574): o `Accept` que lista `text/html`, sem o
  `X-Requested-With`, recebe uma página curta com o mesmo código (`RegraDoArquivo::pedeAPagina` e
  `paginaDaRecusa`) -- a do `401` leva ao entrar; as outras trazem a frase. O `fetch` que pede JSON, o
  jQuery, o `*/*` e o pedido sem `Accept` seguem com o corpo de erro. O `401 personificacao_encerrada`
  da guarda de despacho (`PersonificacaoComOAcessoDeAgora::respostaDaVolta`) segue a mesma regra nesta
  rota: a navegação recebe a página, com a frase da volta (a correção da verificação da fatia 123).
- **`?disposicao=inline` (o padrão) ou `anexo`:** o primeiro abre no navegador, com o tipo do arquivo;
  o segundo baixa, como `application/octet-stream`. O que não for um dos dois é `422` no campo.
- **O que quem pede não vê é `404`, como o que não existe:** o inativo e o de categoria inativa, para
  quem não escreve no cadastro E é administrador (`RegraDoArquivo::veOInativo`, a mesma nas duas portas,
  o D571); o id que não é número de linha (`RegraDoArquivo::id`: no PostgreSQL o número fora do `int4`
  era 500, o D572); o link (que não tem conteúdo); o nome que não tem a forma do gerado pelo servidor
  (`RegraDoArquivo::nomeFisicoValido`); e o que sumiu do armazenamento, com a frase dele. Nunca `403`
  para o arquivo: o `403` é só o do privilégio.
- **A entrega mora num lugar só** (`ISConfiguracao\Service\EntregaDoArquivo`), chamada pelas duas portas
  -- a API e o "abrir"/"baixar" do ERP --: os mesmos bytes e o mesmo cabeçalho, conferidos por caso
  (`ArquivoApiTest::testAEntregaEAMesmaNasDuasPortas`) e pelo `ensaiar-arquivos-adm-api.php`.
- **A sessão é solta depois das guardas** (`soltarASessao`): o arquivo pode ter 12 MB, e a tela pede a
  lista junto. Quem abre (`RegistroDeQuemAbriu::quem`) é lido ANTES de soltar.
- **A entrega que sai fica registrada** (22/10/2026, a fatia 29b, a resposta 253 (c) do dono): o
  `EntregaDoArquivo` grava uma linha em `arquivos_acessos` -- o arquivo, quem entrou e a pessoa assumida
  quando difere (`CrudController::autorDaEscrita`), a unidade da sessão, abrir ou baixar, e quando. O
  registro é parâmetro obrigatório da entrega, e não um passo de quem chama. O `404` e a recusa não gravam;
  a falha ao gravar não muda a entrega (vai ao log), e dentro de uma transação o `INSERT` vai num ponto de
  salvamento (no PostgreSQL, o comando que falha abortaria a transação). A consulta é
  `GET /adm/api/arquivos/:id/acessos`, só a quem escreve no cadastro E é administrador (é dado de
  funcionário, o D583).
- **No cliente**, o `baixar` da regra 9 (`frontend/src/api/cliente.ts`) quando a tela baixa; para abrir
  em aba nova, um link para o `conteudo` basta -- o cookie da sessão vai junto, e o `401` em aba nova é
  a página curta acima (até 08/11/2026 era o JSON de erro, o D574; o `Blob` não entrou: perderia o nome
  do arquivo no visualizador).

### 11. O serviço de fora que falha — 23/10/2026

Nasceu com o WhatsApp da unidade (`POST /adm/api/whatsapp/conexao` e `.../desconexao`, a fatia 31 da
fila das telas que faltam, WHATSAPP-CONEXAO; a F2 de `docs/f3-telas-restantes/tela-whatsapp.md`): gerar
o código e desconectar falam com o provedor da unidade, que pode não responder, recusar a chave, não
achar a instância ou responder torto.

- **É `502`, código `provedor_indisponivel`** (`RespostaApi::erroDoProvedor`), e não `500` (o defeito
  não é nosso, e o servidor fez a parte dele), nem `409` (o estado do recurso não impede nada, e tentar
  de novo pode dar certo).
- **O `detalhes.motivo` é o motivo tratado**, de uma lista fechada -- no WhatsApp, `tempo_esgotado`,
  `sem_conexao`, `chave_recusada`, `instancia_inexistente`, `erro_do_provedor` e `estado_desconhecido`
  (`RegraDaConexaoWhatsApp::motivoDaFalha`, pelo código HTTP e pelo número do cURL, nunca pelo texto) --,
  e a `mensagem` é a frase nossa. **O texto do provedor nunca vai na resposta**: pode ser o corpo inteiro
  dele, ou o erro do cURL com o host.
- **A resposta torta também é `502`**: o que o cliente do provedor lançar vira `erro_do_provedor`, e o
  dado que não tem a forma pedida (no WhatsApp, o QR que não é imagem PNG ou JPEG em base64,
  `RegraDaConexaoWhatsApp::qrDaImagem`) não sai.
- **O `502` do nginx que não achou o PHP também é `502`, em HTML**: a tela os separa pelo `codigo`, e o
  cliente (`frontend/src/api/cliente.ts`) já dá ao HTML o código `resposta_nao_e_json`.
- **O que vem antes da rede é das outras regras**: o `401`, o `403`, o `404` e o `409` que o banco ou a
  trava decidem saem sem construir o cliente do provedor, e a sessão é solta antes da rede
  (`soltarASessao`), com um tempo limite curto e dito no controller.
- **A trava do recurso tomada é `409` com motivo próprio, e não o `429` `em_andamento`** (a regra 2): no
  WhatsApp a trava é da unidade (`RegraDaConexaoWhatsApp::TRAVA_DA_UNIDADE`), e não da pessoa -- a
  tomam os dois `POST` e o "gerar código" e o desconectar da tela antiga, de quem quer que seja --, e o
  motivo é `unidade_ocupada`. A tela não trata os dois do mesmo jeito: o `429` é "o seu pedido ainda
  está no servidor"; o `409` `unidade_ocupada` é "alguém está mexendo nisto: consulte o estado de novo".
- **A consulta que antecede a escrita, quando falha, dá o `502` com o motivo dela** -- a consulta de
  depois também (no WhatsApp, a que confere o `connect` que voltou sem imagem): o tempo esgotado diz
  `tempo_esgotado`, e não "respondeu com erro". **A falha que é o caminho da escrita não é `502`**: no
  WAHA, a sessão que não existe (a consulta em `404`) leva ao `connect`, que a cria
  (`RegraDaConexaoWhatsApp::oConnectCriaASessao`).
- **A escrita que chegou ao serviço de fora deixa rastro, e a recusa não** (23/10/2026, a fatia 32,
  WHATSAPP-RASTRO; o D591): no WhatsApp, o `200` grava `sucesso` e o `502` grava `falha` com o mesmo
  `detalhes.motivo` em `unidades_whatsapp_eventos` (`RastroDoWhatsApp`), com quem entrou e a pessoa
  assumida (`CrudController::autorDaEscrita`, lido antes de soltar a sessão); o `401`, o `403`, o `404` e
  todo `409` não gravam. A linha é gravada antes de soltar a trava da unidade, nas duas portas: a ordem
  das linhas é a ordem dos pedidos ao serviço. A falha ao gravar não muda a resposta (vai ao log, só a classe e o SQLSTATE). O
  `GET` do recurso devolve os últimos (`ultimos`), ou `null` onde a tabela ainda não existe.

### 12. O número da confirmação nas escritas de dinheiro — 07/11/2026

Nasceu com a fatia 103 (IDEMPOTENCIA-DAS-ESCRITAS-DE-DINHEIRO; o **D162** e o **D285**), pela resposta 87
do dono, a opção (a): *"A tela gera um número ao confirmar e o repete se o pedido falhar e o operador
tentar de novo; o servidor responde ao número repetido o que respondeu da primeira vez, sem gravar de
novo. Os dois pagamentos de verdade são duas confirmações e passam."* Vale em quatro escritas:
`POST /adm/api/comandas/:id/pagamentos`, `POST /adm/api/clientes/:id/creditos/compras`,
`.../creditos/devolucoes` e, desde a correção da fatia, `POST /adm/api/devedores/:id/quitacao` (a
quitação cria venda e soma ao caixa; a nova tentativa recebe o `200` da primeira vez, e não o `409`
`ja_quitado` que ela mesma causou). O recorte na tela nova é da triagem da fatia, e não do dono: o ERP
antigo não manda o número, e a pergunta foi ao lote (o D162 e o D285). As peças: `ISBase\Regra\RegraDaConfirmacao` (a regra pura),
`ISBase\Service\ConfirmacoesDaEscrita` (o banco) e o trait `ISBase\Controller\EscritaConfirmada` (o
controller); na tela, `frontend/src/api/confirmacao.ts`.

- **O número vai no cabeçalho `Idempotency-Key`**, o nome do rascunho da IETF: de 16 a 64 letras,
  algarismos, hífen ou sublinhado. A tela gera 32 hexadecimais por `crypto.getRandomValues` (que existe
  também fora de HTTPS). Fora disso, `400` `pedido_invalido`, sem gravar nada. **Sem o cabeçalho, nada
  muda** -- é o caminho do ERP antigo e de quem ainda não o manda. **A ordem na ação nova:** as guardas
  de sessão, privilégio e recurso; `confirmacaoRepetida($rota, $corpo)`; as recusas; e a escrita dentro de
  `comConfirmacao(fn)`. Cada porta tem o caso que prova a ordem (a resposta repetida depois de o estado
  mudar: a comanda finalizada, o saldo inteiro devolvido, o caixa fechado, o débito quitado).
- **A tela repete o número enquanto o pedido é o mesmo, e só até dar certo.** O "mesmo pedido" é o corpo
  sem a senha (a devolução leva a do gerente): outro valor ou outra forma é outro número; o que deu certo
  libera o próximo, e o segundo pagamento igual de verdade leva outro.
- **O número é de quem o mandou** (o usuário da sessão): o mesmo número de outro operador é outra
  confirmação.
- **O número repetido devolve a primeira resposta**, com o mesmo código e o mesmo corpo, e o cabeçalho
  `Idempotent-Replayed: true` (a tela não precisa dele; quem depura, sim). **A resposta repetida vem antes
  das recusas**: a comanda finalizada depois do pagamento, ou o crédito gasto por ele, não transformam a
  nova tentativa num `409` que faria o operador achar que nada entrou. Por isso a conferência fica logo
  depois das guardas de sessão, privilégio e recurso (o `404` da comanda de outra unidade continua antes).
- **A resposta guardada leva o que a resposta leva**, inclusive o nome do cliente e do operador (a comanda
  inteira, o débito com o telefone): a cópia anonimizada não limpa essa tabela, e a pergunta foi ao lote
  (o **D736**).
- **O número com outro pedido é `422`**, `detalhes.motivo` `confirmacao_de_outro_pedido`: a mesma
  escrita (o verbo e o endereço, com o id) e o mesmo pedido (o sha256 do corpo sem a senha, e da
  unidade da sessão) são a condição para devolver a resposta guardada.
- **Só o que deu certo se guarda (o 2xx).** A recusa não grava nada -- é a regra de toda recusa desta
  API --, nem o número: refazê-la com o mesmo número é seguro e dá a resposta de agora (o caixa aberto
  depois do "sem caixa", o saldo que mudou). Guardar a recusa prenderia o operador nela. Vale também
  para a recusa que vem de dentro do serviço, já com o número gravado (a devolução que o saldo, relido
  sob a trava, não comporta): ela volta ao ponto de salvamento.
- **O número entra antes da escrita, na mesma transação**, por um ponto de salvamento (a recusa e a
  chave repetida voltam a ele sem marcar para desfazer a transação de quem chama). Dois pedidos com o
  mesmo número ao mesmo tempo se enfileiram no índice único (`usuario`, `chave`) da tabela
  `confirmacoes`: o segundo espera o primeiro confirmar e devolve a resposta dele, sem ter tocado em
  nada; se o primeiro desiste (a recusa), o segundo grava. **O que fica de fora da corrida:** o segundo
  pedido que chega depois de o primeiro confirmar, mas ANTES de ler a confirmação guardada, passa pelas
  recusas de antes da escrita com o estado novo -- a devolução cujo saldo o primeiro gastou responde o
  `409` do saldo, e não a resposta do primeiro. Nada grava duas vezes; a janela é de milissegundos, e só
  com dois pedidos ao mesmo tempo (a nova tentativa do operador vem depois e acha a guardada).
- **Onde a tabela falta** (a base do teste do BVIP até a próxima cópia; a produção antes do passo 0 do
  corte), o número é ignorado e a escrita grava como antes (`ColunaNoBanco`).
- **Quanto tempo a linha fica é do dono** (o **D735**): até a resposta, nada apaga. A tentativa de novo
  precisa de minutos; a tela gera número novo a cada confirmação, e nunca reaproveita um antigo.

---

## O que fazer com as 174 ações que já devolvem JSON

**Não converter em bloco.** A fatia vertical vale aqui também: quando um módulo
entra na Fase 2, seus endpoints seguem esta convenção; até lá, ficam como estão.

Uma API metade nova e metade velha é desconfortável, mas é honesta — e é bem
melhor que uma conversão de 174 ações sem tela nenhuma para provar que funcionou.

---

## As decisões

Tomadas em 29/08/2026.

| Decisão | |
|---|---|
| **Prefixo** | `/adm/api` |
| **Idioma dos recursos** | **Português, sem exceção** |
| **Autenticação** | **Token** — cookie `httpOnly` no ERP, `Bearer` no aplicativo |

### Sobre a autenticação — são dois públicos, e a resposta é dupla

Eu havia recomendado sessão com o argumento de que token só compensa quando há
cliente que não é navegador. **O argumento estava certo e a conclusão estreita:**
há um cliente que não é navegador — o aplicativo Android e iOS do cliente final.
Ele só não aparecia porque hoje não se autentica.

São dois públicos com clientes tecnicamente diferentes, e **o que decide a
resposta é o cliente, não o usuário**:

| | ERP | Aplicativo |
|---|---|---|
| Quem usa | operadores de caixa, gestores | cliente final |
| Cliente técnico | navegador | app nativo Android/iOS |
| Ameaça dominante | **XSS** — qualquer script na página lê o que o JS alcança | **cópia do dispositivo**; não há DOM, não há XSS |
| Onde o segredo pode morar | só o cookie `httpOnly` escapa do JavaScript | Keychain (iOS) e Keystore (Android), protegidos pelo SO |
| Recomendação | **cookie `httpOnly` + `Secure` + `SameSite`** | **`Authorization: Bearer`** |

**Para o ERP: cookie `httpOnly`.** O conteúdo do cookie pode ser um id de sessão
ou um token assinado — isso é detalhe. O que importa é *onde ele mora*: num
navegador, o cookie `httpOnly` é o único lugar que o JavaScript não alcança, e
portanto o único que sobrevive a um XSS. O custo é proteção CSRF, e
`SameSite=Strict` resolve a maior parte dele.

Isso importa mais aqui do que pareceria: o ERP é onde se fecha caixa e se cancela
venda. Uma sessão roubada ali não vaza dado — movimenta dinheiro.

**Para o aplicativo: `Authorization: Bearer`.** Um app nativo não tem DOM, então a
ameaça de XSS simplesmente não existe. Em compensação ele tem algo que navegador
nenhum oferece: armazenamento protegido pelo sistema operacional. Cabeçalho é a
convenção nativa, e o tratamento de cookie nas bibliotecas HTTP móveis é
desajeitado e fácil de errar.

Vale o par **token de acesso curto + token de renovação**: o de acesso expira em
minutos, o de renovação fica no Keychain/Keystore. Assim um token capturado em
trânsito vale pouco tempo, e o cliente não precisa digitar senha toda semana.

**A decisão "token" continua valendo, e agora com dois carregadores distintos.**
Não é inconsistência: é a mesma credencial viajando do jeito que cada cliente
sabe proteger.

### O mecanismo do ERP não entra agora, e isso é deliberado

Por decisão sua, e não atrasa nada: nenhuma tela React existe ainda, e a
autenticação é a última coisa de que a primeira fatia precisa. Dá para construir
e provar endpoints com a sessão atual do ERP e trocar depois — a troca é de uma
camada só.

### O DO APLICATIVO ENTROU — 03/09/2026

A recomendação da tabela acima deixou de ser proposta para esse lado: `/api/v4`
autentica o cliente final com **`Authorization: Bearer`**, e o token é o que a
convenção descreve — 32 bytes de `random_bytes`, guardado como `sha256`.

O que a construção acrescentou à convenção, e que não estava escrito aqui:

**O par acesso + renovação continua recomendado e não foi construído.** Há um
token só, de 30 dias. Sem aplicativo em campo não havia o que exercitar, e o
segundo é acréscimo — a tabela tem espaço.

**Duas formas de resposta que faltavam nas regras.** O `202` para "recebido, e o
efeito acontece fora desta resposta" — o código entra na fila do WhatsApp, e
quem envia é o cron; dizer 200 afirmaria que a mensagem chegou. E o `401`,
distinto do `403`: **401 quer dizer "entre de novo"**, e a tela deve refazer o
login; **403 quer dizer "você está identificado, e isto não é seu"**, e mandar o
cliente refazer o login por causa dele seria mentir sobre a causa.

**As formas de resposta viraram um trait.** Elas moravam em `ApiAdmController`,
que estende `CrudController` — e o `CrudController` monta a sessão do ERP. Para
o mundo 2 isso está certo; para o mundo do aplicativo, herdar a autenticação
errada só para reaproveitar um `json_encode` seria pior que duplicar. O
`ISBase\Controller\RespostaApi` separa a **forma** da resposta, que os dois
mundos novos compartilham, de **quem está autenticado**, que é diferente em
cada um.

**A regra 5 — plural em português — sobreviveu ao caso mais difícil.**
`/api/v4/sessoes` e `/api/v4/sessoes/confirmacao`: a confirmação é um recurso da
sessão, não um verbo pendurado nela.

**Do lado do aplicativo, porém, há um pré-requisito que não é decisão e sim
leitura:** hoje a API do app **não autentica ninguém** — o id do cliente na URL é
a credencial (**D59**). Acrescentar autenticação às rotas atuais derruba todo
aplicativo instalado, então o caminho é endpoint novo em versão nova, com os
antigos vivos até o parque rodar.

E para saber *quando* os antigos podem morrer é preciso saber quais versões estão
em uso — a pendência da W3 que sai dos logs do nginx. **É o único passo que não
depende de decisão nenhuma, e destrava todos os outros.**

---

## As regras depois de duas fatias — revisão de 06/09/2026

O texto abaixo dizia que as regras eram proposta, não decisão, e que o momento
de revisá-las seria a fatia vertical, antes que a terceira as tornasse caras de
mudar. Duas fatias estão de pé — o PDV e a agenda, **dezenove endpoints** —, e
esta é a revisão.

**Nenhuma das sete caiu.** Três ganharam evidência que não existia quando foram
escritas, e uma ganhou emenda.

| regra | veredito depois de 19 endpoints |
|---|---|
| 1. Verbo diz a intenção | vale, **com emenda** — ver abaixo |
| 2. Código HTTP diz o que aconteceu | vale; 422, 404 e 409 estão em uso, e o 409 virou a forma de pedir confirmação |
| 3. Corpo do erro com forma fixa | os endpoints produzem `campos`; **nenhuma tela consome ainda** — não existe React neste repositório, então a regra está provada só do lado do servidor |
| 4. Sucesso devolve o recurso | vale; o envelope de coleção se pagou na primeira listagem paginada |
| 5. Plural em português | vale; nenhum caso pediu exceção |
| 6. ISO 8601 com fuso | vale; a agenda monta o instante de duas colunas de texto |
| 7. Dinheiro em decimal, nunca float | vale, e **ganhou um argumento novo** — ver abaixo |

### Emenda à regra 1: o verbo vem do que o serviço FAZ, não do nome da ação

O plano da fatia da agenda previa `PUT /adm/api/agendamentos/:id` para remarcar
e `POST /adm/api/agendamentos/:id/cancelamento` para desmarcar. **As duas
estavam erradas, e pelo mesmo motivo: foram escolhidas lendo o nome da ação, não
o serviço.**

- `remarcarSalvarAction` chama `agendar()` e depois `desmarcar()`. O agendamento
  que sai **tem outro id**. `PUT` promete substituir o recurso naquele endereço,
  e o recurso daquele endereço deixa de existir — virou
  `POST .../remarcacao`.
- `desmarcar()` chama `remove()`. Não há agendamento cancelado para a tela
  riscar; não há agendamento nenhum — virou `DELETE`.

Duas correções na mesma fatia, as duas descobertas lendo o código depois de o
mapa estar escrito. A regra 1 continua valendo; o que ela não dizia é **de onde
tirar o verbo**. Agora diz: do que o serviço faz com a linha, e não do
substantivo da tela.

### Reforço à regra 7: número em JSON depende do banco

Quando esta regra foi escrita, o argumento era o arredondamento de ponto
flutuante. Em 06/09/2026 apareceu um segundo, mais direto: **`SUM()` volta
número no MySQL e texto no PostgreSQL** (D118). Um valor numérico serializado
cru muda de tipo conforme o motor por baixo — `89.9` de um lado, `"89.9"` do
outro, no mesmo arquivo gerado pelo mesmo código.

Uma string decimal não tem esse problema: ela é a mesma nos dois bancos, e
continua sendo a mesma quando o banco mudar de novo. A regra que parecia
purismo de contabilidade é, também, a única que sobrevive à migração sem
depender do driver.

### O que esta revisão não faz

Não mexe na fronteira dos dois mundos: `/api*` continua congelado (D5), e
nenhuma das sete regras alcança aquele lado. E não converte as 174 ações que já
devolvem JSON — a decisão de 29/08 continua valendo, e as duas fatias não deram
motivo para revê-la.
