Files
DeskcommCRM/tests/sonda-linha-envenenada.ts
Rafael MelgaçoandClaude Opus 5 9249e6f219 feat(agente): os três papéis — Conversador, Operador e Segurança (vazamento 30% → 10%) (#181)
* docs(agente): a spec dos tres papeis, e a terceira porta que a doutrina nao previa

A doutrina FALAR/OPERAR ja existia e o passo 1 dela ja estava fechado com
numero (30% de vazamento com prompt de operador, 0% com prompt de atendimento,
18 turnos, gate desarmado). O que faltava era a spec que rege o resto.

Fecha as sete decisoes de produto. As tres que mudam o desenho: uma unidade com
tres papeis (nao tres agentes), o Operador roda DEPOIS do envio (o lead nunca
espera o CRM), e desligar o Operador NAO desliga o registro — a declaracao do
Conversador ja e linguagem de negocio e vira estado por codigo, sem LLM.

Registra a TERCEIRA porta de vazamento, que a doutrina subestimava: o vazamento
medido `unsafe_url:https_required` nao veio da description nem do name da tool,
veio do DADO que ela devolveu. Esconder a ferramenta nao fecha essa porta —
exige camada de projecao propria. Na mesma resposta chegaram UUIDs crus de
usuario a tela do cliente, que o detector nao pega por desenho.

Registra tambem que o botao Testar da tela nao exercita guardrail nenhum:
/versions/[vid]/test chama runAgent do runtime deprecated, que nao importa
runBeforeSend. O self-hoster testa, ve limpo, publica, e producao diverge.

Nao muda comportamento — tres documentos.

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

* feat(agente): a declaracao do turno — a fronteira FALAR/OPERAR vira contrato

Passo 2 da spec 16. O checkpoint ja era a metade de um contrato entre quem fala
e quem opera; faltava o campo que diz o QUE ACONTECEU no turno, e faltava
declara-lo como fronteira em vez de subproduto.

A declaracao viaja na chamada de FECHAMENTO que ja existe, nao numa tool. O
motivo e o que este arquivo ja escrevia sobre o checkpoint: uma tool dependeria
de o modelo lembrar de chama-la, e o turno em que ele esquecesse seria um lead
parado em silencio. Custo adicional zero — e a mesma chamada de modelo.

AUSENTE NAO E VAZIA, e o desenho inteiro gira nisso. `declaracao: undefined`
(coluna NULL) = o modelo nao declarou; `{nada_a_declarar: true}` = ele avaliou e
nao havia nada. Um `.default({})` no Zod, ou um `not null default` na coluna,
colapsaria os dois e faria "esqueceu" parecer "nao havia nada" — escondendo o
esquecimento exatamente onde o invariante 4 do sistema vivo manda mostra-lo.
LeadCheckpointRow faz Omit e redeclara o campo porque as duas pontas
representam o "nao sei" diferente: o modelo omite, o Postgres guarda null.

A declaracao ja NASCE com leitor, senao seria evento sem consumer:
buildHandoffSummary passa a entregar ao humano o que a pessoa quer e o que foi
prometido a ela, antes de compromissos e objecoes — e o prazo vai colado na
promessa, porque quem assume precisa do "ate quando" na mesma linha. Um
artefato, dois leitores (invariante 2).

O texto que o Conversador le nao carrega vocabulario de sistema, e ha teste que
reprova se carregar — e o defeito de 30% que a spec existe para matar.

Schema em tripla: migration 0110 + apendice idempotente no baseline + MANIFEST.

Evidencia observada: 16 testes novos; suite 2603 passed (270 arquivos);
typecheck limpo; lint 0 erros e 189 warnings — o MESMO numero medido na main
com as mudancas em stash, entao nenhum warning novo.

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

* feat(agente): a projecao — fecha a terceira porta, a que a doutrina subestimava

Passo 3 da spec 16. As duas primeiras portas de vazamento (nome e descricao da
ferramenta) se fecham nao mostrando a ferramenta. A terceira nao: e o DADO que
ela devolve, e foi medida em turno real — "deram erro na acao de webhook:
unsafe_url:https_required", copiado do resultado para a tela do cliente.

ALLOWLIST, nunca denylist: campo novo no contexto nao chega ao cliente por
omissao. A linha e identificador do SISTEMA sai (lead_id, conversation_id,
media_storage_path), dado da PESSOA fica (nome, telefone, e-mail) — confundir as
duas produz um agente que nao sabe com quem fala.

O interruptor se justifica sozinho: a projecao arma quando NENHUMA ferramenta de
catalogo entrou no turno, porque e exatamente ai que os ids nao tem uso (as MCP
os recebem como argumento, as nativas resolvem pelo closure). Liga-la sempre
quebraria o agente que opera; por env seria mais um botao para errar. No passo 6,
quando o Conversador perder as ferramentas de escrita, ela passa a valer sozinha.

ACHADO NAO PREVISTO, corrigido aqui: search_knowledge devolvia chunk_id e
knowledge_source_id CRUS ao modelo, dois UUIDs por resultado, em toda busca com
RAG — sem uso nenhum do lado dele, ja que as citacoes sao montadas pelo codigo.
Era fonte silenciosa do UUID cru que a medicao viu chegar na tela do cliente.

Fecha tambem a releitura: sem isso a projecao seria decorativa, bastava o modelo
chamar get_lead_context para receber tudo cru de volta. E o cabecalho do bloco de
estado deixa de imprimir o nome da tabela (lead_state).

UM BUG QUE QUASE ENTROU, registrado porque o teste dele fica: eu aplicava a
traducao de erro ao CORPO das mensagens. "Meu site tem um webhook quebrado" —
frase legitima de quem vende integracao — viraria "nao consegui concluir essa
verificacao", e o agente responderia a uma pergunta que ninguem fez.

Os testes medem contra os turnos REAIS de gpt-5.6-terra versionados em evidence/,
com controle positivo em cada caso e falha declarada se a evidencia sumir.
Fixture inventada mede a imaginacao de quem a escreveu, que e justamente a que ja
falhou — senao o defeito nao teria acontecido.

`projeta` ficou OPCIONAL no tipo de buildOpening porque tests/invariants/** e
congelado por hook: torna-lo obrigatorio forcaria editar um invariante existente,
que a catraca proibe com razao. Custo registrado no handoff.

Evidencia observada: 18 testes novos; suite 2621 passed (271 arquivos);
test:db verde (68 arquivos, 462 passed, 1 skipped); typecheck limpo; lint 0 erros.

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

* fix(projecao): a traducao de erro pisava no conteudo do RAG do tenant

Bug MEU, introduzido no commit anterior e pego pela sabotagem — nao por leitura.

`traduzirErroCru` era aplicada a TODA string do retorno de ferramenta. O detector
de vazamento considera "webhook" vazamento (com razao, na SAIDA), e a base de
conhecimento de qualquer empresa que venda integracao fala webhook. O `content`
do search_knowledge viraria "nao consegui concluir essa verificacao" linha apos
linha: a projecao destruiria o RAG do tenant para proteger contra um vazamento
que o gate de saida ja cobre.

E a MESMA falha que a de reescrever a fala do cliente, corrigida horas antes,
numa irma que nao se parece por fora: as duas nascem de aplicar num texto de
TERCEIROS um filtro desenhado para texto do SISTEMA. Procurei a classe depois de
pegar a primeira instancia e nao achei esta — ela so apareceu quando a sabotagem
mostrou que o teste passava pelo mecanismo errado.

Agora a traducao so alcanca valor sob chave de erro (lista fechada e curta:
erro/error/message/code/reason/detail e os equivalentes em pt). Dois testes
novos: o conteudo do RAG sobrevive intacto falando "webhook", e o campo de erro
continua traduzido — a restricao nao desarmou a defesa.

COMO O TESTE ANTERIOR PASSAVA SEM MEDIR: o detector pega UUID (categoria
erro_cru), entao os casos de evidencia passavam pela TRADUCAO mesmo com a remocao
de chave desligada — dois mecanismos redundantes, e eu atribuia o resultado ao
errado. A sabotagem S2 derrubava 1 caso de 3; era o sinal.

Evidencia observada: 20 testes; typecheck limpo.

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

* docs(handoff): o bug do RAG e a sabotagem que o pegou

Registra no handoff vivo o defeito que entrei e corrigi (traducao pisando no
conteudo da base de conhecimento), COMO o teste passava sem medir (dois
mecanismos redundantes, credito atribuido ao errado), e o sinal que denunciou
— S2 derrubando 1 caso de 3, quando devia derrubar os 3.

Depois da correcao: S2 derruba 3 de 3. Oito sabotagens, oito reprovacoes.

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

* feat(agente): nasce o OPERADOR — disparo imposto pelo runtime, nunca pelo modelo

Passo 4 da spec 16. O papel que mexe no sistema e nunca fala com o lead: ele nao
tem send_message no toolset. A separacao e por AUSENCIA, a unica forma que nao
depende de o modelo obedecer.

O DISPARO E DO RUNTIME. O job `operator_turn` e enfileirado no fim do turno,
logo apos o checkpoint existir, com sourceEventId = job do Conversador (retry da
fila nao gera um segundo Operador). Se o Conversador o "chamasse", devolveria o
problema inteiro: voltaria a depender de o modelo lembrar, e o turno em que ele
nao achasse necessario seria um lead parado no funil, em silencio. Depois do
checkpoint porque a declaracao E o insumo — antes, ele leria o turno anterior.

O CURTO-CIRCUITO E ONDE O PASSO 2 SE PAGA. `nada_a_declarar: true` => nao chama
modelo (quem avaliou estava la, com todo o contexto). Declaracao AUSENTE => roda,
porque ausente significa que NINGUEM avaliou, e e ai que promessa fica orfa. Os
dois estados que o passo 2 se recusou a colapsar decidem, aqui, se a chave do
self-hoster e gasta. Ha teste afirmando que eles levam a decisoes OPOSTAS.

`operator_enabled` nasce false: migration nao liga sozinha um papel que gasta
chamada de modelo por turno. `operator_model` null = herda o do Conversador. O
loader le `?? false` para o clone que ainda nao aplicou a 0111 — schema velho
desliga o papel, nunca gasta modelo por engano.

Promessa declarada e nao cumprida vira `promise_unfulfilled` na Central, com copy
que diz o que o CLIENTE espera, nao o que o sistema deixou de gravar — do lado de
la existe uma pessoa que ouviu um compromisso.

DOIS ERROS MEUS QUE AS CATRACAS DO REPO PEGARAM, nao eu:
  - criei um SEGUNDO bloco de `job_queue_kind_check` no baseline quando ja existe
    um. Isso quebra o update.sh de todo clone com vocabulario posterior. Eu tinha
    aplicado essa licao corretamente ao agent_inbox_items minutos antes e a irma
    passou batido — o padrao pego numa ocorrencia da alibi as outras. Consolidado
    no bloco unico (tests/unit/baseline-constraint-reconstruida.test.ts);
  - o kind novo divergia entre banco e TypeScript
    (tests/invariants/vocabulario-banco-x-typescript.test.ts).

Evidencia observada: 7 testes de decisao + 5 invariantes de schema; suite 2630
passed (272 arquivos); test:db verde (69 arquivos, 467 passed); typecheck limpo;
lint 0 erros / 189 warnings (mesmo numero da main).

Schema em tripla: migration 0111 + apendice no baseline + MANIFEST.

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

* docs(handoff): passo 4 registrado, com as duas catracas que me pegaram

Registra o que o passo 4 entregou e o que NAO entregou (o Operador tem canal,
ainda nao tem mao), a sabotagem com contagem prevista, e os dois erros meus que
foram pegos por catraca do repo e nao por leitura minha — porque a saida limpa
sem essa nota creditaria ao rigor o que foi merito do mecanismo.

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

* feat(agente): o Operador ganha MAO — ferramentas proprias, e nenhuma delas fala

Fatia 1 do passo 5. O papel deixa de so registrar promessa e passa a operar: le
`operator_tool_ids`, monta o toolset pela ponte MCP e roda um turno de modelo com
briefing proprio e atribuicao de custo propria (purpose 'operator_turn' — sem
isso o gasto dele entraria como se fosse conversa, e "quanto custa ligar o papel"
nao teria resposta).

COLUNA PROPRIA, nao reuso de tool_ids, por tres razoes que se somam: a tela nao
pode mentir (lista compartilhada faria a secao Operador configurar o que o
Conversador executa); o teto de 20 se resolve por DIVISAO em vez de aumento (hoje
sao ate 32 tools num prompt so, e o e2e capacidades-do-agente esta fora do CI por
causa disso); e o passo 6 vira migration de DADOS, mover ids entre colunas.

SEM CANAL, e isso e estrutural: send_message e tool NATIVA do engine e nao existe
no catalogo, entao nao ha id que a ligue; e crm_send_whatsapp_message esta em
BLOCKED_TOOL_IDS, que agora e exportado para ser aussertivel — garantia que
nenhum teste consegue ler e garantia que ninguem percebe quando some. O system do
papel fala de CRM com todas as letras, e PODE: esse texto nao alcanca cliente. E
a assimetria exata da spec — no Conversador o mesmo vocabulario e o defeito de
30% medido.

Sem ferramenta configurada ele nao chama modelo: descobrir que nao tem mao nao
vale a chave do self-hoster. Herdar as do Conversador daria 20 capacidades a quem
nao escolheu nenhuma, entao o default e vazio.

TERCEIRA CATRACA DO REPO A ME PEGAR: VERSION_COLUMNS esta copiado em 6 arquivos e
eu atualizei 1. O proprio teste conta que `cases_enabled` fez isso (entrou em 2
de 7) e o sintoma nao e typecheck nem teste — e um campo que "se desmarca
sozinho" na tela depois do refresh, e o save seguinte grava por cima. Repeti o
padrao que o comentario descrevia.

Evidencia observada: 8 testes novos; suite unit 1459 passed (154 arquivos);
test:db verde (69 arquivos, 467 passed); typecheck limpo.

Schema em tripla: migration 0112 + apendice no baseline + MANIFEST.

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

* feat(ui): a tela ganha os PAPEIS — e diz a consequencia, nao o nome da feature

Fatia 2 do passo 5. A configuracao do agente deixa de ser uma lista unica e passa
a ter navegacao por papel: "Conversa com o cliente" e "Organiza o sistema". Os
rotulos dizem o que cada papel FAZ — Conversador/Operador e o nosso vocabulario
interno; quem configura pensa em "quem fala com meu cliente" e "quem organiza
minha casa".

SECOES, nao abas com save proprio: o rascunho e um so. Duas telas com dois saves
fariam o usuario publicar metade da configuracao e criariam dois caminhos para o
mesmo ai_agent_versions.

A REGUA DA DISCIPLINA DE INFORMACAO e dizer a CONSEQUENCIA. Com o papel
desligado, a tela explica o que CONTINUA acontecendo (o basico e registrado
sozinho — decisao da spec 16 §2.1) e o que PARA (decidir sobre a operacao). Sem a
primeira frase o usuario conclui que desligar deixa o sistema cego e liga por
medo, nao por escolha. Ha teste exigindo as duas.

Papel desligado NAO mostra a configuracao dele: convidar alguem a configurar o
que nao vai rodar produz a conclusao de que o produto quebrou.

O modelo herdado tem NOME na tela ("A mesma que conversa"), em vez de aparecer
como pendencia — e ganhou o caminho de VOLTA: um Select nao oferece "nenhum" como
item, entao sem o botao a escolha seria de mao unica e o usuario nao teria como
saber por que.

QUARTA CATRACA a me pegar nesta sessao: VERSION_COLUMNS em 6 arquivos, eu
atualizei 1 (corrigido na fatia anterior). Ha um teste novo que varre a tela
inteira atras do nosso vocabulario (MCP, tool_ids, operator_, job, payload).

Evidencia observada: 8 testes de componente; suite unit 1459 passed antes deste
arquivo; typecheck limpo; lint 0 erros.

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

* docs(handoff): passo 5 parcial, e o bloqueio que impediu a prova de tela

Registra as duas fatias entregues (mao do Operador + tela dos papeis) e o motivo
de a prova de jornada NAO ter sido feita: .env.local aponta para a nuvem, e o
playwright sobe o app com next start, que carrega .env.local. Rodar e2e nesta
maquina hoje cria agentes e conversas de teste no banco de PRODUCAO.

Nao e especifico deste epico — vale para qualquer sessao que rode e2e aqui agora.

Fica declarado que o que esta provado da tela e comportamento de componente, nao
jornada de usuario. Sao coisas diferentes.

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

* fix(e2e): a suite escrevia no banco de PRODUCAO — tres camadas de conserto

Medido em 2026-08-06. `.env.local` de um checkout de trabalho aponta para a
nuvem, e o playwright sobe o app com `next start`, que o carrega. A suite criava
organizacoes, usuarios e agentes de teste no banco real, passando verde.

A org `e2e-test-org` esta na producao deste projeto desde 2026-04-29. Ela nao
chegou la por acidente de uma sessao: chegou porque esse era o comportamento
normal da suite.

ISOLAR O webServer NAO BASTAVA, e essa foi a descoberta. Os scripts de seed leem
`.env.local` DIRETO DO DISCO, ignorando process.env — entao o env injetado no
servidor nunca os alcancava, e as specs chamam esses seeds sozinhas no meio do
teste. O sintoma que denunciou: o factor TOTP em `.e2e-creds.json` nao existia no
banco local, porque tinha sido criado na nuvem. 93 arquivos tem esse padrao.

As tres camadas:
  1. `.env.e2e` (gerado por `pnpm e2e:env`, nao versionado) + injecao explicita
     no webServer, com guard que RECUSA rodar se o arquivo faltar — cair no
     .env.local em silencio e o modo de falha caro;
  2. `pnpm e2e:build`, porque as tres NEXT_PUBLIC_* sao embutidas no BUNDLE:
     buildar com o env errado e trocar so no start deixaria a URL de producao
     dentro do JavaScript do browser. `node --env-file` nao serve aqui — o next
     build cria Workers e o Node recusa propagar a flag
     (ERR_WORKER_INVALID_EXEC_ARGV). O script prova as DUAS direcoes: host de
     producao ausente do bundle E host local presente (sem o controle positivo,
     "nao achei producao" pode ser um grep que nao acha nada);
  3. `scripts/lib/env-de-teste.ts` — process.env VENCE o arquivo, e todo seed
     ANUNCIA o destino ("escrevendo em LOCAL" / "⚠️ REMOTO"). Um seed que escreve
     em producao acha os mesmos dados de teste de sempre e termina com "✅ Seed
     completo": sem a linha impressa, nada distingue os dois casos.

`seed-e2e-credentials.ts` migrado (e o que a suite chama sempre). Os outros ~14
seeds e as ~78 sondas ficam declarados como divida no handoff.

GANHO ESTRUTURAL do worktree dedicado: aqui nao existe `.env.local`, entao um
script que o leia do disco falha ALTO (ENOENT) em vez de escrever na nuvem. O
isolamento deixa de depender de disciplina.

Evidencia observada: spec agente-novo-e-uso 5/5 passed contra o banco LOCAL
(era 0/5 antes, por MFA — o factor do banco nao batia com o do arquivo);
suite unit 1467 passed (155 arquivos); typecheck limpo; guard do playwright
recusa sem .env.e2e (verificado).

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

* docs(handoff): o worktree dedicado, e as tres camadas do isolamento do e2e

Registra o achado que destravou a prova de tela e que vale para o repo inteiro:
a suite escrevia em producao, e isolar so o webServer nao bastava porque os
seeds leem .env.local do disco.

Registra tambem o ganho estrutural do worktree (sem .env.local, script que o leia
falha alto em vez de escrever na nuvem) e as tres dividas que sobraram: os ~92
scripts nao migrados, o spec da aba nova, e a decisao sobre as fixtures que ja
estao na producao.

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

* fix(e2e): os 16 seeds param de ler .env.local do disco, e um gate congela isso

Completa o conserto de 783ee85b. Todos os `scripts/seed-e2e-*.ts` passam a usar
`credenciaisSupabaseDeTeste()`: process.env VENCE o arquivo, e cada um ANUNCIA
contra qual banco vai escrever.

Dois ja estavam com a precedencia certa e so ganharam o anuncio — `capacidades`
lia o arquivo como fallback e `invite` usava process.env puro. O `capacidades`
tambem estourava (ENOENT) num worktree sem .env.local, que e justamente onde a
ausencia do arquivo E a protecao; o helper trata os dois casos.

O GATE que impede a volta do padrao: tests/unit/seed-nao-le-env-local-do-disco.
Sem ele o 17o seed nasce copiando o mais proximo, e escrever em producao nao e
reparavel por code review depois do fato. Cobre `scripts/seed-e2e-*` — o que a
suite roda SOZINHA; as ~78 sondas que alguem dispara a mao ficam declaradas no
handoff, nao escondidas.

A PRIMEIRA VERSAO DA REGEX DO GATE NAO MEDIA NADA. Era
`/readFileSync\s*\(\s*(?:path\.join\([^)]*)?...\.env\.local/` e o `[^)]*` parava
no `)` de `process.cwd()`, no meio de `path.join(process.cwd(), ".env.local")` —
a alternativa nunca casava. Descoberto por SABOTAGEM: reintroduzir a leitura num
seed nao vermelhou nada. A versao atual nao modela a sintaxe da chamada, procura
o literal — regex que entende estrutura erra calada.

Evidencia observada: os 16 seeds EXECUTADOS de verdade, 16/16 anunciando
"escrevendo em LOCAL" (typecheck sozinho nao pega `env is not defined`, ja
aconteceu nesta serie); gate 33 passed; typecheck limpo.

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

* docs(handoff): os 16 seeds migrados, e a regex do gate que nao media nada

Registra a migracao completa dos seeds e o gate que a congela — inclusive o fato
de a primeira regex do gate nao pegar o padrao real, descoberto por sabotagem e
nao por leitura. Atualiza a divida remanescente: sao ~78 sondas, disparadas a
mao, nao mais 14 seeds automaticos.

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

* test(e2e): a aba do Operador provada pela TELA, com o refresh que pega campo perdido

O passo 5 tinha teste de componente, que mede o componente e nao o produto: entre
a escolha do usuario e o banco existem o formulario, a server action, o Zod, o
VERSION_COLUMNS de seis arquivos e o SELECT que rele. Cada um pode perder um
campo sem quebrar teste de unidade nenhum.

O CASO CENTRAL e salvar → RECARREGAR → conferir, e nao e zelo generico: e o
sintoma exato que agent-version-columns-drift descreve — "um campo que se
desmarca sozinho depois do refresh, e o save seguinte grava o valor errado por
cima". Aconteceu com cases_enabled (2 de 7 arquivos) e eu repeti com operator_*
(1 de 6) nesta serie. Este e o teste que teria pego.

DOIS ACHADOS AO ESCREVER, os dois medidos e nao supostos:

1. `default_agent_id` do seed de credenciais e um **rag_bot**, e a tela de papeis
   e do mcp_agent — apontar para ele fazia o spec falhar como se a aba nao
   existisse. O spec agora semeia a PROPRIA precondicao no beforeAll em vez de
   depender de um mcp_agent que outra spec deixou (depender da ordem alfabetica
   dos arquivos ja mordeu este repo).

2. Um login por teste custava 7 logins para exercitar uma aba, e o produto tem
   teto de 60 por IP a cada 5 minutos — o MESMO que protege a conta de um
   cliente real. A bateria estourava no 3o caso e a falha aparecia como "campo
   de MFA nao apareceu", parecendo defeito de produto onde havia limite de
   ambiente. Agora e UM login numa pagina compartilhada, com mode serial porque
   os casos passam a compartilhar navegacao.

Evidencia observada: 7/7 passed em 1.8min contra o Supabase LOCAL (era 6/7 em
7.5min); typecheck limpo; lint 0 erros.

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

* docs(handoff): a prova pela tela, e a sabotagem que mostrou o culpado

Registra o spec e2e da aba do Operador (7/7 em 1.8min) e a sabotagem que o
valida: removi operator_enabled do SELECT de UM dos seis arquivos — o defeito
exato que ja aconteceu com cases_enabled — e o spec reprovou apontando o culpado
na propria mensagem.

Registra os dois achados de escrita: o default_agent_id do seed e um rag_bot (a
tela de papeis e do mcp_agent), e um login por teste estourava o teto do proprio
produto, fazendo limite de ambiente parecer defeito.

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

* fix(ui): o botao Testar deixa de mostrar resposta que nenhum gate examinou

Fatia 3 do passo 5, e o ultimo item aberto dele.

O DEFEITO, medido em 2026-08-05 com controle positivo: a rota /versions/[vid]/test
chama runAgent de lib/ai/runtime/agent.ts, marcado @deprecated, que nao importa
runBeforeSend. Nenhum literal da cadeia (internal_vocabulary,
case_promise_without_case, messaging_window_closed) aparece no build do app Next
— ela vive no processo do worker. O self-hoster testava, via limpo, publicava, e
producao divergia. Falha-em-verde no caminho de primeira impressao, que num
produto instalado pela propria pessoa ninguem descobre.

O CONSERTO NAO FINGE COBERTURA. Seis dos dez gates dependem de estado que so
existe no turno real — contadores de pacing, janela de copias, carimbo da ultima
inbound, ledger de envios, base legal. Fabricar esse estado daria um veredito
INVENTADO, que e pior que veredito nenhum porque tem aparencia de prova. Entao a
avaliacao cobre o que e decidivel so com o texto (o gate de vocabulario interno —
justamente o que a medicao elegeu como mais caro: 30% dos turnos com prompt de
operador vazavam) e DECLARA os outros nove na tela, com o motivo de cada um.

Mostrar "nao verificado" em voz alta e o conserto. Silencio que parece aprovacao
era o defeito.

Os motivos sao escritos para o DONO DO NEGOCIO, nao para nos: "depende de quando
o contato falou com voce pela ultima vez", nao "depende do send_ledger". Ha teste
varrendo jargao nessa lista — repetir na tela de configuracao o vocabulario que a
spec 16 existe para matar seria ironico.

A lista de nao-avaliados e AMARRADA a BEFORE_SEND_GATES: acrescentar um gate a
cadeia vermelha o teste e forca decidir de que lado ele cai. O caminho do stub
tambem avalia — um caminho sem avaliacao voltaria a ser o verde que nao olhou
para nada, so que mais dificil de notar.

Evidencia observada: 6 testes novos, dois deles com os textos REAIS medidos (o
que vazou e o que o modelo reescreveu depois do veto); suite 2685 passed (276
arquivos); typecheck limpo; lint 0 erros.

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

* docs(handoff): passo 5 fechado — o Testar declara o que nao consegue verificar

Registra o ultimo item do passo 5 e a decisao que o define: nao fingir cobertura.
Seis dos dez gates dependem do turno real, e fabricar esse estado daria veredito
inventado — entao a tela mostra o que foi verificado E o que nao foi, com motivo
escrito para o dono do negocio.

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

* feat(agente): a CURA — a ferramenta sai do Conversador quando o Operador a assume

Passo 6 da spec 16, o ultimo. O gate de vazamento e REDE: barra na saida e ensina
o modelo. A cura e o Conversador nunca ter visto o vocabulario — ele nao repete o
nome de uma ferramenta que nao esta no contexto dele. Foi pelo NOME que o
vazamento voltou depois de a descricao ser limpa (crm_list_webhook_sources,
medido), e nome de tool e contrato de wire que nao se renomeia.

A REGRA QUE IMPEDE O BURACO: a capacidade so muda de dono quando o novo dono
EXISTE. A nativa sai do Conversador apenas se o Operador estiver ligado E tiver,
marcado na tela, um equivalente que faca a mesma coisa. Ligar o papel e esquecer
de marcar crm_move_lead_stage tiraria o avanco do funil de um lado sem dar a
ninguem — o funil pararia de andar em silencio, que e o modo de falha que este
epico existe para combater. Tirar de um lado sem garantir o outro nao separa
papeis, perde capacidade.

E A INSTRUCAO SAI DO PROMPT JUNTO. Remover a ferramenta e manter a linha
"Marque-o com update_lead_state" seria o pior dos dois mundos: o modelo tentaria
chamar o que nao existe E o nome continuaria no contexto — ou seja, a cura nao
teria acontecido. Ha teste separado so para isso.

O QUE NAO SAI, MEDIDO CONTRA O CATALOGO REAL: save_lead_note, open_human_case e
provide_case_update nao tem equivalente MCP — entrega-las deixaria a capacidade
orfa. Ficam, com a divida declarada. request_human_handoff fica por outra razao:
ela EXISTE no catalogo mas esta em BLOCKED_TOOL_IDS (a variante do catalogo nao
silencia o harness), e passar a conversa para uma pessoa e decisao sobre a
CONVERSA, com efeito imediato — adia-la ao turno seguinte deixaria o assistente
respondendo depois de o lead pedir um humano, num caminho que a Meta fiscaliza.

A tabela de equivalencia e verificada nos DOIS sentidos: toda nativa listada
existe em AGENT_TOOL_DEFS e todo equivalente existe em TOOL_CATALOG. Um typo em
qualquer lado faria o corte nunca acontecer, silenciosamente.

Zero mudanca para quem nao ligou o papel: operator_enabled nasce false.

Evidencia observada: 16 testes novos; suite 2701 passed (277 arquivos);
typecheck limpo; lint 0 erros.

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

* docs(handoff): passo 6 parcial — o mecanismo esta provado, o efeito nao

Registra o que a cura entregou e, com o mesmo destaque, o que ela ainda NAO
provou: o sinal de sucesso definido pela spec e a taxa de 30% cair, e isso exige
re-rodar a medicao de 18 turnos com o Operador ligado. Nao foi feito.

Esta provado o MECANISMO (a ferramenta e o nome somem do contexto do
Conversador), nao o EFEITO (a taxa cair). Sao coisas diferentes.

Registra tambem as 3 nativas que ficaram por falta de equivalente no catalogo —
divida declarada, nao um corte pela metade escondido.

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

* medicao(passo6): re-medido — o vazamento NAO foi a zero, e agora se sabe por que

O sinal que a spec 16 define para o passo 6 e "o vazamento vai a zero por
AUSENCIA, nao por filtro". Medido: nao vai.

10 cenarios x 3 configuracoes, mesmo modelo da linha de base (gpt-5.6-terra):
  A CONTROLE (base replicada) 10.0% · B passo 6 como entregue 10.0% ·
  C cura completa (operacao sai do Conversador) 10.0%

As tres iguais, e o MESMO cenario vazando. Em C — sem nenhuma ferramenta de
webhook no contexto — o modelo ainda disse "webhook": parte do vocabulario vem
do MODELO, nao do contexto. Ausencia de ferramenta reduz superficie; nao elimina
vazamento. A frase "so se fecha nao mostrando" fecha as portas 1, 2 e 3 — nao
fecha o modelo.

O CONTROLE NAO REPRODUZ OS 30%, e a causa esta identificada: o instrumento nao
executa as ferramentas, entao nao exercita a porta 3 (dado retornado), que era 2
dos 3 vazamentos originais. Ele mede a porta 1/2 e achou 1 de 10 — exatamente o
unico vazamento por NOME da linha de base. Calibracao parcial e explicada.

O que a medicao CONFIRMA: o prompt e a variavel dominante. O valor do epico nao e
"zero por ausencia" — e o dono do negocio deixar de precisar escrever "atenda E
organize" no prompt do Conversador, porque agora existe um papel para isso. Foi
essa troca que a linha de base mediu levando 30% a 0%.

O INSTRUMENTO REPORTOU 0,0% NAS TRES CONFIGURACOES NA PRIMEIRA EXECUCAO. Parecia
o passo 6 funcionando perfeitamente. As 30 chamadas tinham voltado HTTP 400
(gpt-5.6-terra recusa function tools com reasoning em /chat/completions), os
textos vieram vazios, e o detector nao acha nada em texto vazio. O erro estava
capturado por linha; o resumo so imprimia a taxa. Corrigido: recusa calcular taxa
quando ha turnos que nao rodaram, e sai com codigo 1.

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

* medicao(passo6): a taxa TOTAL, com ferramentas executadas — 30% cai para 10%

A medicao que faltava, agora com o controle CALIBRADO: a configuracao A
reproduziu 30,0% com os MESMOS tres cenarios da linha de base, com as
ferramentas rodando de verdade contra o banco local. Zero respostas vazias.

  A CONTROLE (21 capacidades) ......... 3/10 = 30,0%
  C sem as de OPERACAO (14) ........... 1/10 = 10,0%

Tirar as sete ferramentas de operacao eliminou DOIS dos tres vazamentos —
exatamente os que vinham do DADO retornado (unsafe_url:https_required e
admin/manager+UUIDs). Sobrou o cenario 3: sem nenhuma ferramenta de webhook no
contexto, o modelo AINDA disse "webhook". O termo veio dele, nao do que lhe foi
mostrado. "So se fecha nao mostrando" fecha as portas 1, 2 e 3 — nao fecha o
modelo.

CONSEQUENCIA PARA O PASSO 6 COMO ESTA: ele entrega as NATIVAS com equivalente, e
nenhum dos tres vazamentos veio delas. Nao move a taxa. O que move e entregar ao
Operador tambem as `crm_list_*` de OPERACAO — que nao servem para responder
pergunta de paciente, servem para o dono cuidar da casa, que e a definicao do
papel. Recomendacao medida, com numero: 30% -> 10%.

DOIS INSTRUMENTOS QUEBRADOS NO CAMINHO. O primeiro imprimiu 0,0% nas tres
configuracoes (30 chamadas com HTTP 400, textos vazios, detector nao acha nada em
vazio). O segundo coletou 10 turnos vazios e o spec passou — tres causas em
cadeia, cada uma escondida pela anterior: credencial cifrada com outra chave,
AI_CRED_AES_KEY de 21 bytes no .env.e2e (placeholder do CI, que nao exercita
cifra), e o spec nunca validando a credencial que criava (funcionava so
reaproveitando uma validada a mao). Os tres consertados, o gerador do .env.e2e
passa a emitir chaves de 32 bytes de verdade.

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

* feat(agente): as capacidades de OPERACAO saem do Conversador — 30,0% cai para 10,0%

Aplica a recomendacao que a medicao anterior produziu, e re-mede com o codigo no
caminho.

  A CONTROLE (todas as capacidades) .... 3/10 = 30,0%
  C codigo aplicado .................... 1/10 = 10,0%

Ferramentas EXECUTADAS contra dados reais, zero respostas vazias nas duas
corridas, controle calibrado contra a linha de base (mesmos tres cenarios).

O MECANISMO E DIFERENTE do resto do modulo: aqui nao ha mapa de equivalencia, e a
MESMA ferramenta mudando de dono. A condicao continua sendo a mesma — so sai se o
Operador estiver ligado e a tiver marcada, para a capacidade nunca ficar orfa.

Os dois vazamentos que sumiram vinham do DADO que essas ferramentas devolvem
(unsafe_url:https_required e admin/manager+UUIDs), nao do nome nem da descricao.
E a porta 3, a que "nao mostrar a ferramenta" so fecha tirando a ferramenta.

crm_list_pipelines/stages/tags FICAM: saber em que etapa o lead esta e contexto
de CONVERSA. Tira-las trocaria um vazamento por um atendimento pior.

O LACO ENTRE CODIGO E MEDICAO ESTA FECHADO: o spec de coleta pergunta ao codigo
quais capacidades sobram, em vez de repetir a lista. Uma copia paralela mediria a
copia, e poderia divergir do que o turno monta sem nada vermelhar. Ha teste
travando o conjunto que sobra — mudar a lista sem re-medir reprova.

Os 10% que restam sao o residuo do MODELO: sem nenhuma ferramenta de webhook no
contexto, ele ainda diz "webhook". O gate segue cobrindo isso.

Evidencia observada: 21 testes; suite 2706 passed (277 arquivos); typecheck
limpo; lint 0 erros.

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

* docs(handoff): fixtures de e2e removidas da producao, e dois defeitos que a operacao expos

Removidas em 2026-08-06, com backup previo: as orgs `e2e-test-org` e
`e2e-tenant-b` e tudo que pendurava nelas — 5 usuarios @deskcomm.test, 57
contatos, 144 mensagens, 56 leads, 18 agentes, 5 canais. Restaram 3 orgs, todas
legitimas. `onboarding-teste` NAO foi tocada: nao e fixture automatica, pode ser
teste manual do dono.

A operacao expos DOIS DEFEITOS REAIS do produto, os dois da mesma familia
(entidade que nao se consegue apagar):

1. DELETAR UMA ORGANIZACAO FALHA. O cascade apaga os filhos, e o trigger de
   audit de cada um tenta inserir em api_audit_log referenciando a org que ja
   nao existe → violacao de FK. So funciona apagando os filhos ANTES, com o pai
   vivo, e a propria api_audit_log por ultimo.

2. DELETAR UM AGENTE DONO DE LEAD FALHA. O SET NULL em
   crm_leads.owner_agent_id viola crm_leads_owner_kind_coherence, que exige
   owner_agent_id quando owner_kind='ai'. Ou seja: um agente que ja atendeu
   alguem nao pode ser removido.

Nenhum dos dois foi corrigido aqui — a tarefa era limpar, e consertar schema de
producao no mesmo movimento seria escopo que ninguem pediu. Ficam registrados.

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

* fix(e2e): as sondas param de ler .env.local do disco — 108 arquivos, zero restantes

Fecha a divida que sobrou do conserto dos seeds. Todas as sondas, provas e
capturas de scripts/ e tests/ passam a usar `carregarEnvLocal()`.

POR QUE UMA FUNCAO NOVA E NAO `credenciaisSupabaseDeTeste`: essas ~96 sondas nao
leem so as credenciais do Supabase — cada uma pega o que precisa (WAHA_API_KEY,
OPENAI_API_KEY, SUPABASE_DB_URL) de um Record<string,string> que montavam a mao.
`carregarEnvLocal()` devolve o MESMO shape, com duas diferencas que sao o
conserto inteiro: process.env VENCE o arquivo, e a ausencia do arquivo nao e erro
— no worktree dedicado de e2e nao existe .env.local, e e essa ausencia que
protege. O loader antigo estourava com ENOENT ali.

Oito formatos diferentes de loader inline, migrados em quatro passadas com
verificacao a cada uma. Os quatro ultimos casos (funcoes que remontavam o texto
do arquivo para re-parsear) foram substituidos por inteiro, nao remendados.

O GATE FOI ESTENDIDO e ficou mais rigoroso que o meu grep: ele pegou 11 arquivos
que eu tinha dado por migrados (formatos multi-linha que a minha busca nao
casava). Agora sao 188 casos cobrindo scripts/ + tests/ + tests/e2e/. O anuncio
de destino continua exigido so dos SEEDS: sonda e disparada a mao, com a pessoa
lendo a saida.

LINT: subiu para 235 warnings com a migracao — 44 arquivos ficaram com import de
`fs`/`path` orfao depois que o loader saiu. Removidos: 185 agora, contra 183 na
base. Os +2 sao de arquivo novo, nao regressao.

Evidencia observada: gate 188 passed; suite 2861 passed (277 arquivos);
typecheck limpo; 0 arquivos lendo .env.local do disco.

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

* fix(db): duas entidades que nao se conseguia apagar — organizacao e agente

Achados operando: ao remover as fixtures de E2E da producao, os dois erros
apareceram um depois do outro, cada um abortando a transacao inteira. Sao da
mesma familia — uma escrita AUTOMATICA (trigger de audit, SET NULL de FK)
reagindo ao DELETE e violando regra que vale para o estado normal, nao para a
remocao.

DEFEITO 1 · apagar uma ORGANIZACAO falhava. O cascade apaga os filhos e o
trigger de audit de cada um insere em api_audit_log com o organization_id de uma
org que ja nao existe. Agora o audit e pulado no DELETE quando a org sumiu — nao
se perde auditoria, porque essa linha seria apagada pelo cascade em seguida. A
checagem fica SO no ramo DELETE: um `exists` no INSERT/UPDATE cobraria um SELECT
em todo hot path de escrita para cobrir um caso que nao ocorre la.

DEFEITO 2 · apagar um AGENTE que ja atendeu falhava. A FK e ON DELETE SET NULL e
o CHECK exige owner_agent_id quando owner_kind='ai'; o SET NULL zerava um lado e
deixava o outro. Agora um BEFORE DELETE desfaz a atribuicao INTEIRA antes de a FK
agir, e o lead fica sem dono em vez de meio-atribuido. O CHECK NAO foi afrouxado:
tolerar 'ai' sem agente trocaria erro barulhento por dado incoerente em silencio
— ha teste cobrando isso.

TRES CATRACAS DO REPO ME PEGARAM AQUI:
  - a varredura de hardening: minha funcao nova nasceu SECURITY DEFINER
    executavel por `authenticated` e podendo escrever; meu revoke cobria
    `public` e `anon` e faltou a terceira origem. Revogada — seguro porque o
    unico call site e o trigger, e o Postgres nao exige EXECUTE do usuario para
    invocar funcao de trigger;
  - o hook de sequencia de migration: 0113 ja existe na main (e em 6 branches).
    Renumerada para 0115, que e o proximo livre em TODAS as branches locais;
  - e um caso MEU passava pelo motivo errado: o teste do CHECK montava o insert
    por subquery e caia em `stage_id` nulo — teria seguido verde se alguem
    afrouxasse o CHECK. Agora usa ids diretos.

Schema em tripla: migration 0115 + apendice no baseline + MANIFEST.
Evidencia observada: test:db verde (70 arquivos, 470 passed); typecheck limpo.

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

* test(db): o caso do defeito 1 usava tabela SEM audit e nao media nada

DESKCOMM_GOV_INVARIANTS_EDIT=1 — e a razao, porque a valvula pede que se cite:
este invariante foi criado por mim no commit ANTERIOR desta mesma branch
(9f98784a), nao e invariante estabelecido do repo. Nao e "invariante incomodo
sendo silenciado": e um teste recem-escrito que a sabotagem provou nao medir
nada. Manda-lo para a inbox em vez de corrigi-lo deixaria um caso verde
permanentemente cego no repo.

DESCOBERTO PELA SABOTAGEM, nao por leitura: desfiz o conserto do audit no
baseline e o test:db seguiu VERDE, 470 passed. O caso usava `contacts`, que NAO
tem o trigger fn_audit_log_row — ele vive so nas seis tabelas de IA (ai_agents,
ai_agent_versions, ai_agent_runs, ai_provider_credentials, ai_routers,
ai_router_members). O caso passava por AUSENCIA de audit, nao por conserto.

Agora usa `ai_agents`, que e de onde o erro veio na producao (a org de fixture
tinha 18 agentes). E ha um controle positivo que reprova se a tabela escolhida
deixar de disparar audit — exatamente o furo que deixou isto passar.

Evidencia observada: com o conserto DESFEITO, 1 failed (era 470 passed antes da
correcao); com o conserto no lugar, test:db verde 471 passed.

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

* fix(seeds): a travessia de credenciais perdia o dbUrl e derrubava o CI

O refactor de `scripts/lib/env-de-teste.ts` está certo no mérito — o motivo
escrito no cabeçalho dele é um defeito sério de verdade: cada seed lia
`.env.local` do disco ignorando `process.env`, e a suíte E2E semeava em PRODUÇÃO
mesmo com o ambiente de teste injetado.

Mas a travessia saiu incompleta. `CredenciaisSupabase` não declarava `dbUrl` e
nenhum dos dois ramos a devolvia, enquanto **13 seeds** já liam
`credenciais.dbUrl` para abrir `pg.Pool`.

POR QUE NENHUM GATE PEGOU: `tsconfig.json` tem `"exclude": ["scripts/**"]`, então
os seeds não são typechecked — ler propriedade que o tipo não declara não custa
nada ao compilador. (O `as Record<string, string>` no objeto `env` de cada seed
não é a causa; eu cheguei a achar que fosse, e é o `exclude` que decide.)

E O SINTOMA ESCOLHIA O AMBIENTE, que é o pior tipo de defeito para achar:

  local → cai no ramo do ARQUIVO, ou o shell já tem a variável → passa
  CI    → o workflow EXPORTA as credenciais, o ramo "ambiente" vence → undefined

Com `connectionString: undefined` o `pg` cai no default do libpq. No CI isso é
`ECONNREFUSED 127.0.0.1:5432` — o Supabase local publica na 54322.

Medido nesta máquina, mesmo comando, com as credenciais exportadas no ambiente
(o que o CI faz):

  antes  → [seed-e2e-escalacao] falhou: database "rafaelmelgaco" does not exist
  depois → [seed-e2e-escalacao] escrevendo em LOCAL: http://127.0.0.1:54321
           (origem: ambiente) … travas ligadas

O erro local difere do erro do CI porque esta máquina tem um Postgres local —
mesma causa (connection string indefinida), superfície diferente.

O ramo "ambiente" cai no `.env.local` antes de desistir: quem exporta URL +
service role não necessariamente exporta a conexão direta. Isso não contradiz a
regra da função — `process.env` continua vencendo quando existe, e há um caso de
teste afirmando exatamente isso.

O teste vive em `tests/` e não em `scripts/` de propósito: importar o helper de
lá é o que o traz para dentro do `tsconfig`, que é a razão de fundo de o defeito
ter passado. Sabotado nos dois ramos: zerar o do ambiente reprova 3 casos, o do
arquivo reprova 1.

typecheck limpo, lint sem erros, 2927 testes unitários verdes.

* fix(e2e): o CI cria o .env.e2e que a suíte passou a exigir

O PR protegeu a suíte contra escrever em produção — `playwright.config.ts`
exige um `.env.e2e` e falha alto quando falta. A proteção está certa; o que
saiu incompleto foi a outra ponta: `.github/workflows/e2e.yml` não foi
tocado. O job seguiu rodando `pnpm build` e chamando a suíte sem gerar nada,
e o e2e morreu em `Falta o .env.e2e` depois de 3m48s.

Três consertos, todos medidos:

1. O workflow gera o `.env.e2e` (`pnpm e2e:env`) depois de o stack local
   subir, e builda com `pnpm e2e:build` — não `pnpm build`. As três
   `NEXT_PUBLIC_*` são embutidas no BUNDLE durante o build: buildar com um
   env e trocar só no `next start` deixaria a URL errada dentro do
   JavaScript do browser. Medido: `==> OK (controle): o host local
   (127.0.0.1:54321) ESTÁ no bundle`.

2. `gerar-env-e2e.sh` usa o `supabase` do PATH quando existe (é o que o CI
   instala via supabase/setup-cli) e cai no `npx` só na máquina do dev. A
   CLI não é dependência deste projeto — insistir no `npx` custaria um
   download do registry em cada uma das três chamadas.

3. O heredoc que escreve o `.env.e2e` não é quotado, e um comentário dentro
   dele tem crases. O shell tentou EXECUTAR o que estava entre elas:

     scripts/gerar-env-e2e.sh: linha 79: credential_decrypt_failed:
     comando não encontrado

   Saía em stderr e o arquivo gerado perdia exatamente o identificador que
   o comentário existe para nomear (`# agente morria em , o que aparecia`).
   Varri o heredoc inteiro: as crases eram a única expansão indesejada, as
   `$VAR` são todas de propósito.

A guarda nova é sobre a ORDEM, não a presença: `tests/unit/
e2e-workflow-honra-o-env.test.ts` casa quem gera o arquivo contra quem o
consome e exige que o índice do primeiro seja menor. Sabotei as quatro
propriedades uma a uma — passo removido, ordem invertida, `pnpm build` de
volta, e a exigência sumindo do playwright.config (guarda de vacuidade) —
e cada uma vermelha o caso certo.

gov:verify: 0 erros de lint, 286 arquivos / 2931 testes (2927 + 4).
e2e:build: exit 0.

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

* fix(e2e): uma fonte de ambiente, em vez de três redações do mesmo segredo

O conserto anterior destravou a suíte (27 specs passaram, contra zero). As 8
que sobraram vermelhas — 6 de system-update no `heartbeat`, o drain do
anti-SSRF e o invite-lifecycle — eram todas 401 em rota interna, e a causa
era a mesma: `INTERNAL_SECRET` estava declarado em TRÊS lugares com valores
diferentes.

  .env.e2e              e2e-placeholder-nao-e-segredo   → o servidor
  env: dos 2 passos     ci-placeholder-nao-e-segredo    → o processo de teste
  .env.local do seed    ci-placeholder-nao-e-segredo    → os seeds

MEDIDO (Playwright 1.5x, webServer que imprime o que recebeu):

  var só no process.env       → CHEGA (mescla, não substitui)
  var nos dois, valores dif.  → vence a do `env:` do config

Ou seja: o servidor pegava a do arquivo, o teste mandava a do passo, e
igualar as strings à mão só adiaria a próxima divergência. O conserto é
fonte única — o workflow publica o `.env.e2e` inteiro no `$GITHUB_ENV` e o
`.env.local` do seed passa a ser `cp .env.e2e`. Zero segredos redigitados no
workflow (era 24 linhas).

De brinde, o processo de teste passa a ver as chaves de CIFRA de verdade (32
bytes, geradas pelo script) em vez do rótulo de 21 caracteres que o bloco
`env:` fixava — o mesmo que o comentário do script já registrava como causa
de `credential_decrypt_failed`.

Dois comentários que a medição desmentiu, corrigidos junto:

- `playwright.config.ts` avisava "NÃO declare `env:` aqui sem espalhar
  `process.env` junto ... declarar substitui o ambiente inteiro" — e a linha
  logo acima declarava `env: envDoE2E()`. O arquivo se contradizia, e a
  afirmação é falsa: o Playwright mescla. Trocado pela tabela medida.
- `gerar-env-e2e.sh` prometia "valores iguais aos do CI" e eles não eram
  iguais. É a frase que fazia a divergência parecer impossível.

A guarda ganhou as duas propriedades novas, sabotadas uma a uma:
redigitar `INTERNAL_SECRET` num passo → vermelho; parar de publicar o
`.env.e2e` (que faria o caso anterior passar por ausência) → vermelho.

gov:verify: 0 erros de lint, 286 arquivos / 2933 testes.

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

---------

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

96 lines
3.8 KiB
TypeScript

/**
* QUAL LINHA envenena o lote? — uma atividade por lead, afastadas no tempo.
*
* O defeito é POR LINHA (medido pelo QAVivo: escritas afastadas mostram que a
* assinatura sobrevive e o que se perde são as linhas que viajam JUNTO com a
* envenenada). Meus testes anteriores davam 2/2 porque eu pegava o lead com
* `limit 1` SEM ORDEM — alvo sorteado, e por sorte um lead saudável.
*
* Aqui cada lead recebe a SUA atividade, com folga entre elas para caírem em
* lotes diferentes. O que não chegar aponta a linha.
*/
import * as fs from "node:fs";
import { createClient } from "@supabase/supabase-js";
import { carregarEnvLocal } from "../scripts/lib/env-de-teste";
const env = carregarEnvLocal();
const creds = JSON.parse(fs.readFileSync(".e2e-creds.json", "utf8"));
const admin = createClient(env.NEXT_PUBLIC_SUPABASE_URL!, env.SUPABASE_SERVICE_ROLE_KEY!, {
auth: { persistSession: false },
});
const PIPES: Record<string, string> = {
"CRM Vivo": "35bf4ac9-c5e0-4f7d-846a-99b1bcc92d69",
Pedidos: "48c02b4a-0ca1-4bca-8ef0-206b6d240d23",
};
async function main(): Promise<void> {
const user = createClient(env.NEXT_PUBLIC_SUPABASE_URL!, env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, {
auth: { persistSession: false },
});
const { data: sessao } = await user.auth.signInWithPassword({
email: creds.users.manager.email,
password: creds.password,
});
await user.realtime.setAuth(sessao!.session!.access_token);
user.realtime.connect();
await new Promise((r) => setTimeout(r, 2500));
await user.realtime.setAuth(sessao!.session!.access_token);
const chegaram = new Set<string>();
const canal = user
.channel("caca-linha")
.on("postgres_changes", { event: "INSERT", schema: "public", table: "crm_lead_activities" }, (p) => {
const id = (p as { new?: { lead_id?: string } }).new?.lead_id;
if (id) chegaram.add(id);
});
await new Promise<void>((res, rej) =>
canal.subscribe((s) => (s === "SUBSCRIBED" ? res() : s === "CHANNEL_ERROR" ? rej(new Error(s)) : undefined)),
);
await new Promise((r) => setTimeout(r, 6000));
// Um lead de Pedidos entre cada dois do CRM Vivo: o controle roda NA MESMA
// grade, não num bloco separado — se ele cair, a rodada não julga nada.
const alvos: Array<{ id: string; rotulo: string; owner: string }> = [];
for (const [nome, pipe] of Object.entries(PIPES)) {
const { data } = await admin
.from("crm_leads")
.select("id, owner_kind, title")
.eq("pipeline_id", pipe)
.order("id");
for (const l of (data ?? []) as Array<{ id: string; owner_kind: string | null; title: string }>) {
alvos.push({ id: l.id, rotulo: nome, owner: l.owner_kind ?? "null" });
}
}
const org = "6e567068-fd1c-4f94-ae1f-40e0334be190";
for (const a of alvos) {
await admin.from("crm_lead_activities").insert({
organization_id: org,
lead_id: a.id,
type: "note",
source_module: "crm",
source_id: a.id,
actor_kind: "system",
reason: "caça à linha envenenada",
});
await new Promise((r) => setTimeout(r, 1200)); // folga para separar lotes
}
await new Promise((r) => setTimeout(r, 8000));
const perdidos = alvos.filter((a) => !chegaram.has(a.id));
console.info(`\nenviados ${alvos.length} · chegaram ${chegaram.size} · PERDIDOS ${perdidos.length}`);
for (const [nome] of Object.entries(PIPES)) {
const doPipe = alvos.filter((a) => a.rotulo === nome);
const p = doPipe.filter((a) => !chegaram.has(a.id));
console.info(` ${nome}: ${doPipe.length - p.length}/${doPipe.length} chegaram`);
}
if (perdidos.length > 0) {
console.info("\nLINHAS QUE NÃO CHEGARAM:");
for (const p of perdidos) console.info(` ${p.id.slice(0, 8)} · ${p.rotulo} · owner_kind=${p.owner}`);
}
await user.removeAllChannels();
process.exit(0);
}
void main();