mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
* 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 Em0ea9f4b(base desta branch) nenhum workflow rodava test:db e 56 arquivos de invariante ficavam fora do gate. Outra frente consertou emce93ab0+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 em93a3a91, 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: commitei8ec69d8com 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 em206797e8, 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>