Files
Rafael MelgaçoandClaude Opus 5 fcac742291 Feat/canais oficial (#86)
* docs(canais): doutrina de restrição de canal + plano das fases 0-2

Auto-restrição (WAHA: bane se abusar) e hetero-restrição (Meta: proíbe e
cobra) são famílias distintas — nenhuma é subconjunto da outra. Doutrina
declara os 4 invariantes verificáveis + o contrato de parâmetros derivado
(a causa raiz do 'number of parameters does not match').

Sistema vivo ganha o invariante 6: toda configuração tem superfície.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* fix(plano): baseline de gates lê trace jsonb e exige turno de IA

before_send_traces.trace é array jsonb (não colunas) e a tabela exige
job_id de job_queue — só grava em turno de agente. Sem provocar a IA na
jornada, o CSV sai vazio e passaria em qualquer diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* test(canais): baseline de regressão da jornada WAHA antes do seam

Foto do "antes" das Fases 0-2, medida e não afirmada:
- unit 1035 passaram / 1 falhou (exit 1)
- e2e 29 passaram / 15 falharam / 14 não rodaram (exit 1)
- gates.csv com 9 linhas de UM turno real de IA (claude-sonnet-4-5):
  stop, lgpd, pacing, spinning, promise, semantic_promise,
  case_promise, disclosure — todos pass
- 7 screenshots da jornada vivida pela tela

Dois instrumentos nascem aqui para as tasks seguintes re-medirem:
tests/journeys/ (a jornada, fora de tests/e2e para não mudar a
composição da suíte que serve de régua) e scripts/provoke-agent-turn.ts
(o único caminho que escreve before_send_traces — a tabela exige job_id
de job_queue, então envio manual pelo inbox não gera trace).

O vermelho do unit é da BRANCH, não da main: o próprio plano cita os 7
PNGs por nome puro e o guarda de evidência os resolve contra a pasta do
documento. Fica anotado como constante conhecida, não consertado — trocar
a régua no meio da medição invalidaria a comparação das Tasks 1-7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* fix(canais): evidência versionada em evidence/canais e baseline e2e em série

A Task 0 gravou a prova em .superpowers/evidence/, que o .gitignore ignora
(linha 84) — a evidência vivia numa máquina só e nenhum clone a recebia. E o
plano citava os 7 screenshots por nome puro, que o guarda resolve contra a
pasta do documento: procurava docs/superpowers/plans/01-login.png.

- evidência movida para evidence/canais/ (convenção versionada do repo: já
  havia 96 arquivos rastreados lá), com README dizendo o que cada artefato
  prova e como re-gerar;
- plano, handoff e o default de CANAIS_EVIDENCE_DIR citam CAMINHO, não nome
  puro → tests/unit/evidencia-citada.test.ts 28 passed / 0 failed, e a suíte
  unitária inteira ficou verde (1038/1038, exit 0, era 1035/1);
- e2e re-rodada em SÉRIE: 41 passaram / 4 falharam / 13 não rodaram (3.6min)
  contra 29/15/14 com 5 workers (5.2min). 3 falham nas duas execuções
  (defeito provável), 12 só em paralelo (flake de concorrência), 1 rodou uma
  vez só. Nenhuma consertada — a task classifica, não conserta;
- Step 3 do plano trocou `$?` cru por `set -o pipefail`. NÃO ${PIPESTATUS[0]}:
  é do bash e no zsh expande vazio — gravou `exit=` ao ser testado.

Nenhuma linha de produção tocada.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* fix(plano): teste da Task 1 usa a assinatura real de decidePacing

PacingInput tem state ANINHADO e o veredito é 'allow', não 'allowed' —
o teste que eu tinha escrito não compilaria. Quarto erro de premissa
pego na auto-revisão antes de custar tempo de execução.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* fix(canais): horário comercial deixa de ser desarmado junto com o anti-ban

decidePacing fundia duas naturezas: cortesia (janela horária, domingo, fuso —
existe para não incomodar o cliente) e anti-ban (throttle, jitter, warm-up, cap
diário — existe para não ser banido). Um canal sem risco de ban precisa desarmar
as segundas sem levar as primeiras junto; senão, quando a API oficial entrar, a
IA passa a acordar cliente às 3h da manhã.

PacingInput ganha banRisk?: boolean e o curto-circuito fica DEPOIS da checagem
de janela. Default `?? true`: nenhum chamador existente muda de comportamento —
quem passa `false` de verdade é a Task 5.

Invariante 3 de docs/doctrine/restricao-de-canal.md.

Medido: teste vermelho primeiro, no caso certo (o cap de warm-up DESARMA);
sabotagem controlada movendo o guarda para antes da janela deixa o 1º caso
vermelho, provando que ele discrimina a ordem. Suíte 1042 passed / 137 arquivos
exit 0; typecheck e lint exit 0; jornada de 7 paradas re-vivida (3 passed) e
`diff` de gates.csv contra a baseline VAZIO.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): descritor de capabilities por provider, fail-closed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* fix(plano): filtro de teste era falso verde e invariants não gateia CI

Medido: 'pnpm run test:unit -- <filtro>' ignora o filtro e roda a suíte
inteira (exit 0 com o arquivo inexistente) — o step 'veja falhar' do TDD
reportaria PASS. Trocado por 'pnpm exec vitest run <filtro>'.

Registrado: tests/invariants/ (56 arquivos, inclui rls-isolation) está
excluído de test:unit e nenhum workflow roda test:db. Fora do escopo
deste épico; anotado para outra frente.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* fix(plano): Task 3 define RecipientInput e exclui InboundEvent do escopo

O código da task importava RecipientInput sem que o plano o definisse,
e InboundEvent não tem consumidor antes da Fase 3b — tipo especulativo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): ChannelAdapter com WAHA como primeira implementação

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* fix(plano): contrato do adapter distingue não-configurado de sem-external-id

Achado pela Task 3: send() -> {externalId:null} colapsava 'WAHA fora do ar'
(mensagem fica queued, nada sai) com 'enviou e o id não veio' (status sent).
A Task 4 transformaria queued em sent sem ter enviado — perda de mensagem.

Adapter ganha isConfigured() + codes{}. Os códigos vivem no adapter porque
carregam nome de provider, o que o lint da Task 7 proibiria no handler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* plan(canais): Task 4 vira 5 micro-passos com rede de caracterizacao antes

O caminho de envio central nao tem hoje nenhum teste que gateie PR: o
unico que exercita sendMessageHandler esta em tests/invariants (fora do
CI) e exige Postgres real via gov-helpers.

4a escreve a rede (6 desfechos enumerados do codigo, ordem inclusa),
4b/4c/4d trocam UMA chamada cada, 4e prova no mundo real. Se algo
quebrar, o culpado e um diff de 5 linhas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* test(canais): fixa os 6 desfechos do handler de envio antes do refactor

Rede de caracterizacao para as Tasks 4b-4d: 8 casos que assertam o estado
final da linha de mensagem (status/error_code/queued_reason/external_id/ack),
nao a sequencia de chamadas internas. Fake proprio de SupabaseClient — o
unico teste existente do handler vive em tests/invariants/ (fora do CI) e
exige Postgres real.

Cada desfecho foi sabotado no _handler.ts e vermelheceu no caso certo; o
arquivo de producao voltou com SHA-256 identico. Nenhuma linha de producao
alterada nesta task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* plan(canais): alerta da 4a sobre assimetria de erro nao procede

O adapter preserva o branch midia/texto (chama client.sendMedia ou
client.sendMessage como o handler faz hoje), entao a mensagem de erro
atravessa o seam intacta. Registrado para a 4d nao 'consertar' o que
nao esta quebrado — uniformizar seria a mudanca de comportamento que
as Fases 0-2 proibem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* refactor(canais): handler resolve destinatario pelo adapter, nao por resolveWahaChatId

Task 4b: uma substituicao. getWahaClient/sendMedia/sendMessage/parseWahaMessageId
seguem intocados. Provider fixo em 'waha' com comentario — channel_sessions.provider
so chega na Task 6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* refactor(canais): pre-check de configuracao do envio passa pelo adapter

Task 4c: ChannelAdapter ganha isConfigured() e codes (3 casos novos, vermelhos
antes da implementacao). O handler troca `if (!waha)` por
`if (!adapter.isConfigured())` e o literal do queued_reason por
adapter.codes.notConfigured.

getWahaClient() segue vivo: sendMedia/sendMessage ainda usam o cliente cru — a
4d os troca por adapter.send. Manter o getWahaClient nao basta para o tsc
(TS18047 nos dois sites, medido), entao o narrowing e um `!` comentado que a 4d
apaga junto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* refactor(canais): handler de envio fala com ChannelAdapter, nao com WAHA

Task 4d: sendMedia/sendMessage + parseWahaMessageId viram adapter.send(); o
literal "waha_error" vira adapter.codes.sendFailed; getWahaClient sai do handler.
storage_sign_failed segue literal — e falha do nosso Storage, nao do canal.

OutboundKind era mais estreito que o chamador real: a Task 3 escreveu 5 valores a
mao e input.type tem 8 (document/sticker/location/contact tambem chegam). Passou a
derivar de SendMessageInput["type"] — fonte unica, sem divergencia silenciosa.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* docs(canais): handoff registra 4b/4c/4d e as duas armadilhas de compilacao

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* test(canais): prova real do seam — envio pela tela com external_id do WhatsApp

Jornada revivida contra build FRESCO (o anterior era pre-4b e daria um
diff vazio mentiroso). diff do gates.csv contra a baseline: vazio.
Mensagem enviada pelo inbox chegou status=sent com external_id
3EB0644366757BD8B9CA71 devolvido pelo WhatsApp — o adapter fala com o
WAHA real, nao so com fetch stubado.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* chore(canais): handoff segue a convencao docs/handoffs/ trazida pela main

* plan(canais): invariantes voltaram a gatear o CI — premissa corrigida

Em 0ea9f4b (base desta branch) nenhum workflow rodava test:db e 56
arquivos de invariante ficavam fora do gate. Outra frente consertou em
ce93ab0 + 696f083, ja integrados via merge. A Task 6 volta a poder
ficar em tests/invariants/, que agora reprova PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* plan(canais): Task 5 tinha 3 premissas erradas sobre before-send

pacingGate nao e exportado (linha 304, ao contrario dos irmaos);
GateContext nao tem campo provider; e o construtor de producao do ctx
fica na linha ~507 do mesmo arquivo, onde provider precisa ficar fixo
em 'waha' ate a Task 6 trazer a coluna.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): pacing desarma por capability e registra skipped no trace

O gate de pacing pergunta capabilitiesOf(ctx.provider).banRisk e passa a flag a
decidePacing — nao curto-circuita antes dele, como o plano pedia: a janela
horaria vive no mesmo motor e e CORTESIA (invariante 3), entao um return
precoce desarmaria o horario comercial junto com o anti-ban. Sabotagem: com o
curto-circuito literal do plano, o caso da janela vermelheceu sozinho.

Quando o anti-ban nao se aplica, o veredito carrega skipped:'not_applicable' e
o runner grava {verdict:'skipped', code:'not_applicable'} em before_send_traces
— nunca um pass silencioso (invariante 4). Em producao o provider e literal
'waha' ate a Task 6: banRisk continua true, nada muda de comportamento, e o
gates.csv da jornada sai identico a baseline (diff vazio, exit 0).

DESKCOMM_GOV_INVARIANTS_EDIT=1 usado, e NAO e o flip de test.fails previsto pelo
hook: tests/invariants/case-guardrail.test.ts ganhou +1 linha no fixture
(provider: "waha"), obrigatoria para o tsc depois que GateContext ganhou o campo.
Nenhuma assercao do invariante mudou. Razao registrada no HANDOFF.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): o canal vem do banco, e a sessao meta_cloud e inexprimivel sem numero

`channel_sessions` ganha `provider text not null default 'waha'` e as tres
colunas do ramo Meta. Com ela, os dois ultimos literais 'waha' saem do caminho
de producao: o handler de envio resolve o adapter pela coluna e o ctx do
`before_send` le a mesma linha sob o advisory lock que ja tinha.

Tagged union, nao flag. `provider` sozinho aceitaria uma sessao meta_cloud sem
`meta_phone_number_id` e uma waha sem `waha_session_name` — as duas
irresolviveis na hora do envio, descobertas em runtime com a mensagem do
cliente ja aceita. O CHECK move a descoberta para o INSERT.

O indice unico de (organization_id, phone_number) que o plano pedia NAO entra,
e a decisao esta medida: `channel_sessions_phone_per_org_unique ... DEFERRABLE
INITIALLY DEFERRED` ja existe desde o snapshot e ja responde ao invariante
("um numero vive em UM provider"), porque nao olha o provider. Cria-lo de novo
duplicaria a checagem em toda escrita e poria uma trava NAO-deferivel ao lado
de uma deferivel, quebrando no meio qualquer transacao que hoje troca numeros
entre sessoes — que e exatamente o motivo de alguem te-la feito DEFERRABLE.

Backfill nenhum, por construcao e nao por sorte: o default preenche as linhas
existentes no mesmo ALTER e `waha_session_name` era NOT NULL ate aqui, entao
toda linha pre-existente ja satisfaz o ramo 'waha' quando o CHECK nasce. Vale
para qualquer clone — e e o que garante o `update.sh`.

O par de vocabulario (CHECK do banco x ChannelProvider do TypeScript) mora no
arquivo NOVO de invariante, nao na lista PARES canonica: `tests/invariants/**`
e congelado pelo pre-commit e a excecao documentada e outra. Nao usei a flag de
override — o pedido foi para a inbox (INBOX-004) com opcoes A/B.

Medido: 4 -> 4 sessoes, todas `waha`; `pnpm test:db` exit 0 (install ok,
update ok, 373 verdes); suite unitaria 1079 verdes (+2 meus) com o unico
vermelho herdado da main; jornada 3/3 pela tela e `diff` do gates.csv contra a
baseline vazio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): lint reprova nome de provider fora de lib/channels

O invariante 1 da doutrina de restricao de canal passa a ter mecanismo:
scripts/lint-channels.ts, no gov:verify e num step proprio do CI (medido:
nenhum workflow invoca gov:verify, entao so ele nao gatearia PR nenhum).

O plano estimava 4 infratores. Sao 56, medidos. Limpar todos exigiria
reescrever copia de tela, renomear campo de API publica (checks.waha,
waha_ban) e mover /api/v1/webhooks/waha/* — mudanca de comportamento, que
a Global Constraint 1 das Fases 0-2 proibe, e que e trabalho da Fase 3.
O lint virou catraca: divida itemizada em 4 categorias com razao escrita,
reprova infrator novo, entrada que ainda vaza, e entrada ja limpa (para a
lista so poder encolher). As 3 sabotagens ficaram vermelhas.

Limpos os 3 do caminho que as Fases 0-2 abriram. O waha_session_name do
handler nao virou excecao na allowlist: com dois providers o sessionRef
vem de waha_session_name OU de meta_phone_number_id, e essa escolha e de
lib/channels/. Nasceu resolveSessionRef, tipado com a tagged union que a
migration 0087 ja enforca — sem cast novo, sem ramo novo, sem desfecho
novo. Dois consumidores desde ja. "waha_unknown" seguiu o caminho da 4c:
o valor vai para o banco, entao atravessa o seam via adapter.codes.

Sabotagem achou um buraco real: a rede do handler assertava o endpoint do
fetch, nunca o corpo — um resolvedor errado mandaria session: undefined e
nada vermelhecia. O caso 5 ganhou a assercao do que chega ao fio.

Fases 0-2 fechadas: diff do gates.csv contra a baseline vazio, jornada
3 verdes contra build refeito do HEAD, e envio manual pela tela chegando ao
canal (status=sent, external_id=3EB0C84FF2954F12B3D118).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* spike(canais): deriveTemplateContract contra os 5 templates reais da WABA

Fixture com o payload REAL da Graph API (sem credencial). O spike achou
duas coisas que a contagem ingenua de {{n}} nao ve:

1. CAROUSEL aninha — cada card tem header/body/buttons proprios, com
   indices proprios no payload. O ParamSlot plano que eu tinha desenhado
   nao expressa isso; o endereco virou recursivo.
2. Header de midia (format IMAGE, sem text e sem example) nao tem {{n}}
   nenhum e mesmo assim e um slot. Contando placeholder daria 0 params
   para 2 dos 5 templates.

NAO provado end-to-end: a Graph API valida destinatario ANTES do payload
(131030), entao nao consegui fazer a API confirmar que header de midia e
obrigatorio. Evidencia e estrutural (format sem conteudo), nao empirica.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* test(canais): contrato de template ganha rede e parameter_format

8 casos contra o payload REAL da Graph API (fixture capturada da WABA de
teste), nao mock — mock escrito por quem escreveu a derivacao concorda
com ela por construcao.

parameter_format entra no contrato: POSITIONAL manda {type:'text'},
NAMED exige {type:'text', parameter_name:'...'}. A Meta declara o campo,
entao lemos em vez de inferir da chave — template NAMED com chave
numerica existe e seria classificado errado.

Sabotagem: sem o ramo de header de midia, 2 casos vermelham; sem o de
carrossel, 1. Restaurado e 8 verdes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* docs(canais): prova empirica dos dois erros de parametro da Meta

Medido contra a Graph API real, nao deduzido:
  132012  header IMAGE sem parametro -> 'expected IMAGE, received UNKNOWN'
  132000  3 params enviados com 2    -> 'localizable_params (2) ... (3)'
  OK      os 3 corretos              -> mensagem entregue de verdade

Fecha a pendencia do spike: header de midia E parametro obrigatorio,
era inferencia estrutural e agora e fato.

O ponto que justifica o desenho: contar {{n}} previne so o 132000 e e
CEGO ao 132012 — header format:IMAGE nao tem placeholder para contar.
Nos 5 templates reais desta WABA a contagem ingenua acha 3 parametros;
a derivacao acha 6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* plan(canais): Fase 3a — contrato de templates, sync, tela e trava por hash

6 tasks construindo sobre o spike (derivacao ja escrita e testada):
hash do contrato -> buildComponents -> tabela+sync -> webhook -> tela
-> trava por hash. Cada uma com TDD, sabotagem e prova.

Assume os fatos MEDIDOS contra a API real: 132000 e contagem, 132012 e
formato, contar {{n}} previne so o primeiro. E os limites do repo:
proximo NNNN = 0088 medido em TODAS as branches, status sem CHECK por
ser vocabulario aberto da Meta, filtro de teste com -- e falso verde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): hash do contrato derivado, estável a texto e sensível a parâmetro

O hash é do CONTRATO derivado, não do JSON cru: corrigir uma vírgula no corpo
não invalida a config de ninguém, mas acrescentar um {{n}} ou trocar o formato
do header invalida. Hash instável viraria alarme falso permanente e alguém o
desligaria.

Discordância com o plano: a assinatura só com `components` serializava
`parameterFormat` sem poder fazê-lo variar — campo morto no payload canônico.
NAMED e POSITIONAL exigem payloads de envio diferentes, então precisam de
hashes diferentes; o formato entrou como 2º parâmetro (default POSITIONAL).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): contrato + valores viram components[] da Meta, provado contra a API real

O segundo consumidor da mesma derivação que alimenta o formulário — é isso que
torna o mismatch impossível por construção, não por disciplina.

slotKey(address, key) é a fonte ÚNICA da chave de values e a tela usará a MESMA:
um carrossel de 2 cards tem dois slots com key '1', e chaveado só pela key um
sobrescreveria o outro em silêncio.

buildComponents LANÇA quando falta valor, em vez de montar payload capenga e
deixar a Meta reprovar com 132000 — o erro passa a nascer onde o operador ainda
pode corrigi-lo. missingSlots é a pergunta sem exceção, para a tela.

Prova contra a Graph API real (2 envios, e só dois): o payload montado volta
wamid/accepted; o mesmo payload com 1 parâmetro removido à mão volta 132000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* fix(canais): console.log do spike era o anti-pattern 14 e derivou a contagem de warnings

O handoff afirmou '156 warnings, identico a baseline' em seis tasks
seguidas. Deixou de ser verdade em 93a3a91, quando commitei o spike com
4 console.log — e eu continuei copiando o numero em vez de medir.

console.info (permitido; o script e uma CLI cuja saida E o entregavel).
Medido sem pipe: lint exit 0, 156 problems, 0 errors. De volta a baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): meta_templates — espelho local do template com contract_hash

Migration 0088 + apendice idempotente no baseline + MANIFEST. Tabela e
projecao sincronizada, nunca autoritativa: a Meta e a fonte.

Decisoes de schema, com razao no proprio SQL:
- chave (org, waba_id, name, language) — pt_BR e pt sao templates
  DISTINTOS na Meta; chavear so por nome os colapsaria
- status SEM CHECK (vocabulario aberto da Meta; CHECK quebraria o
  update.sh de um clone que ja gravou valor legado)
- parameter_format COM CHECK (valor normalizado por nos, fechado em dois)
- template que some da Meta vira DISABLED, nunca DELETE

Consertado no caminho: o apendice do baseline tinha 'create index' onde
o nome prometia unicidade — self-hoster ficaria SEM a trava e o ON
CONFLICT do sync quebraria.

Gates: test:db exit 0 (install ok + update ok, 380 testes), test:unit
1117 passed (1 vermelho herdado da main), typecheck 0, lint 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): webhook da Meta provado ponta a ponta com a Meta real

GET devolve hub.challenge em TEXTO PURO (envelopar quebra a verificacao),
POST valida HMAC SHA-256 com o App Secret, e evento desconhecido devolve
200 — recusar o que ignoramos faz a Meta re-entregar em backoff por horas.

Prova ao vivo (evidence/canais/fase3a/prova-webhook-ao-vivo.md), sem mock
em ponto nenhum: tunel cloudflared -> subscription registrada (a Meta so
registra depois de verificar) -> template criado pela Graph API nasce
PENDING -> a Meta aprova -> o webhook chega -> a linha vira APPROVED
sozinha. 3 requisicoes no tunel, 0 erros.

BUG que so a prova ao vivo revelou: a Meta manda reason:'NONE' em template
aprovado. O handler gravava o literal e o sync normalizava para null — a
MESMA coluna com duas convencoes. Regra unificada em normalizeRejectedReason
no modulo puro; teste + sabotagem travam.

A catraca lint-channels me pegou duas vezes: '.eq(provider, meta_cloud)' na
rota (movido para lib/channels/meta/session.ts) e a palavra WAHA na prosa de
um comentario. Nenhuma exceção adicionada — allowlist sem conserto e divida
silenciosa.

Gates: typecheck 0, lint 0, lint-channels ok, test:unit 1135 passed
(1 vermelho herdado da main).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): tela de templates — a superficie que o invariante 6 exigia

Existia disparo de template por follow-up sem NENHUM lugar para ver ou
configurar template. Mecanismo operavel so por quem le o banco nao e
operavel (sistema-vivo, invariante 6).

O contrato vai DERIVADO no payload, nunca guardado: guardar o derivado
criaria a segunda fonte da verdade que esta fase existe para eliminar.
Nao existe campo 'quantidade de parametros' em lugar nenhum.

Provado pela TELA (3 jornadas verdes, screenshots em
evidence/canais/fase3a/tela/): o operador chega clicando pelo hub, os
parametros aparecem com o texto ao redor como rotulo — inclusive o
header de midia, que nao tem {{n}} e e o slot que contar placeholder
nao ve — e o botao Sincronizar fala com a Graph API de verdade (200 +
toast com as contagens).

Dois defeitos que so aparecerem OLHANDO a tela, nao nos asserts:
- 'Recusado: NONE' em template aprovado: linha gravada antes do conserto
  do webhook. Normalizado tambem na LEITURA — clone atualizado ainda
  carrega o valor velho.
- o preview repetia o corpo inteiro a cada parametro, cada linha
  destacando o seu e deixando o vizinho cru. Agora o texto aparece uma
  vez so, com todos marcados.

Gates: typecheck 0, lint 0 (156 warnings, zero meus), lint-channels ok,
test:unit 1135 passed (1 vermelho herdado da main).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): trava por hash — template que mudou na Meta vira estado, nao erro de envio

O cenario: alguem configura um follow-up apontando para pedido_confirmado.
Semanas depois outra pessoa edita o template na Meta e o corpo ganha {{3}}.
A config salva continua mandando 2 parametros e o envio passa a falhar com
132000 — em producao, no disparo, com o lead ja esperando. Sem a trava, o
sistema so descobre no erro. E o estado atual do TomikCRM.

bindingState compara o hash do contrato vigente quando a config foi salva.
Precedencia missing > not_approved > stale > ok: template rejeitado E com
hash mudado reporta not_approved, porque mandar o operador reconfigurar
parametros de um template que a Meta recusou e trabalho jogado fora.

PENDING tambem nao e enviavel — 'quase aprovado' nao existe no disparo.

3 sabotagens verificadas (precedencia, idioma, PENDING), cada uma vermelha
no caso certo.

NAO escrevi o produtor de agent_inbox_items que o plano pedia: hoje nao
existe bind nenhum (o consumidor e o fallback do follow-up, Fase 4). Seria
mecanismo sem entrada, e pior, um escritor nao exercitado que parece
cobertura. A superficie nasce junto do consumidor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* plan(canais): Fase 4 — janela de 24h derivada e o template como saida

Duas premissas medidas ANTES de escrever mudaram o desenho:

1. conversation_window_expires_at nao existe aqui, mas
   conversations.last_inbound_at existe. A janela e DERIVAVEL. O Tomik
   guarda a coluna e meu desenho anterior copiou — errado: coluna
   guardada precisa de cron pra expirar e deriva do real. DIRC do
   proprio CLAUDE.md manda Calcular.

2. O follow-up NAO compoe a mensagem — enfileira turno com
   purpose:'send_message' e o AGENTE compoe. Entao tudo que ele manda ja
   passa pelo before_send, e o gate cobre o follow-up de graca. O
   'fallback de template no follow-up' que eu tinha desenhado presumia o
   contrario e some do escopo.

E uma premissa FALSA do repo que a fase conserta: o cabecalho de
before-send.ts afirma que mudar a ordem sem bumpar a versao 'quebra o
CI'. Nao quebra — a constante so aparece na propria definicao, nada a
testa. Task 1 cria o guarda ANTES de eu mexer na cadeia, porque escrever
a trava depois seria escreve-la ja conformada a mudanca.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* test(canais): a trava de ordem da cadeia before_send, que dois comentarios prometiam e nao existia

O modulo afirmava, em DOIS lugares, que mudar a ordem sem bumpar a versao
quebra o CI — e um deles nomeava o guarda: 'o snapshot pinado por versao
em before-send.test.ts'.

Medido: BEFORE_SEND_CHAIN_VERSION e BEFORE_SEND_GATES so apareciam no
proprio arquivo de definicao, e 'find . -name before-send.test.ts' nao
devolve nada. O arquivo nunca existiu. A ordem que o cabecalho chama de
'invariante de seguranca (regra dura no 2)' nao era verificada por nada.

5 casos travando ordem, primazia do stop, lgpd antes de gasto de recurso,
par (tamanho, versao) e unicidade. Tres sabotagens verificadas: trocar
pacing/spinning derruba a ordem; acrescentar gate sem bumpar derruba
ordem E versao; gate duplicado derruba ordem E unicidade.

Escrito ANTES de mexer na cadeia de proposito: a Task 3 acrescenta o
messagingWindowGate e este teste tem que vermelhar primeiro. Trava escrita
depois da mudanca ja nasce conformada a ela e passa por construcao.

Os dois comentarios agora apontam para o guarda real.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): janela de 24h derivada de last_inbound_at, nunca guardada

conversation_window_expires_at nao existe neste repo e NAO vai existir:
conversations.last_inbound_at ja e a verdade, e a janela e funcao pura
dele. Coluna de expiracao seria segunda fonte da verdade (precisa de cron
pra expirar, deriva no minuto em que um caminho de escrita esquece) e
falsamente tranquilizadora — data no futuro parece autoritativa mesmo
contradita pelo last_inbound_at. DIRC do CLAUDE.md: Calcular, nao Duplicar.

Fail-closed: sem inbound = FECHADA. Tratar null como aberta faria toda
prospeccao fria passar como se fosse resposta.
Carimbo no futuro nao estende: relogio torto em webhook nao vira licenca.

Uma sabotagem NAO vermelheceu e isso rendeu: trocar '<= 0' por '< 0' era
equivalente, porque Math.min(0, WINDOW_MS) da 0 nos dois. A linha tinha
forma nao-carregada. Reescrita como max(0, min(...)) — agora sabotar o max
derruba um caso, sabotar o min derruba outro, e sabotar o '> 0' de
isWindowOpen derruba quatro.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): gate messaging_window — o irmao de hetero-restricao do anti-ban

A cadeia vai a v5. O gate entra logo apos pacing porque os dois respondem
a MESMA pergunta ('posso falar agora?') com fisica invertida: pacing e
auto-restricao (o canal me BANE se eu abusar), messaging_window e
hetero-restricao (a plataforma me PROIBE e me COBRA). Nenhum e subconjunto
do outro — convivem, nao se generalizam.

A trava da Task 1 vermelheceu PRIMEIRO, como projetado: 2 casos (ordem e
tamanho/versao). A mudanca foi vista, nao presumida.

messagingWindow e OPCIONAL no ctx, ao contrario de provider — e nao por
conveniencia: ausente vale lastInboundAt null, que a janela le como
FECHADA. Chamador que esqueca produz VETO visivel, nao envio errado
silencioso. provider nao tinha default seguro; aqui o default e a direcao
segura, e isso evitou tocar num invariante congelado pela terceira vez.

A razao do veto diz a SAIDA (ferramenta send_template), nao so o problema:
veto que apenas nega faz o modelo tentar de novo igual e o turno morre em
silencio. Ha teste assertando a instrucao, nao so a negacao.

Provado em producao com turno real: diff contra a baseline v4 tem UMA
linha acrescentada (messaging_window,skipped,not_applicable) e zero
alteradas. Baseline v4 preservada — ela provou o que precisava provar.

3 sabotagens no gate + 3 na trava, cada uma vermelha no caso certo.
Gates: typecheck 0, lint 0, lint-channels ok, test:unit 1475 passed / 0 falhas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): sendTemplate — a porta que o gate da janela precisava

Sem isto o messagingWindowGate e parede sem porta: o modelo e vetado, nao
tem o que fazer, e o turno morre em silencio — o oposto do invariante 4 do
sistema vivo. Vetar e metade do trabalho.

Tres checagens ANTES de qualquer chamada, porque a Meta cobra por mensagem
entregue e so responde 132000/132012 depois de aceitar:
  1. bindingState — hash bate? existe? aprovado?
  2. missingSlots — todo parametro tem valor? (espaco em branco conta como
     ausente)
  3. buildComponents — a MESMA funcao que alimenta o formulario da tela

O resultado nunca e um false mudo: cada desfecho carrega o motivo, senao o
chamador teria de adivinhar se reconfigura, escala ou tenta de novo.

Template sem parametro NAO leva components: [] — a Meta recusa array vazio,
e ha teste travando isso.

PROVA REAL, um envio so: wamid.HBgMNTUzMTk4OTY2Mzk4... entregue no numero
de teste, com o hash vindo do espelho (digita-lo simularia o bind em vez de
exercita-lo).

3 sabotagens verificadas (nao checar bind: 3 vermelhos; nao checar valores:
2; components vazio: 1).

Removi 5 PNGs que eu havia commitado na task anterior: vieram de uma jornada
que falhou parcialmente e ninguem os citava. Screenshot de execucao
incompleta nao e evidencia, e o guarda bidirecional estava certo em cobrar.

Gates: typecheck 0, lint 0, lint-channels ok, test:unit 1483 passed / 0 falhas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): o gate reconhece template como saida, sem virar bypass

Descoberto ao escrever a tool: send_template nao pode pular a cadeia nem
passar por ela como esta.

Pulando, template vira bypass de TUDO — um contato que pediu para sair
receberia template. Passando como estava, o proprio messaging_window o
vetaria: a ferramenta bloqueada pelo gate que ela existe para resolver.

A flag isTemplate vive DENTRO de messagingWindow, nao num campo de topo do
contexto. Um ctx.isTemplate global convidaria outros gates a consulta-lo, e
ai o bypass voltaria pela porta dos fundos. Ha teste assertando que o topo
do contexto NAO tem esse campo.

Stop, LGPD, pacing e os demais continuam valendo integralmente.

2 sabotagens: tirar o passe derruba 1 caso; ignorar a flag (passar sempre
fora da janela) derruba 6 — o par texto-livre/template prova que o passe e
da flag, nao de relaxamento do gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): as duas pecas puras que a tool send_template precisa

explainSendResult — o que o MODELO le. Cada desfecho diz a CONDUTA, nao so
o problema, e so 'missing_values' e retriable: e o unico que depende do
modelo. Marcar outro como retriable faz o turno queimar tentativas ate
morrer, deixando o lead sem resposta. Sucesso manda PARAR de falar, senao
o modelo emenda texto livre depois do template e a janela fechada recusa.

renderTemplateBody — o texto que o LEAD vai ler, com os {{n}} aplicados.
Nasceu de uma consequencia boa do desenho: runBeforeSend recebe body:string
e o entrega aos gates de promessa/spinning/disclosure. Passando o template
RENDERIZADO como body, esses gates avaliam exatamente o que o contato vai
ler — sem isso, 'usar template' seria a forma de escapar dos guardrails de
conteudo. Parametro sem valor fica visivel como {{n}}: some-lo faria o gate
analisar um texto que nao e o que sai.

runBeforeSend ganha isTemplate opcional, que chega SO ao gate de janela.

Tipo estreitado na raiz: {sent:false} agora exclui reason:'ok', que nunca
acontece — o switch do tradutor fica exaustivo por construcao em vez de
exigir um default que engoliria caso novo em silencio.

4 sabotagens verificadas. test:unit 1498 passed / 0 falhas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* plan(canais): Task 5 bloqueada pela Fase 3b — descoberto implementando

O send que a tool injetaria precisa passar por channel.send, que registra a
mensagem em  e avanca o ledger (job_id, seq). Sem o adapter Meta
(lib/channels/index.ts:10 -> meta_cloud: null) nao ha por onde.

Contornar chamando sendTemplate direto custaria: template invisivel na
conversa (ilha, invariantes 3 e 6) e REENVIO em retry pos-crash — e
template, diferente de texto livre, e cobrado por entrega.

Toda a logica que decide algo ja esta pronta e testada fora do
inbound-turn.ts. Falta so o fio, e ele depende do adapter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): adapter Meta Cloud — o canal oficial deixa de ser null

Fase 3b. lib/channels/index.ts nao diz mais 'meta_cloud: null'.

Burro de proposito, como o irmao: traduz formato e nada mais. Janela, cap
e horario vivem na cadeia before_send.

Tres armadilhas medidas, nao deduzidas:
- nao existe 'sessao': o phone_number_id entra na URL, nao no corpo (no
  corpo a Meta devolve 400 sem explicar). Ha teste assertando que o corpo
  NAO tem .
- destinatario e E.164 em DIGITOS, sem + e sem sufixo: nada de @c.us, e um
  + sobrevivente vira (#131009).
- audio so vira nota de voz com voice:true; sem a flag chega como anexo de
  musica. E a Meta NAO converte — o outro canal converte por nos.

Grupo devolve null: a API de grupos da Cloud e recente e nao faz parte
deste seam. Honesto, em vez de montar endereco que seria recusado.

Sem credencial e NOOP, nunca excecao — mesmo contrato do outro canal.

PROVA REAL: wamid.HBgMNTUzMTk4OTY2Mzk4... E o envio provou algo que ninguem
projetou: texto LIVRE foi aceito, o que so acontece com a janela de 24h
ABERTA — ou seja, o dono respondeu um template e a janela abriu. A regra que
implementei como funcao pura foi observada acontecendo na plataforma.

4 sabotagens verificadas, 12 casos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* fix(canais): a rede pegou minha propria mudanca — caso 7 desamarrado do canal

O caso '7. provider sem adapter falha fechado' usava meta_cloud como
exemplo de 'sem adapter'. A Fase 3b criou o adapter e o caso ficou
vermelho. A rede fez exatamente o trabalho dela.

Trocado por um provider que NAO existe: o que se testa ali e o
fail-closed, nao qual canal esta pronto. Amarrado a um canal especifico,
o caso expiraria de novo na proxima fase.

Acrescentei o par (7b): sessao meta_cloud agora RESOLVE adapter e o envio
sai por graph.facebook.com. Um caso prova que provider desconhecido nao
vaza para canal nenhum; o outro prova que o oficial deixou de ser
desconhecido. Sozinho, nenhum dos dois diz isso.

Confissao de processo: commitei 8ec69d8 com este vermelho porque encadeei
os gates com ';' em vez de '&&'. O exit code estava la e eu nao o li. Esta
rodada foi encadeada com && de ponta a ponta.

typecheck 0, lint 0, lint-channels ok, test:unit 1511 passed / 0 falhas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): 'template' vira tipo de mensagem de primeira classe (0091)

O agente precisava disparar template com a mensagem APARECENDO na conversa
e sem reenvio em retry. Isso exige passar pelo POST /api/v1/messages, que
so conhecia texto e midia.

Gravar como type:'text' com o corpo renderizado compilaria — e seria mentira
no banco. O tipo e a unica coluna que carrega:
  * CUSTO: template e cobrado por entrega (desde 01/07/2025 a Meta cobra por
    mensagem); texto livre dentro da janela e gratis. Sem o tipo ninguem soma
    a fatura pelo historico.
  * CONFORMIDADE: fora da janela so template e permitido; auditoria que
    pergunte 'respeitou a janela?' precisa do tipo.
  * O QUE O CONTATO VIU: template tem cabecalho, rodape e botoes.

CHECK alterado, nao removido: messages.type e vocabulario FECHADO (quem
escreve e o nosso codigo), diferente de meta_templates.status e de
crm_lead_activities.type. Backfill nenhum por construcao — o conjunto antigo
e subconjunto do novo, entao todo clone passa.

template_name em COLUNA, nao em metadata: e a chave de custo e auditoria, e
metadata e vocabulario aberto por desenho.

NNNN=0091 medido em TODAS as branches (0089 e 0090 ocupados).

A rede de 11 casos ficou VERDE durante toda a mudanca — o ramo novo nao
perturbou os caminhos existentes. Mais 2 casos: envio real por
graph.facebook.com com nome/idioma gravados, e template ausente do espelho
falhando ANTES de sair (senao viraria 132000 cobrado e tarde). 2 sabotagens.

Gates: typecheck 0, lint 0 (zero warnings meus), lint-channels ok,
test:db install+update 388 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): send_template na mao do modelo — a Fase 4 fecha o ciclo

O gate veta texto livre fora da janela, a reason nomeia a ferramenta, e
agora a ferramenta existe. Turno vetado termina com template enviado, nao
com silencio (invariante 4 do sistema vivo).

Duas decisoes que impedem o template de virar bypass:
- o  que vai a cadeia e o texto RENDERIZADO, nao o nome do template:
  os gates de promessa, spinning e disclosure avaliam o que o contato vai
  LER. Sem isso, 'usar template' seria a forma de escapar deles.
- a chave de idempotencia continua sendo o texto. Troca-la por 'nome do
  template' faria dois envios com valores DIFERENTES colidirem no ledger e
  o segundo virar already_sent sem ter saido.

A tool so entra em canal que EXIGE template — tool inutil no prompt gasta
contexto e degrada a escolha do modelo.

DIVIDA DECLARADA: sabotei o gating e nenhum teste vermelheceu. O fio dentro
do inbound-turn.ts nao tem cobertura — testa-lo exige turno completo com
pool/LLM/job. A DECISAO esta testada (matriz de capabilities) e todas as
pecas que ela orquestra tambem; a LIGACAO nao. Registrado em
evidence/canais/fase4/prova-tool-send-template.md.

typecheck 0, lint 0, lint-channels ok, test:unit 1513 passed / 0 falhas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): variantes de telefone — o nono digito nao pode partir o contato em dois

Medido contra a WABA real, com payload capturado (fixture
tests/fixtures/meta/inbound-webhooks.json):

  envio (funcionou)     5531998966398   13 digitos
  wa_id do inbound       553198966398   12 digitos, sem o nono

A MESMA pessoa, dois identificadores. Sem tratar: quem recebe a mensagem e
quem responde viram DOIS contatos — conversa partida, historico do lead
fragmentado, e silencioso ate alguem ver dois cadastros com o mesmo nome.
O normalizePhoneBR que ja existe no repo so formata; nao resolve isso.

VARIANTE, nao normalizacao (decisao do dono): normalizar na entrada
uniformiza o dado e pode FUNDIR dois contatos reais — um fixo legitimo de
12 digitos viraria um celular inexistente, e fusao nao tem volta. Gerar
variante so amplia a BUSCA: nenhuma escrita muda, e errar custa um select.

Dois defeitos que os testes acharam em mim:
1. a regra 13->12 era generosa onde a 12->13 era prudente: removia o 9 de
   qualquer 13 digitos, entao um 9 grudado num fixo (5531 9 3234-5678)
   gerava a variante do fixo REAL de outra pessoa. Simetrizada.
2. a sabotagem 'tirar a checagem de +55' passou VERDE — meus exemplos
   estrangeiros caiam fora das regras por tamanho e nao provavam nada sobre
   pais. Acrescentado caso discriminante (123498765432, que ganharia
   variante espuria de outro pais).

3 sabotagens, 16 casos. test:unit 1529 passed / 0 falhas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): o canal oficial passa a RECEBER — a metade que faltava

Ate aqui o oficial era um megafone: sabia enviar e nao sabia receber. O
webhook so tratava status de template e entrega das NOSSAS mensagens; o
campo messages[] (do contato) caia fora.

Consequencia que ninguem tinha visto: sem inbound, conversations.last_inbound_at
nunca se move no canal oficial — e a janela de 24h da Fase 4 deriva dele.
O gate vetaria PARA SEMPRE e o sistema so saberia falar por template.

Parser escrito contra payload REAL capturado da WABA (fixture
tests/fixtures/meta/inbound-webhooks.json), nao mock. Tres coisas que so o
real revelou:
- o timestamp vem em SEGUNDOS (tratar como ms daria 1970)
- a midia vem com URL PRONTA, nao so media_id como eu antecipava — mas com
  ext= de expiracao, entao baixa na hora
- inbound e status chegam no MESMO field 'messages'; o que separa e
  messages[] vs statuses[]. Tratar no mesmo if faria um mascarar o outro
  quando viessem juntos (ha teste com payload misto)

A ingestao REUSA as operacoes canonicas do outro canal
(fn_upsert_wa_contact / fn_upsert_wa_conversation / fn_mark_conversation_message)
— escrever uma segunda resolucao de contato recriaria a divergencia que a
migration 0027 eliminou. E resolve o contato por phoneLookupVariants: sem
isso o nono digito parte a pessoa em dois cadastros.

Idempotencia por 23505 nao e higiene aqui, e obrigatoria: a Meta re-entrega
tudo que nao recebe 2xx.

3 sabotagens, 11 casos. test:unit 1540 passed / 0 falhas (uma rodada
anterior deu 22 timeouts com load average 40 — re-rodada limpa).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* fix(canais): inbound gravava nada e a rota nao contava — duas falhas minhas

Duas coisas, e a segunda escondia a primeira:

1. messages.channel_session_id e NOT NULL e eu nao o incluia no insert.
   Toda ingestao falhava.

2. A rota fazia 'await ingestMetaInbound(...)' e DESCARTAVA o retorno. O
   insert falhava e a resposta era {'received': 1}. 'Chegou e falhou' ficou
   indistinguivel de 'nao chegou' — e eu passei uma hora diagnosticando o
   tunel e a Meta, que estavam certos.

Escrevi a falha silenciosa que a doutrina do sistema vivo proibe, no
caminho onde ela mais engana. A rota agora devolve  no corpo e
loga estruturado. 2xx continua (senao a Meta re-entrega em backoff por
horas), mas o silencio acabou.

PROVA AO VIVO: mensagem de um celular real entrou como
wamid.HBgMNTUzMTk4OTY2Mzk4, e last_inbound_at foi carimbado — que e o que
ABRE a janela de 24h. Sem inbound o gate da Fase 4 vetaria para sempre.

O nono digito, com CONTRAPROVA: contato gravado com 13, wa_id chegando com
12. COM phoneLookupVariants -> 1 contato. SEM (sabotado) -> 2 contatos e
duas conversas para a mesma pessoa. A primeira medicao NAO exercitou o
caso (nao havia contato previo) e eu quase reportei 'nao duplicou' como
prova — foi preciso construir o cenario.

Achado do caminho: wa_identity e coluna GERADA, deriva de phone_number.

MEDICAO: typecheck 0, lint 0, lint-channels ok, e 70 casos verdes nos 5
arquivos tocados. A suite COMPLETA nao foi medida nesta rodada — a maquina
esta com load ~25 (13 containers de outra sessao) e estourou 10min. A
ultima medicao limpa foi 1540 passed / 0 falhas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* docs(canais): Embedded Signup nao cabe em self-host — premissa copiada sem verificar

O dono questionou e ele estava certo. A doc da Meta confirma: Embedded
Signup e para Tech Providers e Solution Partners, e exige App Review com
Advanced Access, Business Verification e Access Verification — sem as
tres, teto de 10 clientes por 7 dias.

Num self-host os dois caminhos sao ruins: ou cada instalacao vira Tech
Provider (App Review POR INSTALACAO, semanas antes do primeiro envio), ou
o projeto vira Tech Provider central e deixa de ser self-host.

BYO nao e limitacao, e a arquitetura certa: o self-hoster JA precisa criar
o proprio app e a propria WABA para ter numero oficial. Colar credencial e
o passo natural seguinte.

A Fase 5 vira: dar SUPERFICIE ao BYO (invariante 6) — tela que valida a
credencial na hora, mostra webhook URL e verify token prontos, e guarda a
credencial POR SESSAO cifrada, em vez de env global (que hoje limita a
instalacao a uma WABA so).

Licao de metodo registrada na doutrina: escrevi 'Fase 5 = Embedded Signup'
em tres planos sem perguntar se cabia no modelo. Vinha do TomikCRM, que e
SaaS. Premissa copiada de outro contexto nao vira verdade por estar
escrita em tres lugares.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): credencial do canal oficial por SESSAO — o multi-tenant que faltava

Ate aqui getMetaCreds() lia META_PHONE_NUMBER_ID e META_SYSTEM_USER_TOKEN do
ambiente. Funciona para quem tem UM numero, e torna impossivel duas
organizacoes com numeros oficiais diferentes na mesma instalacao —
contradizendo o multi-tenant que o CLAUDE.md estabelece desde o dia 1.

Foi o dono quem expos isso, ao questionar o Embedded Signup: perguntando se
a Fase 5 cabia no modelo, ficou visivel que a credencial em env ja era o
limite arquitetural.

A credencial passa a viver em channel_sessions (colunas da migration 0087),
cifrada pelas MESMAS RPCs do resto do repo (fn_encrypt_oauth/fn_decrypt_oauth,
lib/webhooks/secrets.ts). Um terceiro caminho de cifra seria mais um lugar
para a chave vazar.

Ordem SESSAO-primeiro, env como fallback nomeado (source:'env'): um env
esquecido nao pode silenciar o que foi configurado pela tela, senao o
operador nao entende por que nada mudou. E instalacao de numero unico segue
funcionando sem tocar em nada.

isConfigured() continua olhando so o env: e sincrono por contrato, e a
resposta com credencial na sessao vem do banco. Quem confirma e o send, que
e async — devolver false aqui gravaria  sem motivo.

Os testes do adapter VERMELHARAM quando a resolucao por sessao entrou (o
fetch stubado passou a capturar a query do Supabase antes da Graph API). O
vermelho foi correto e virou cobertura dos dois caminhos. 2 sabotagens:
inverter a precedencia derruba 1 caso; tirar o fallback derruba 3.

typecheck 0, lint 0, lint-channels ok, 55 casos verdes nos modulos tocados.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): tela de conexao do canal oficial — a superficie que faltava ao BYO

A credencial por sessao existia mas so era gravavel por SQL, o que viola o
invariante 6 que eu mesmo escrevi na doutrina.

A rota VALIDA contra a Meta antes de gravar. Gravar primeiro e descobrir
depois e o que faz o operador achar que conectou e so entender que nao na
primeira mensagem que nao sai, com o lead esperando. E o motivo da recusa
vem do campo  da Meta — e ele que distingue token vencido de numero
errado de permissao faltando.

O token NUNCA volta num GET: a tela mostra que a credencial EXISTE
(hasToken), nunca qual e. Devolve-la para preencher o campo seria vaza-la a
cada render.

A tela entrega a URL de webhook e o verify token PRONTOS para colar no
painel da Meta. Sem esse passo o canal envia e nao recebe — as respostas do
cliente nao chegam e a janela de 24h nunca abre. Descrever onde achar nao
resolve; a tela da o valor com botao de copiar.

A catraca me pegou de novo: a rota chamava graph.facebook.com direto.
Movido para lib/channels/meta/validate-credentials.ts — e o conserto e
melhor desenho, porque a rota nao deve saber com quem fala.

Sem upsert: a trava unica de (org, phone_number) e DEFERRABLE e o Postgres
recusa constraint deferivel como arbitro de ON CONFLICT — medido ao criar a
sessao de teste da Fase 3b.

MEDIDO: typecheck 0, lint 0 (zero warnings meus), lint-channels ok, build
com as duas rotas, 63 casos verdes nos modulos de canal.
NAO MEDIDO: a jornada pela tela. O Docker travou no meio (mesmo sintoma do
inicio da sessao) e derrubou Postgres e Supabase locais. O spec esta escrito
(tests/journeys/canal-oficial.spec.ts) e cobre os tres casos, incluindo
credencial ERRADA recusada com 422 — o caso que separa 'validar' de 'aceitar
e torcer'. Fica para rodar com o ambiente de pe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* test(canais): jornada da tela de conexao — 2 de 3 provados, e o 3o expos um buraco do kit

PROVADO pela tela: o admin chega clicando pelo hub, e credencial ERRADA e
recusada com 422 sem estragar a conexao existente — o caso que separa
'validar' de 'aceitar e torcer', falando com a Graph API real.

NAO PROVADO: conectar com credencial valida. Devolve 422 e o codigo esta
CERTO — a rota recusa gravar quando a cifra nao esta disponivel, porque
guardar token de acesso em claro seria pior. Nao consegui provisionar a
chave localmente (Supabase local recusa ALTER DATABASE como postgres;
supabase_admin pede senha).

ACHADO MAIOR QUE O TESTE: hostgator-setup-kit/install.sh NAO provisiona
app.nuvemshop_oauth_key — nem o update.sh (medido por grep). Em toda
instalacao self-host de hoje fn_encrypt_oauth levanta erro, entao segredo
de webhook e token OAuth do Nuvemshop tambem nao sao cifrados at-rest. A
doutrina do repo ja diz COMO injetar (spec 06, linha 491); o que falta e o
kit faze-lo. Anterior a este epico, afeta feature ja na main.

Tres correcoes de teste no caminho, todas por presumir estado:
- o laco de MFA tinha timeout de 10s e re-digitava num campo que ja sumira
- 'toHaveCount(0)' presumia banco limpo; o que importa e a conexao boa
  sobreviver a tentativa ruim
- 'credencial guardada' presumia um estado que so existe DEPOIS do caso 3

E seis diagnosticos errados meus antes de fotografar a tela: a causa era
PostgREST com 'name resolution failed' apos o restart do Docker, que fazia
loadAuthUser (que ENGOLE o erro, lib/auth/server.ts:47) esconder todos os
cards de admin. O bug silencioso que eu mesmo reportei na Task 4e.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* test(canais): fase 5 fechada, e o buraco que reportei no kit nao existia

A jornada 3 estava declarada bloqueada por falta da chave de cifra no
install.sh. O install.sh provisiona: install.sh:629 e update.sh:124 chamam
ensure_encryption_key, que semeia private.app_secrets. Eu havia grepado por
ALTER DATABASE ... SET, que e o que a spec descreve — e a implementacao
divergiu de proposito, com o motivo escrito duas linhas acima do codigo que
nao li (Supabase cloud recusa GUC custom). O buraco era so do meu ambiente.

Com a chave semeada: 10/10 jornadas verdes em 52,8s, e o token gravado
decifra no banco (EAASbhCM..., 203 chars) — a tela prova que existe, o
banco prova que serve.

lib/auth/server.ts falha alto: as queries de platform_admins e
user_organizations descartavam o erro, entao banco instavel virava "voce
nao tem organizacao". Foi o que sumiu com os cards de admin desta sessao e
custou seis diagnosticos errados. Travado por 5 testes; sabotagem derruba 3
e mantem os 2 caminhos felizes verdes.

Jornadas: loginAdmin estava copiado em 3 specs com o conserto em 1 —
extraido para _login.ts. O flake restante (primeiro teste de cada arquivo,
arquivo variavel) cedeu ao logar uma vez via storageState; a hipotese de
replay de TOTP foi descartada por medicao, nao por opiniao.

De graca, por rodar tudo junto: o teste 6-7 (follow-up + Radar) nao estava
quebrado, estava sendo abortado; e o seletor "Enviar" casava por substring
com o preview de uma conversa. Removi tambem uma config de playwright que
eu mesmo dupliquei sem procurar a que ja existia.

typecheck 0 · lint 158 (0 nos arquivos tocados; +2 sobre 156 vieram da main
em 206797e8, medido) · unit 1561/1561 · jornadas 10/10

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* docs(canais): o MANIFEST do 0087 citava o arquivo errado de cobertura

O pareamento do CHECK de provider com ChannelProvider existe e passa, mas em
channel-provider-schema.test.ts:147 — nao como linha em
vocabulario-banco-x-typescript.test.ts, que e o que o registro dizia. Aquele
arquivo pareia coluna com simbolo por extrator generico sobre arrays de
valores; o vocabulario do provider vive numa union de tipo e precisa de
extrator proprio.

So apareceu porque um hook me obrigou a abrir o arquivo que eu afirmava ter
mudado — e o git log provou que eu nunca o toquei nesta branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* feat(canais): o agente para de disparar template nao aprovado

A tela diz "so APPROVED pode ser disparado" e o caminho HUMANO respeitava
(template-binding -> not_approved). O caminho do AGENTE nao consultava o
status — e e o unico que age sem humano olhando. Template PENDING ou
REJECTED ia a Graph API, voltava erro generico, e o modelo lia como falha
de infra em vez de configuracao pendente.

Ligado a MESMA regra: isStatusSendable exportado de template-binding (funcao,
nao constante, para o call-site nao reimplementar a comparacao). Erro separado
de template_desconhecido: criar template e resolver reprovacao sao acoes
humanas diferentes.

Mais o guard da ligacao que faltava (15 casos): a tool so existe onde o canal
exige e a decisao vem de capabilitiesOf (nao de literal de provider); o corpo
renderizado passa pela cadeia; isTemplate: true; o veto retorna antes do
sucesso; o envio carrega template como template. Sabotagem em 7 pontos, cada
uma derrubando exatamente uma assercao.

E o teste de repasse do adapter (3 casos), que nasceu de um alarme falso meu:
grep por "template" no waha-adapter acha so um comentario, mas o adapter passa
o input INTEIRO adiante. Segunda vez no mesmo dia que grep de padrao me fez
concluir ausencia de mecanismo. O teste ficou porque o repasse so era
garantido pela estrutura do TypeScript — e esta provado que o typecheck NAO
pega: destrinchar o input em campos nomeados derruba o template e compila.

typecheck 0 · lint 164 (0 nos arquivos tocados) · unit 1664/1664

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* chore(canais): evidencias regravadas na rodada de 10/10

Re-execucao das jornadas apos o gate de status do send_template: nada
regrediu pela tela. As imagens sao o mesmo conjunto ja citado no handoff,
com timestamp novo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* test(canais): o turno completo envia template — o harness que eu disse faltar

Eu havia declarado que provar isto exigia "um harness que este repo nao tem"
e que construi-lo era um projeto proprio. Errado: o custo era um arquivo.

O que me fez errar foi presumir que openingContext saia para o CRM por MCP.
Nao sai — getLeadContext recebe o cfg como _cfg, com underscore, e le o
Postgres direto. Os seams necessarios ja existiam, e createFakeRegistry (o
modelo fake do repo) estava definido SEM NENHUM consumidor.

5 casos contra Postgres real, inbound de 30h (janela fechada de verdade):
o template sai com corpo renderizado e identidade preservada; o gate
messaging_window deixa passar (a flag fazendo efeito, nao so existindo);
PENDING e recusado, nada sai, e o modelo LE o motivo (inspeciona o
tool-result que voltou); inexistente idem com outro codigo; em WAHA o
modelo TENTA a ferramenta e ela nao esta la.

Sabotagem em 3 pontos: isTemplate:false derruba os 2 casos de envio (a
janela realmente veta); gate de status desligado derruba o PENDING;
requiresTemplates:true no WAHA derruba o caso do canal.

Tres erros meus que o arquivo registra: o spike mentiu por assert frouxo
(so checava enviados>0 e capturava o erro — o turno enviava SEM FECHAR);
o caso do WAHA nao discriminava (modelo que so falava texto passaria com a
tool presente); e eu nao completava o job, entao o claim seguinte nao pegava
nada com maxConcurrency 1.

scripts/test-db.sh passa "$@" ao vitest — da para rodar um invariante
isolado, em 3min em vez dos 60 arquivos por sabotagem.

typecheck 0 · lint 164 (0 nos arquivos tocados) · unit 1669/1669 ·
test:db 399 passed | 1 skipped, 60 arquivos

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* test(jornadas): um worker — o paralelismo estava mascarando defeito

fullyParallel: false serializa apenas DENTRO de cada arquivo; entre
arquivos os 3 workers concorriam sobre a MESMA organizacao, o MESMO
contato e a MESMA sessao de canal — enquanto canal-oficial troca a
credencial da sessao, canais-baseline envia por ela.

Medido em 4 corridas do mesmo codigo: 8/10, 10/10, 9/10, 8/10, com falha
diferente a cada vez. Com workers: 1 a instabilidade virou uma falha
DETERMINISTICA — que e o desfecho util: defeito reproduzivel em vez de
ruido. Suite que passa em metade das corridas nao reprova mudanca nenhuma.

O custo e wall-clock. O beneficio e um verde que significa alguma coisa.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

* fix(auth): sem IP identificavel, o teto por IP virava DoS contra a instalacao

lib/auth/rate-limit.ts resolvia o IP com fallback para a string "sem-ip", e o
kit self-host expoe o app DIRETO, sem proxy (docker-compose.prod.yml). Ou seja,
x-forwarded-for nao existe em nenhuma instalacao padrao e o balde global era o
caminho NORMAL: com ip:60, qualquer anonimo derrubava o login da empresa
inteira em 60 requisicoes, e o atacante ainda dividia o balde com as vitimas.

O teto por IP existe para ISOLAR uma origem. Sem origem, ele nao isola nada.
Agora clientIp() tenta x-forwarded-for, depois x-real-ip, e devolve null quando
nao sabe — null, nao sentinela, para "nao sei de onde veio" ser inexprimivel
como origem. Sem IP, o teto por IP nao entra; o teto por CONTA, que e o que
barra forca bruta de senha, continua integral.

3 casos novos, incluindo o contrapeso "sem IP o teto por conta CONTINUA
valendo". Sabotagem: voltar "sem-ip" derruba um; tirar x-real-ip derruba outro.

Mais dois consertos do e2e, que passou de 15 falhas para 2:

- workers: 1 nos dois configs. fullyParallel:false so serializa DENTRO do
  arquivo; entre arquivos os specs concorriam sobre a mesma org e o mesmo
  banco. Os mesmos specs passavam isolados em 18s. Ficou ate mais rapido.

- vps-webhook-outbound-ssrf esperava o texto "Ativa" na tela, e RulesTab faz
  setQueryData ANTES do mutate — UI otimista. O lead era disparado com
  is_active ainda false no banco, o handler nao casava a regra e marcava o
  evento done SEM run e SEM erro. Era esse o flake que fazia o e2e da main
  alternar verde/vermelho. Agora espera a resposta do PATCH.

Gate do CI: 20/20 em tres corridas seguidas.

typecheck 0 · lint 164 (0 nos arquivos tocados) · unit 1686/1686

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01671t6LhFTTonLgwL47wBy3

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:19:57 -03:00
..
2026-07-31 12:19:57 -03:00