11 Commits
Author SHA1 Message Date
Elias GervannoandClaude Opus 5.5 4d90532ed7 fix(waha): mensagem que chega com o banco indisponível não se perde mais
A ingestão do webhook WAHA engolia a falha do banco nos quatro pontos em
que a mensagem depende de uma escrita (contato, conversa, mensagem
recebida e mensagem enviada pelo celular): `console.error` + `return`, e
a rota devolvia 200. O WAHA riscava o evento achando que entregou, e a
mensagem do cliente sumia — medido em produção: três perdidas em 14/09
num dia de `statement timeout`, duas na troca de banco de 24/09.

- `lib/waha/falha-transitoria.ts`: a régua. Erro de dado, integridade,
  schema, regra de negócio e pedido malformado são permanentes; o resto
  (timeout, conexão, PostgREST sem banco, 5xx, sem código) é transitório.
  A ingestão passa a lançar a classe certa nos quatro pontos.
- `lib/waha/desfecho-do-webhook.ts`: as duas rotas gravam o desfecho no
  arquivo (`processed` / `error`), que até aqui nascia e morria
  `received`. Falha transitória responde 503 + Retry-After: o WAHA
  reentrega qualquer erro 15x com espera max(2s, Retry-After), e a
  reentrega é segura porque o 23505 vira dedup.
- `app/api/v1/cron/webhook-replay`: relê a cada minuto o arquivo marcado
  `transitoria:`, até 20 tentativas; depois `dead` e um aviso `event_dead`
  na Central com título próprio (`MENSAGEM_QUE_NAO_ENTROU`), que o dreno
  de handlers exclui do seu dedupe. Para na terceira falha seguida da
  rodada para não martelar um banco que ainda está voltando.

É o `process-pending-webhooks` que o PRD 03 e a Spec 03 prometiam; os
dois passam a dizer o nome e a régua reais. Sem migration: `processed`
e `dead` já estavam no CHECK, `event_dead` já existia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:51:43 -03:00
PessoaandClaude Opus 5 73857d4881 docs: a Vercel deixa de aparecer como onde o CRM roda e valida
O projeto do CRM na Vercel foi desvinculado do GitHub por decisao do dono: a
plataforma fica so com a landing page, em outro repositorio. Medido com a mesma
sonda nos dois lados — `gh api repos/.../commits/<sha>/status` devolve `Vercel`
no commit f65d04667 (antes) e vazio em a8c9e1ad3, 7168961ad e no head do #1081
(depois).

Com isso, dezenas de afirmacoes do repositorio publico viraram falsas. As que
falavam no presente sairam:

- mensagem automatica ao contribuinte, molde de PR, CONTRIBUTING e os espelhos
  na skill de contribuir prometiam um check "Vercel" vermelho que nao aparece
  mais. No lugar, a mensagem passa a dizer onde olhar o que trava o merge — os
  checks marcados Required no proprio PR, com a ressalva de que `--required` so
  lista os que ja reportaram;
- runbooks de operacao mandavam abrir o painel da plataforma e redeployar a
  main; passam a descrever o `.env` da instalacao e a recriacao dos conteineres;
- `lib/env.ts` mandava, no boot, ajustar variavel "na Vercel";
- specs e PRDs descreviam topologia, crons e guarda de segredo na plataforma;
- comentarios de codigo ancoravam decisoes vivas em premissa morta.

Registros datados (pesquisa, pitch, epicos, handoffs) nao foram reescritos:
ganharam uma linha dizendo que sao registro da fase hospedada.

O `vercel.ts` fica: ele serve quem hospeda um fork, e o gate
`tests/unit/cron-routes-scheduled.test.ts` continua reprovando divergencia de
rotas entre ele e o crontab do scheduler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:00:03 -03:00
Rafael MelgaçoandClaude Opus 5 d69d708d8d feat: concluir jornadas da comunidade — organizações, atendimento, agenda e autonomia (#613)
* feat: complete community workflows for organizations, agents and scheduling

* docs: cite selected community evidence in the journey map

* test(automation): supply event boundary and agenda fixtures for AI sends

* fix: prevent readonly support from changing channel AI access

* test: verify integrated community journeys and secure fixture randomness

* fix(agenda): ocupação do Google sem catálogo volta a contar, e o gatilho do Meet trava na ordem das irmãs

fn_google_counts_for_conflicts exigia linha em calendar_connection_calendars
para um evento externo contar. Os três leitores (grade, semente da página e o
motor de horários livres) passaram a ler a view que a usa, então conexão sem
catálogo montado perdia a ocupação na tela E deixava de bloquear o horário —
o oposto do que os três leitores faziam antes. Passa a falhar ABERTO: só não
conta quem tem linha dizendo counts_for_conflicts=false.

fn_meet_delivery_enqueue travava job_queue segurando a linha do compromisso
sem o mutex do contato; fn_meet_redact_contact (0229) faz a ordem inversa.
Duas ordens opostas sobre os mesmos recursos = 40P01 sob concorrência.

* fix(contatos): fundir duplicado volta a funcionar no caso ordinário

A guarda `mescla_conversas_colidentes`, introduzida em 0222, abortava a fusão
sempre que os contatos do grupo tivessem mais de uma conversa no mesmo
`channel_session_id` — que é EXATAMENTE como a duplicata de WhatsApp nasce
(dois cadastros, dois números, o mesmo número de atendimento). O caminho
dominante do recurso virava 409.

Medido no Postgres da QA, fixture de dois contatos com uma conversa cada na
mesma sessão:

  antes:  ERROR: mescla_conversas_colidentes
  depois: {"repontado": {"messages.contact_id": 1, "demandas.contact_id": 1},
           "nao_repontado": {"conversations.contact_id": 1}, ...}

A colisão já tinha dono e não é perda de mensagem: `messages.contact_id` não
tem índice único por contato e passa inteira para o vencedor; quem colide é a
conversa, contra `uniq_conversations_1to1_per_contact_session`, e o passo 5 já
cai para repontamento linha a linha, deixa a conversa na lápide e a CONTA em
`nao_repontado` — que a rota devolve e a tela anuncia. É o desfecho que
`tests/e2e/juntar-contatos-duplicados.spec.ts` trava, com número.

O mutex de atendimento que 0222 trouxe (fn_service_lock + `for no key update`)
fica: o problema nunca foi a ordem de trava, foi a recusa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ia): rascunho do assistido volta a respeitar dono humano e silêncio

O drain desliga o gate de elegibilidade antes de enfileirar quando a org tem
agente assistido publicado no canal (`canAssist`, drain.ts:307/315). Isso é
deliberado — o rascunho é o produto do modo assistido — e transfere a checagem
inteira para o turno. Só que o ramo assistido de `createInboundTurnHandler`
devolvia ANTES de `runAgentTurn`, que é onde as duas guardas moram
(isLeadInHandoff e decidirElegibilidadeDaConversa). O gêmeo de :1572 está
depois delas e por isso nunca sofreu.

O gate não é só o pré-go-live: a mesma consulta lê `contacts.force_human`,
`conversations.assignee_kind` (dono humano) e `bot_silenced_until`
(consulta-pg.ts:34-48). Na prática, uma conversa que uma PESSOA assumiu seguia
recebendo rascunho do robô.

As guardas entram DENTRO do ramo assistido, não antes dele: o caminho
automático já as refaz em `runAgentTurn`, e antecipá-las custaria duas queries
por turno sem mudar desfecho nenhum. Handoff falha fechado; falha da consulta
de elegibilidade degrada aberto, igual ao gêmeo.

tests/unit/assistido-respeita-o-gate.test.ts cobre os três motivos da regra
pura, o handoff, o controle positivo (sem ele, um `return` cedo demais deixaria
tudo verde por ausência) e a degradação aberta.

  npx vitest run tests/unit/assistido-respeita-o-gate.test.ts
  Test Files  1 passed (1) / Tests  6 passed (6)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(agenda): o veredito de I3 sai de cima do relógio

googlePushCandidates filtra `google_next_attempt_at <= now`, com `now` no
relógio do PROCESSO (new Date()) e a coluna no default `now()` do BANCO, que
aqui roda num container. O veredito de I3 passava a depender de os dois
relógios concordarem na casa dos milissegundos.

Medido nesta máquina, 30 amostras: o lote selecionado tem SEMPRE 1 linha (a
`healthy`), os 50 vínculos redigidos estão sempre fora, e a folga entre os dois
relógios é de 6 a 204 ms. Sabotagem de 5 ms (`now()+interval '5 milliseconds'`
na fixture) reproduz na hora o vermelho do CI — `:377:61 expected false to be
true`, 1 vermelho em 2 rodadas. A fixture passa a gravar o instante um minuto
no passado pelo relógio do próprio banco: tolera qualquer desvio abaixo de 60s
e não afrouxa nada do que I3 mede.

⚠️ freeze-invariants.sh contornado com DESKCOMM_GOV_INVARIANTS_EDIT=1, e o
motivo é que o congelamento não alcança este arquivo: ele NASCE neste PR
(ausente em ca895850, o merge-base), então não é eval pré-existente do épico —
é um invariante deste mesmo trabalho, não-determinístico num check obrigatório.

* fix(atendimento): quem já usa não perde o acompanhamento no update

A 0222 criou `messages.service_revision`, `conversations.current_demanda_id`,
`demanda_conversas.service_revision` e `followup_enrollments.service_boundary`
NULOS e sem backfill. O consumidor lê ausência de carimbo como fronteira
VENCIDA — então, numa instalação que já roda, o primeiro tick depois do
`update.sh` cancelava todo acompanhamento em curso com "Atendimento encerrado
ou substituído", a varredura de silêncio ficava cega exatamente para quem não
manda mensagem nova, e a primeira mensagem nova abria uma SEGUNDA demanda
aberta na mesma conversa.

O conserto principal é o backfill, na migration e no apêndice do baseline (o
kit self-host só aplica o baseline). Ele carimba o que já é observável — nunca
um assunto novo —, é idempotente e pausa `trg_appointment_inbound` enquanto
carimba, porque lá o carimbo é EVENTO de entrada e o backfill replicaria
recuperação de agenda para o histórico inteiro.

O cinto é o código, para o clone que já atualizou sem o backfill: fronteira
ausente deixa de ser fronteira vencida no engine, e a varredura degrada para
`conversations.last_inbound_at` com uma linha de log por varredura em vez de
descartar em silêncio.

Ensaio em pg15 descartável, baseline aplicado em install e update, cenário
legado com e sem o bloco de backfill:

  COM backfill  | demandas abertas 1 -> 2 (R2 do baseline) -> 2 apos a 1a
                  mensagem nova | carimbo da msg legada: tem | fronteira: tem
  SEM backfill  | demandas abertas 1 -> 2                  -> 3 apos a 1a
                  mensagem nova | carimbo: NULO             | fronteira: NULA

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(atendimento): o backfill diz ao operador o que carimbou

O dump do baseline abre com `set client_min_messages = warning`, então o
`raise notice` do bloco de backfill NUNCA chegava a quem roda o `update.sh` —
nem o de sucesso nem o de falha ao pausar o gatilho de agenda. Vira `warning`,
e a linha traz a contagem e se o gatilho foi mesmo pausado. Em banco já
carimbado a contagem é zero e nada é escrito.

  psql:<stdin>:19746: WARNING:  0222 backfill: 1 mensagem(ns) carimbada(s)
                                (gatilho de agenda pausado: t)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(suporte): a varredura de guarda passa a enxergar handler exportado como const

`export const PATCH = async () => {}` é forma válida de handler no App Router e
era invisível para o gate: a varredura só casava `ts.isFunctionDeclaration`.
Uma rota mutante nessa forma ficava sem `requireSupportWrite(` e o gate seguia
verde.

Prova de que ganhou dente — rota sintética `app/api/v1/__sonda_frente_c/route.ts`
com uma única linha, `export const PATCH = async () => new Response(...)`:

  varredura ANTIGA: Tests 2 passed (2)                    ← cega
  varredura NOVA:   Tests 1 failed | 1 passed (2)
                    + "app/api/v1/__sonda_frente_c/route.ts:PATCH"

Sem dívida herdada: as 6 ocorrências da forma `const` hoje em `app/api/v1`
estão todas sob `cron/`, que a varredura já pula na linha 9.

Para a forma `const` o texto medido é a declaração inteira, não só o corpo —
assim um wrapper (`export const POST = comX(async () => …)`) também é lido.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(drain): a frase "o turno revalida" era falsa no modo assistido

O comentário do gate de elegibilidade afirmava que desligar a checagem aqui era
seguro porque "o turno revalida (defesa em profundidade)". Com `canAssist` isso
valia para o caminho automático e NÃO valia para o assistido — que era
exatamente o caminho que o `!canAssist` liberava. A prosa é o que fazia a
ausência parecer intencional.

Agora aponta para onde a revalidação de fato mora.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(contatos): o invariante da colisão passa a travar o contrato que vale

EXCEÇÃO DECLARADA ao freeze-invariants.sh (DESKCOMM_GOV_INVARIANTS_EDIT=1).
O hook nomeia dois casos: código errado, ou invariante mal-escrito. Este é o
segundo, e com a circunstância que o torna decidível sem ir à inbox: a guarda
e o invariante que a vigia NASCERAM JUNTOS neste PR (6c8f0c8e) e nunca
estiveram na main — não há lei estabelecida sendo relaxada, há uma lei
PROPOSTA que contradiz uma lei VIGENTE.

O caso exigia que colisão de conversas abortasse a fusão inteira. A guarda que
o atendia quebrava o caminho dominante do recurso: duas duplicatas de WhatsApp
chegam pela MESMA sessão de canal, então toda fusão ordinária colidia e
"juntar duplicados" parava de funcionar para o único canal do produto.

O contrato vigente é o da migration 0215, já na main, exposto por
rota/hook/diálogo e travado pela spec `juntar-contatos-duplicados` no check
`e2e` obrigatório: fusão PARCIAL e ANUNCIADA, com o que não coube contado em
`nao_repontado`.

A preocupação da guarda não foi apagada, foi medida. Checkpoint é lido por
contato (`retomada.ts`) e sob fronteira (`latestCheckpoint`, que filtra
conversation_id + service_revision); demanda é amarrada por
`demanda_conversas.conversation_id`. O registro que fica na lápide é inerte
para o vencedor. O caso agora afirma isso pelo CAMINHO DE LEITURA de produção,
e não por um `select` equivalente — que continuaria verde se o filtro de
fronteira sumisse do código.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(e2e): uma terceira parte, porque o teto de 30 min disparou certo

`e2e-parte (2)` foi CANCELADA aos 30 min neste PR (job 101799282999) com 55 dos
57 arquivos terminados. Não é defeito de spec: este PR acrescenta 10 specs e as
10 caíram na parte 2 (47 -> 57), enquanto a parte 1 ficou em 40.

Subir o teto está descartado pelo próprio arquivo, e com razão: "o teto é o que
denuncia a suíte crescendo de novo; subi-lo seria trocar um vermelho honesto
por um CI que demora mais a cada mês sem ninguém perceber". Ele disparou
corretamente — a suíte chegou a 100 specs.

Duas partes também não davam mais para equilibrar com segurança. Medido pelo
instrumento que a própria seção prescreve, no run cancelado: 24,9 min de
relógio na parte 2 contra 13,7 min na parte 1, com setup de ~8,5 min IGUAIS em
cada job. Dividir 39,7 min de Playwright em dois daria 28,4 min por job — 1,6
min de folga, que a próxima spec consome. Em três, dá ~22 min, a mesma folga
que a parte 1 tem hoje.

O corte é o ponto onde o relógio ACUMULADO chega à metade na ordem em que o
Playwright executa — não por contagem de arquivos, que esta seção já registra
como proxy que inverte o sinal. Ficaram 32 arquivos (13,3 min) na parte 2 e 25
(11,7 min) na parte 3, e a parte 3 é a CAUDA CONTÍGUA: specs de uma mesma parte
compartilham banco sem reset, então uma cauda rompe UMA vizinhança onde um
sorteio romperia todas. Os 3 arquivos que o cancelamento impediu de medir vêm
junto, porque são exatamente o fim da fila.

Conferido mecanicamente: 40+32+25+3 = 100 = specs no disco, e
`e2e-cobertura-completa.test.ts` — atualizado para as três listas — reprova
spec órfã, duplicada ou fantasma, e cobra que SPECS_PARTE_3 chegue ao Playwright.

Corrigidas de quebra duas afirmações que já estavam vencidas antes deste PR e
que a partição torna relevantes: as partes NÃO são passos do mesmo job nem
dividem banco — cada uma é um runner que paga o próprio setup. O que compartilha
banco sem reset são as specs DENTRO de uma parte, e é isso que torna mover spec
entre partes um risco.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(acompanhamento): retirar o cinto de fronteira; quem cuida do legado é o backfill

O cinto que eu mesmo pus em ae4b4bc3 — sem carimbo, medir silêncio por
`last_inbound_at`; sem fronteira, seguir o acompanhamento — protegia uma
população VAZIA e abria dois caminhos reais. Medido por verificação
adversarial, em Postgres com o baseline aplicado:

· a 0222 não é ancestral de `origin/main`, nenhuma tag a contém, e a main para
  na 0218 — a migration nunca chegou a instalação nenhuma;
· o backfill vive no apêndice idempotente do baseline, então o `update.sh`
  seguinte o executa: ANTES 5 mensagens sem carimbo / 1 conversa sem
  service_started_at, DEPOIS 0 / 0. A janela teórica se cura sozinha.

O custo, em banco JÁ backfilled, é que falta de carimbo NÃO é sinal de legado —
é normal e recorrente em duas classes, e nas duas o cinto inscrevia quem não
devia em follow-up automático:

· conversa de GRUPO: `fn_service_inbound` retorna cedo e nunca carimba, e a
  consulta da varredura não filtra `is_group`;
· mensagem entregue FORA DE ORDEM depois de um fechamento
  (`m.sent_at <= c.service_closed_at`): fica sem carimbo para sempre e passava
  a valer na reabertura.

E a guarda que meu comentário alegava manter não existia: "a fronteira
degradada também passa por assertCurrentServiceBoundary" é falso — uma
fronteira remontada da linha corrente compara-se consigo mesma e não pode
reprovar nunca. Foi essa frase que impediu a brecha de ser vista.

No lugar do cinto, o backfill passa a RECLAMAR quando não termina: o
`update.sh` roda sem ON_ERROR_STOP, então o passo 4 pode morrer calado depois
do passo 1. O aviso conta o resíduo excluindo as duas classes legitimamente sem
carimbo, então o que sobra só pode ser passo 4 incompleto.

`ae4b4bc3` também havia removido `.not("messages.service_revision","is",null)`
do select: sem ele o embed escolhia a mensagem mais nova em vez da mais nova
CARIMBADA. Restaurado.

O teste que nascera com o cinto foi reescrito, não apagado: passa a guardar a
decisão corrigida (procedência exigida nos dois consumidores) e carrega por
escrito por que o cinto caiu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(atendimento): progressão de fronteira deixa de ser lida como conflito

Dois lugares tratavam "o mundo avançou de nada para algo" como "o mundo mudou
sob mim". É a mesma forma da guarda de colisão de fusão que este PR já
consertou: o caminho ORDINÁRIO lido como conflito.

1 · `assertCurrentServiceBoundary` comparava `demanda_id` por igualdade crua,
    mas a 0222 só incrementa `service_revision` quando TROCA de demanda — ir de
    "nenhuma demanda" para a primeira é o MESMO atendimento, por decisão do
    schema. O TypeScript discordava do SQL.

    O custo não era um teste: o gatilho de silêncio captura a fronteira de um
    contato CALADO (que por definição não tem demanda aberta) e o nó
    `ai_classify` espera o inbound do lead. Era essa resposta que abria a
    primeira demanda e vencia o acompanhamento que ela acabara de acordar — o
    nó ficava morto por construção, em produção.

2 · O CAS de `fn_service_begin` comparava a fronteira atual contra o literal
    `{"absent":true}`, que difere sempre. Um lead criado e depois movido de
    etapa gera dois eventos observados como `absent`: resolver o primeiro cria
    a conversa e o segundo morria com 40001 — que `serviceForEvent` engole como
    `stale_origin`. O follow-up de etapa não nascia, sem erro em lugar nenhum.

Nos dois casos o que se recusa continua sendo o atendimento OUTRO: demanda
fechada, conversa terminal, e trocar de demanda ou reabrir (que incrementam a
revisão). O estado sucessor admitido é exatamente um.

Também neste commit:

· `followup-builder`: a 6ª opção de gatilho não é decorativa. `appointment_no_show`
  tem motor ponta a ponta — `fn_appointment_change` emite
  `appointment.outcome_confirmed`, `gatilho-presenca.handler.ts` consome e está
  REGISTRADO em `register-handlers.ts`, e chama `fn_appointment_recover`, que
  insere em `followup_enrollments`. A lista da spec é que estava velha.

· `roteamento-por-canal`: a guarda `assertNoForeignRoutingDue` exigia fila
  global vazia, e nenhum outro ponto da suíte drena essa fila — era
  insatisfazível por construção. Medido nas duas pontas antes de trocar: mesma
  recusa com 152 specs antes e com 18. Agora ADIA o que não é da org (empurra
  `next_attempt_at`), em vez de recusar.

· gatilhos de etapa e de caso: `status:"ok"` descarta o `detail` no dreno, e era
  justamente ali que morava `origem_obsoleta=`. Com `matched && enrolled > 0`,
  o motivo de o follow-up NÃO nascer passa a chegar à linha do event_log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ia): agente PAUSADO volta a ser invisível para quem decide se ele atende

O PR reescreveu `estadoDoAgente` para ler `paused_at`, mas o campo continuou
OPCIONAL em `FatosDoAgente` — então consulta que não trouxesse a coluna
entregava `undefined`, `!= null` dava falso, e o agente pausado voltava a
contar como no ar. O TypeScript não podia acusar.

Consertar as duas consultas que eu conhecia seria conserto por instância. O
campo virou OBRIGATÓRIO, e aí o compilador achou CINCO sítios:

· `api/v1/ai/automatico-ativo` — se a tela diz "Automático atendendo";
· `lib/ai/agents/org-tem-automatico` — o mesmo fato, no servidor;
· `api/v1/ai/agents/assignable` — quais agentes podem receber conversa;
· `workers/ai-sentiment-worker` — QUAL agente cuida da conversa;
· `workers/ai-response-worker` (via o contexto em `lib/ai/types`) — se ele
  responde.

Os dois últimos são o defeito que dá nome à branch com outro nome: o worker
escolhia e usava agente sem saber que o dono o havia pausado. Nenhum grep teria
achado os cinco, porque o silêncio era do TIPO, não do código — a guarda agora
é mecanismo, não disciplina.

Também aqui: a precondição de `inbox-quem-manda`. "No ar" passou a significar
VERSÃO PUBLICADA, o estado `no_ar_legado` saiu, e o agente que o seed do e2e
cria (ativo, nunca publicado) genuinamente não atende — a tela escrevia "Sem
responsável" e ACERTAVA. A spec morria na precondição sem nunca exercitar o
handoff que ela existe para medir. A spec passa a publicar uma versão; a
asserção continua intacta. Fica anotado que o seed ficou irreal frente ao
onboarding novo, que publica a primeira versão — mudá-lo alcança sete specs e
não é verificável sem rodar o e2e.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(atendimento): a progressão se conserta na ORIGEM DO EVENTO, não no CAS

EXCEÇÃO DECLARADA ao freeze-invariants.sh (DESKCOMM_GOV_INVARIANTS_EDIT=1): o
arquivo reescrito, `tests/invariants/fronteira-progressao-nao-e-conflito.test.ts`,
foi CRIADO por mim no commit anterior (7afb2857) e nunca esteve na main. Ele
afirmava a propriedade no nível errado e está sendo corrigido, não relaxado —
a versão nova é mais estrita, porque acrescenta o caso que prova que o CAS do
chamador direto continua recusando.

Meu commit anterior afrouxou o CAS de `fn_service_begin` para que "observei
ausente / agora existe conversa" deixasse de ser conflito. Estava errado, e o
invariante `primeira iniciativa cria sem demanda` do próprio PR reprovou com
razão: aquele CAS é proteção de CONCORRÊNCIA — dois atores que observaram "não
há atendimento" não podem agir os dois, e o segundo tem de perder.

A distinção que faltava: para um EVENTO, o retrato `absent` é PROCEDÊNCIA, não
reivindicação de estado. Ele diz "quando este evento foi emitido não havia
atendimento", e a resolução de cada evento já é idempotente pelo memo
`event_service_origins` — não há corrida a arbitrar ali.

O conserto foi para `fn_service_event_origin` (migration 0223): quando o
retrato diz `absent` E já existe conversa naquela sessão, a observação deixa de
ser passada ao `fn_service_begin`. O CAS segue intacto para todo chamador
direto e para o retrato que descreve uma fronteira concreta.

Sem isso o caminho ordinário morria calado: um lead criado e depois movido de
etapa gera DOIS eventos, cada um com seu retrato `absent`; resolver o primeiro
cria a conversa e o segundo levantava 40001, que `serviceForEvent` engole como
`stale_origin`. O follow-up não nascia e nada aparecia em lugar nenhum.

O `baseline.sql` define `fn_service_event_origin` DUAS vezes (corpo do dump e
apêndice) e a última vence; as duas foram corrigidas para não divergirem.

Medido: `service-boundary.test.ts` (que eu havia quebrado),
`service-event-origin.test.ts` e o invariante novo passam juntos — 23 casos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(e2e): duas fixtures anteriores à fronteira criam o estado que a produção cria

Ao retirar o cinto do `silence-sweep`, duas specs da parte 1 ficaram vermelhas.
Parecia regressão minha. Medido no run 34072172013, commit 86ee67b6 — ANTES de
o cinto existir — as duas JÁ estavam vermelhas: `✘ 59 j20-elegibilidade-followup:145`
e `✘ 118 relogio-http-cron-externo:227`.

Ou seja: o cinto não as quebrou, ele as MASCARAVA. Ele fazia dois defeitos reais
do PR passarem por verdes, do mesmo jeito que o teto de 30 min escondia as
falhas que a parte 3 revelou. Rede de segurança que faz teste passar não é rede,
é venda nos olhos.

As duas fixtures nasceram antes da fronteira do atendimento e semeavam estados
que o produto não cria:

· `seed-silent-contact` criava a conversa com `last_inbound_at` e NENHUMA
  mensagem — o silêncio como um CAMPO. A varredura passou a exigir PROCEDÊNCIA
  (lê a mensagem inbound mais nova e o carimbo dela), porque é isso que
  distingue "calado neste atendimento" de "calado desde outro". Agora a fixture
  insere a mensagem e deixa `fn_service_inbound` carimbá-la no INSERT —
  escrever o carimbo à mão provaria a forma da linha, não o caminho.

· `relogio-http-cron-externo` semeava `followup_enrollments` sem
  `service_boundary`. Todo caminho de produção carimba (`lib/followup/enroll.ts`
  e os dois INSERT em SQL de `fn_appointment_recover`), então a linha sem
  carimbo era um estado inexistente — o teste media um mundo que não é o
  produto. Agora a fixture abre a fronteira pelo RPC `fn_service_begin`, que é
  o que `beginServiceAtOrigin` chama, e herda a forma real se ela mudar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(e2e): a fixture da jornada também cria a mensagem carimbada

Mesma classe do commit anterior, em outro arquivo — que é o modo de falha
"conserto por instância, não por classe". `seed-silent-contact` de
`e2e-followup-journey-helpers.ts` criava o silêncio como um CAMPO
(`last_inbound_at`) sem linha em `messages`, e a varredura passou a exigir
PROCEDÊNCIA. Sem a mensagem, a conversa é invisível para o gatilho.

Medido depois do conserto da fronteira: o erro da spec MUDOU de
`service_boundary_stale` no `complete-turn` para "varredura de silêncio não
enrollou o contato a tempo" — ou seja, ela passou do defeito antigo e morreu no
seguinte, que é esta fixture.

Varri a classe: `seed-e2e-escalacao` usa `last_inbound_at` de agora (não é
silêncio) e `seed-e2e-queue` é da visão "Fila", não da varredura — nenhum dos
dois alimenta o gatilho, e as specs deles passam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(schema): a 0224 sobrescrevia o conserto da 0223, e agora um gate mede isso

A migration 0224 redefine `fn_service_event_origin` — assinatura idêntica, logo
`create or replace` puro — com o corpo ANTERIOR ao conserto da 0223. E
`20260906120000 > 20260906030000`: na CADEIA de migrations o conserto sumia.

No `baseline.sql` não sumia, porque lá o bloco foi editado à mão. Os dois
artefatos divergiram, e nenhum gate via: `pnpm test:db` aplica só o baseline.
Quem aplica a cadeia (Supabase CLI, `db reset`, clone que migra em vez de
re-aplicar o baseline) ficava com o defeito. Reproduzido em Postgres: aplicar o
bloco da 0224 sobre o baseline reintroduz `service_stale` no segundo evento.

O conserto entrou DENTRO da 0224, que é a versão funcionalmente mais nova (ela
acrescenta o ramo de `appointment.outcome_confirmed`) — não numa migration
nova, porque a 0224 é deste mesmo PR e nunca foi aplicada em lugar nenhum.

E como o custo aqui não é o defeito e sim a INVISIBILIDADE dele, entra o gate:
`apendice-do-baseline-nao-diverge-da-cadeia.test.ts` compara, para toda função
escrita à mão no apêndice, o corpo da última definição da cadeia com o do
baseline. Ele mede SEMÂNTICA, não prosa — três normalizações, cada uma exigida
por um falso vermelho que ele mesmo produziu antes de eu confiar nele:

· comentários fora (11 funções antigas diferiam só em comentário reescrito);
· marca do delimitador uniformizada (`$fn$` do baseline contra `$$` da
  migration — o corpo é o mesmo, e um parser que procura `$$;` fixo lê ALÉM do
  fim da função);
· espaço colado ou não em `(`, `)`, `,` (o `pg_dump` e a mão humana discordam).

Com a régua certa: 88 funções tocadas por este PR, 129 comuns aos dois
artefatos, e ZERO divergências — sem allowlist nenhuma, que é o que separa um
gate de uma lista de desculpas.

Terceira peça: o resumo do dreno passa a carregar `pulados`. O `detail` de um
`skipped` já sobrevivia na linha do `event_log`, e isso basta para quem tem
psql — não basta para o CI, onde o único artefato que sobra do job é o trace, e
o trace guarda o CORPO da resposta. Um e2e que morre porque o gatilho pulou não
conseguia dizer QUAL pulo foi: `failed=0` e nenhuma pista, que foi o que travou
o diagnóstico de `gatilho-de-etapa.spec.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(crm): a origem do atendimento é reservada ao servidor — então ele passa a escrevê-la

EXCEÇÃO DECLARADA ao freeze-invariants.sh: o arquivo tocado,
`fronteira-progressao-nao-e-conflito.test.ts`, foi criado por mim neste PR e
ganha DOIS casos novos. Nada foi relaxado.

CAUSA RAIZ do último vermelho, e ela é de produto, não de teste. O `pulados` que
eu acabara de acrescentar ao dreno entregou o dado na primeira rodada:

  lead.stage_changed/followup-gatilho-etapa.v1:
    armados=1 enrolled=0 origem_obsoleta=1 ja_vivo=0 gate=0 sem_contato=0

`emit_event` RECUSA `service_origin` vindo de chamador autenticado (42501, e com
razão: é o campo que autoriza efeito operacional, não payload público) — e
ninguém o escrevia no lugar dele. O resultado:

· quem move o negócio PELA IA carimba a origem no servidor
  (`agent-stage-sync`, `appointment-stage-move`, `handoff-stage-move`) e o
  follow-up nasce;
· quem move PELO QUADRO — o operador, pela rota HTTP autenticada — emitia um
  evento SEM origem. `fn_service_event_origin` caía no `service_stale` final
  (40001), `serviceForEvent` engolia como `stale_origin`, e o follow-up nunca
  nascia. Sem erro em lugar nenhum.

Ou seja: o gatilho de etapa era inalcançável pelo único caminho que o produto
oferece na tela. A spec não estava vermelha por acaso — ela mede exatamente
"o negócio movido NO QUADRO arma o follow-up sozinho".

O conserto lê "campo reservado" pelo que ele significa: reservado AO SERVIDOR,
e o servidor tem de escrevê-lo. `emit_event` passa a carimbar a origem quando
ela está ausente, tirando o retrato no instante da emissão — que é a semântica
de procedência que a 0223 quer. A resolução do contato repete a regra que
`fn_service_event_origin` já usa; tipo que ela não sabe resolver segue sem
origem, como antes. Origem já presente NÃO é sobrescrita: quem move pela IA
pode estar passando uma CONTINUAÇÃO, que é o que amarra o efeito ao atendimento
de onde ele nasceu.

Entrou na 0224 (a última da cadeia a definir `emit_event`) e na definição
correspondente do baseline — o gate de espelho confirma que os dois artefatos
seguem iguais.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(e2e): a contagem de cobertura também soma a parte 3 — e o gate passa a cobrar isso

As TRÊS partes ficaram verdes e o job agregador reprovou assim mesmo:

  ::error::recorte das listas divergiu — rodou=72 fora=3 disco=100

40+32+3 = 75, e faltavam exatamente as 25 da parte 3. O passo de cobertura do
agregador soma as listas à mão, e eu tinha atualizado a matriz, o `case` que
alimenta `LISTA` e o gate de teste — mas não essa soma.

O defeito de verdade não é a linha esquecida: são DUAS implementações da mesma
regra sem nada ligando uma à outra. `e2e-cobertura-completa.test.ts` guardava
"toda spec está em alguma lista" e o passo do workflow guardava a mesma coisa
por outro caminho; deu para divergir porque nada media a divergência.

Então o gate parou de enumerar as partes à mão — enumerar aqui repetiria
exatamente o erro que ele existe para impedir. Ele DESCOBRE as
`SPECS_PARTE_\d+` declaradas no workflow e cobra, para cada uma:

  · que ela alimente `LISTA` (senão a lista existe e nunca roda);
  · que ela entre na soma `RODOU=$( { ... } )` do agregador.

Quem acrescentar uma quarta parte não precisa lembrar de nada — e se esquecer
de ligá-la, descobre no gate em vez de descobrir no CI vermelho.

Sabotagem, com previsão declarada antes: tirar a parte 3 da soma reprova
nomeando `SPECS_PARTE_3 não entra na contagem de cobertura`; tirar a linha do
`case` reprova com `SPECS_PARTE_3 não alimenta a variável que roda`. As duas
bateram.

`AGENTS.md` dizia "listas SPECS_PARTE_1/SPECS_PARTE_2" — afirmação de estado que
esta mudança venceu. Trocada pelo padrão `SPECS_PARTE_*`, que não envelhece.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:50:37 -03:00
Rafael Melgaço c7e5308af7 fix(295): a doutrina acompanha o detector novo, e o conserto do modelo sobrevive ao refetch
Consertos de triagem sobre o #295. Os cinco consertos dele ficam — medi um a um e
os cinco são reais (a conta fecha: cinco afirmados, cinco entregues).

── 1. DoD 16: quatro documentos descreviam um detector que deixou de existir ───

`git diff --name-only origin/main...pr/295 -- docs/ CLAUDE.md '*.md'` era VAZIO, e
depois do merge estas quatro afirmações ficariam falsas em documento de
autoridade — o modo de falha nº 1 da triagem, e o motivo de o DoD 16 existir
(a auditoria de 2026-08-14 achou 227 afirmações desatualizadas em 393):

  CLAUDE.md:92 · docs/business-rules/…:172 (W-02, hard constraint) ·
  docs/prd/03-…:231 · docs/research/reference-synthesis.md:121

Todas trocadas seguindo a receita do próprio CLAUDE.md — "onde a afirmação puder
virar comando, troque em vez de corrigir". Em vez de recopiar uma regex nova que
envelhece igual, elas passam a apontar para `lib/opt-out/deteccao.ts` e para as
frases de controle do teste, com o comando que as lê.

── 2. Uma DECISÃO REGISTRADA estava sendo revertida em silêncio ───────────────

`ADR-EPIC03-07` diz literalmente: "STOP detection é match exato regex (palavra
isolada). **Frases livres NÃO bloqueiam**". O #295 inverte isso — "não quero mais
receber", "me tira da lista" e "cancelar inscrição" passam a bloquear.

A reversão é a coisa CERTA (a regra antiga deixava passar 21 de 33 pedidos reais,
e opt-out é direito legal num produto vendido como LGPD-nativa), mas o CLAUDE.md
exige abrir item de revisão, não sobrescrever. A ADR-07 fica marcada como
supersedida, com o porquê, e entra a **ADR-EPIC03-07b**.

E há uma ironia que vale registrar: a ADR-07 existia para evitar falso positivo,
e a implementação nunca foi "match exato" — a regex caçava a palavra em qualquer
posição. Ela produzia exatamente o falso positivo que a decisão queria evitar. A
decisão escrita e o código nunca coincidiram.

── 3. O conserto nº 4 ("modelo em vigor") não sobrevivia ao primeiro refetch ───

O PR acrescentou o join `versao_publicada` ao Server Component, mas a lista é
re-hidratada por `useAgentsList` → `GET /api/v1/ai/agents`, cujo `AGENT_COLUMNS`
não tinha o join. O cartão voltava a mostrar o id do CADASTRO na primeira
revalidação. Duas fontes para a mesma lista têm de pedir as mesmas colunas.

O join entra numa constante SEPARADA, usada só na listagem: pedi-lo no POST faz o
tipo da linha recém-inserida deixar de resolver (`GenericStringError` —
reproduzido, `tsc` acusou nas linhas 158 e 170), e é coerente, porque agente
recém-criado tem `published_version_id = null` por construção.

── 4. A religação do runtime não era vigiada por NENHUM dos 5123 testes ───────

Medido: revertendo só `lib/agent-engine/agent/human-handoff.ts` para a versão
antiga — que reintroduz as duas regras divergentes cuja unificação é o conserto
nº 1 — a suíte inteira fica VERDE (458 arquivos, 5123 casos, exit 0). O PR
protegeu o outro lado (`pos-entrada.ts` tem assertiva de import), e este ficou
descoberto.

Dois casos de COMPORTAMENTO, não de texto — as frases só respondem certo pela
regra nova. Sabotado: revertendo o arquivo, 1 de 46 falha; restaurado, 46/46.

── 5. Dois consertos menores ──────────────────────────────────────────────────

- O dublê do SELECT em `arquivar-agente-arquiva-mesmo` tinha aridade FIXA de dois
  `.eq()`. Um filtro novo o quebraria com "maybeSingle is not a function", erro
  que não fala do comportamento vigiado. É a armadilha que mordeu três vezes
  nesta rodada; virou encadeável sem limite, como o dublê do UPDATE ao lado.
- `app/app/ai/agents/page.tsx` descartava o `error` do SELECT — e o join novo
  acrescentou uma causa de erro a ele. "Não consegui perguntar" e "você não tem
  agente nenhum" pintavam a MESMA tela, e a segunda é uma afirmação forte sobre o
  trabalho de quem instalou. Continua degradando para lista vazia (a tela não
  pode quebrar), mas agora deixa rastro.

── Verificação ───────────────────────────────────────────────────────────────

`tsc --noEmit` exit 0 · `pnpm lint:channels` ok · 88 casos verdes nas seis suítes
tocadas pelo PR.

NÃO MEDIDO: nada pela tela. Os itens de UI foram verificados por código, teste e
sonda de componente — não por navegador em ambiente fresco (DoD 12).
2026-08-21 07:13:01 -03:00
Rafael Melgaço 4413b7fdb0 fix(pacing): domingo deixa de vetar por default — e a regra ganha a guarda que nunca teve
Decisão do dono do produto (2026-08-20). A janela horária é CORTESIA, não
anti-banimento — a distinção já está no código, no `banRisk` de
`pacing/engine.ts`, que desarma warm-up, cap e throttle mas mantém a janela em
todo canal. Calar o domingo INTEIRO num CRM de ATENDIMENTO faz quem escreve no
domingo só ser respondido na segunda: o custo cai sobre o cliente final, não
sobre o risco de bloqueio.

`allowSunday` passa a `true` por default. A janela horária (7h-22h) fica: cala à
noite, como antes. E continua sendo knob por canal — quem faz prospecção ativa e
prefere não incomodar no fim de semana desliga em Conexões → anti-banimento
(`AntiBanSheet` → `POST /api/v1/ai/pacing`, coluna `channel_knobs.allow_sunday`).

**A metade do domingo não tinha NENHUMA guarda de comportamento.** Medido antes
de mexer: virar o default de `false` para `true` não deixou um único teste
vermelho — `pacing-cortesia-vs-antiban` cita "domingo" só num comentário, e os
outros dois arquivos que tocam `allow_sunday` passam `null`. Default de regra de
negócio sem teste é default que volta sozinho no próximo refactor, então entra
`tests/unit/janela-domingo-e-knob.test.ts`, que guarda as DUAS direções:

- domingo comercial passa; terça comercial também (controle, para o primeiro não
  passar por acidente); domingo de madrugada veta por HORA e o motivo não fala
  em domingo;
- com `allowSunday: false` explícito, domingo comercial é vetado com "sem
  domingo" no motivo, e o reagendamento cai FORA do domingo.

Sabotado para provar que vigia: devolvendo `allowSunday: false`, 3 de 6 falham.
Restaurado, 6/6. As quatro suítes vizinhas seguem verdes.

Doutrina acompanhando o código, não a prosa: W-07 em
`docs/business-rules/00-business-rules-catalog.md` reescrita com o porquê e com
onde se configura, `docs/prd/03-prd-whatsapp-waha.md:241` (o exemplo era
literalmente "domingo 23h"), `CLAUDE.md:91`, e a cópia da tela — que dizia
"Desligado por padrão: envio em domingo aumenta o risco de denúncia e bloqueio".
2026-08-20 15:55:06 -03:00
Rafael MelgaçoandClaude Opus 5 4aa65590e0 feat(orcamento): a tela deixa de mentir, e a IA que para avisa gente
Ondas 4 e 5, que fecham o conserto: o controle da tela passa a valer, o sistema
paralelo morto sai, e um bloqueio deixa de abandonar o lead.

`DESKCOMM_GOV_INVARIANTS_EDIT=1` — dois invariantes MODIFICADOS, e a razão:
`vocabulario-banco-x-typescript` ganhou o par `ModoDeOrcamento` (coluna nova com
CHECK precisa de par no TypeScript, senão o job `invariants` reprova), e
`orcamento-apos-backfill` acompanhou a mudança do piso. O invariante NOVO
(`orcamento-nasce-desarmado`) não precisa da flag.

## Os três achados que valeram mais que a feature

**1. A escolta era inalcançável no caso dominante.** `classifyStage` roda em
todo turno e estourava ANTES da escolta — que, portanto, nunca protegia o
caminho normal. Movida para `runAgentTurn`, que envolve o turno inteiro. A
guarda antiga contava `runModelCall(` no texto e era cega para helpers; agora
conta call sites e a fronteira dos auxiliares.

**2. A escada era contornável pela REST do Supabase** (migration 0160). O PATCH
exige `off → avisar → bloquear`, mas `ai_budgets` aceitava escrita direta de
`authenticated` — qualquer cliente com a anon key pulava a escada e armava
bloqueio imediato. `revoke insert/update/delete`, SELECT fica.

**3. O texto mandava clicar num botão que não existe.** A mensagem que o cliente
lê quando a IA para dizia "Retomar atendimento automático"; o componente
renderiza **"Devolver ao automático"**. Corrigido em 7 arquivos, e a guarda nova
**lê o rótulo do componente** em vez de guardar uma cópia — cópia de rótulo é a
próxima mentira esperando envelhecer.

## O dropdown decorativo saiu

"Ação ao atingir 100%" oferecia "Pausar" vs "Desabilitar" e o produto entregava
sempre a mesma coisa. Era o defeito que este trabalho existe para matar,
sobrevivendo dentro do conserto — um juiz reprovou um desenho inteiro por
mantê-lo. Ou entrega os dois futuros, ou sai. Saiu.

## O lead não fica mais no vácuo

Quando o orçamento barra, o turno vai para `performHumanHandoff` com contexto, e
o job é **cancelado** (`cancelJob`), não falhado com retry: repetir uma chamada
que o teto recusa é gastar de novo para receber a mesma recusa. O caminho legado
ganhou o mesmo veto — e depois dos gates que reconhecem "quero falar com um
atendente", não antes, senão pedir humano viraria silêncio.

## Sabotagem: 10 previsões, 10 acertos

escolta fora do turno 3×3 · piso só para bloquear 3×3 · não zerar a carência 2×2
· veto antes dos gates 1×1 · sem handoff 1×1 · ressalva de medição sumida 1×1 ·
rótulo do período revertido 2×2 · flag de gasto incompleto fixa 1×1 · janela do
mês removida 1×1 · botão renomeado 3×3.

## Uma discordância mantida, com evidência

O KPI de plataforma fica na coluna acumulada. A alternativa barata seria um
`group by` inline — a segunda régua de gasto que `orcamento-uma-regua-de-gasto`
existe para impedir, e o gate provou o ponto reprovando o próprio comentário do
implementador quando ele escreveu a query em PROSA. Mitigado: nunca `critical`,
rótulo "acumulado", divergência escrita no arquivo e link para a tela que tem a
régua certa.

## Bateria

typecheck 0 · lint 0 erros · lint:channels 0 · test:shell 0 · build 0 ·
test:unit **3347 passam**, 1 falha — a baseline do `.env.local` deste worktree,
que não cita orçamento, budget, handoff nem money.

## NÃO MEDIDO, declarado

- `tests/invariants/orcamento-nasce-desarmado.test.ts` é **novo e nunca foi
  executado** (15 casos, e é a primeira vez que `SQL_ORCAMENTO` roda contra
  Postgres em todo o repo). É o maior risco de CI vermelho desta entrega, e está
  escrito aqui porque dívida declarada não é defeito — dívida presumida é.
- O `revoke` da 0160 não foi provado contra PostgREST vivo: o achado veio de
  leitura de grants.
- **DoD 12 (prova pela tela): NÃO MEDIDO.** Radio, kill switch e badge estão
  provados por unidade em jsdom e por `build`, não por browser em ambiente fresco
  estilo VPS.
- `test:db`, `e2e` e `build-and-size`: quem prova é o CI.

Nota de release em `docs/release/teto-de-orcamento.md`; mapa vivo em
`docs/architecture/teto-de-orcamento.architecture.json` (26 nós, 43 arestas).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TCiffR3ugjQbxsfceQE2GB
2026-08-15 10:58:07 -03:00
Rafael MelgaçoandClaude Fable 5 e74d76735e merge: origin/main → feat/followup-flows (integração do sistema de follow-up)
Integra 134 commits da main (inbox-multimodal, split de mensagens, templates,
snooze, notes, auth signup/recovery) com o sistema de follow-up (8 ondas).

DESKCOMM_GOV_MIGRATION_EDIT=1: falso positivo do guard de migration durante MERGE —
o 0055 que ele acusa é da main (entrando via merge), não migration nova. Confirmado
zero NNNN duplicado no tree (cada número único; única migration nova é a 0065).

Conflitos resolvidos (6, aditivos): Sidebar, agent-inbox-copy, audit/actions,
register-handlers, baseline.sql, MANIFEST. Migrations sem colisão de número.

Forward-fix 0065: 0057(followup_dead) e 0062(snooze_expired) redefiniam a mesma
constraint agent_inbox_items_kind_check — a última vencia; reconciliada com a união.

Kit self-host: docker-compose.prod.yml ganha o cron do followup-flow-worker (a cada
minuto) — sem ele o motor de follow-up não roda na VPS.

Validação: typecheck 0, lint 0, build ok, invariantes 40 arquivos/247 verdes (baseline
fresh+update com os 12 apêndices), pnpm-lock reconciliado.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143T3gYRomX8kFxK3KhJK1U
2026-07-23 09:21:01 -03:00
Rafael MelgaçoandClaude Fable 5 a4762f846b docs(followup): documenta contrato do sistema de follow-up no PRD 05 (DoD) [onda 8]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143T3gYRomX8kFxK3KhJK1U
2026-07-22 22:12:34 -03:00
Rafael MelgaçoandClaude Fable 5 f3ff2af40b docs(branding): reposicionamento — de CRM de e-commerce para AI Sales OS
De "CRM operacional para e-commerce" para "sistema operacional de vendas
open source com agentes de IA, nativo no WhatsApp" — refletindo a adoção
multi-nicho da comunidade (clínicas, imobiliárias, infoprodutos, agências)
e a especialização em agentes de IA via MCP.

- VISION.md novo: fonte da verdade do posicionamento (Deskcomm = Desk +
  comm, "comercial de mesa"; princípios sobre agentes auto-aprimoráveis;
  modelo open source + infraestrutura declarado sem letra miúda)
- README bilíngue (pt-br primário + README.en.md com seletor de idioma)
  com hero orientado a benefício e âncora "alternativa aberta a
  Kommo/Octadesk/Intercom"; bloco HostGator preservado intocado
- public/llms.txt: GEO — resumo estruturado pra crawlers de IA
- PRD master: nota de transição §0 + sumário/visão/diferenciais/glossário
  atualizados (e-commerce = primeiro vertical, não definição)
- CLAUDE.md e package.json alinhados ao novo posicionamento
- deploy-selfhost: tagline atualizada

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARJNvbYiBAtSV5W1rLg5zj
2026-07-22 09:45:42 -03:00
Rafael Melgaço 8cd723b84d docs(waha): switch production hosting recommendation Hetzner → Hostgator
Hostgator é parceiro comercial do projeto, justificando preferência sobre
Hetzner mesmo com custo maior (~R$140 vs ~$5/mês). Datacenter São Paulo
adiciona vantagem real de latência <30ms pro Meta BR (vs ~150ms da EU).

- Novo runbook docs/runbooks/waha-hostgator.md (13 seções: bootstrap,
  Nginx + egress allowlist Vercel CIDRs, restic→B2, watchdog, restore drill)
- Specs/PRDs/research/presentation atualizados com plano Turing, custo R$,
  datacenter SP
- Hetzner mantido como plano B documentado (sem parceria) e DR cross-region
- Backup: substituído "Hetzner Volume Snapshots" por "restic → Backblaze B2"
  (Hostgator não tem snapshots nativos — restore drill mensal documentado)
2026-05-05 11:52:23 -03:00
Rafael MelgaçoandClaude Opus 4.7 d0859733d8 feat: bootstrap DeskcommCRM v0.1 — full PRD/Specs/scaffold + Supabase schema
DOCS (~85k words):
- PRD-Master + 6 Sub-PRDs (platform, customer 360, whatsapp, pipeline, ai, nuvemshop)
- 60 Business Rules catalog (T/L/W/P/AT/IA/B prefixes with enforcement layer)
- 8 Technical Specs (~60k words) — schema SQL, code patterns, flows
- 15 Mermaid architecture diagrams (C4, ER, sequence, state machines)
- Pitch deck for SP meeting 2026-04-29
- Reference synthesis from CRM Nichado WAHA bundle

CODE SCAFFOLDING (38 files, ~1.7k lines):
- Next.js 15 App Router + TypeScript + Tailwind + shadcn/ui
- Supabase clients (browser, server, admin) via @supabase/ssr
- API wrappers (ok, fail, ApiSuccess<T>, ApiError) + canonical error codes
- env.ts with Zod validation; .env.example exhaustive
- Health check /api/v1/health (Supabase + Redis + WAHA pings)
- docker-compose for local WAHA dev
- vercel.ts with 7 cron schedules
- CLAUDE.md with project conventions and anti-patterns

SUPABASE SCHEMA DEPLOYED (sa-east-1, project rrydmwnporysaiysiztn):
- 19 tables with RLS enabled
- platform_base: organizations, user_organizations, platform_admins, api_tokens,
  api_audit_log (append-only), user_recovery_codes, idempotency_keys
- event_log + emit_event/fn_log_event helpers (bus interno; trigger NEVER calls HTTP)
- customer_360: contacts (CPF encrypted via pgcrypto), crm_pipelines, crm_stages,
  crm_leads, crm_lead_activities, crm_lead_links, merge_queue
- whatsapp: channel_sessions, channel_session_warmup, conversations, messages,
  webhook_events_log
- Triggers: auto won/lost via stage flags, denorm last_activity_at, emit lead/message
  events, seed default pipeline on org create, validate lost_reason
- RLS helpers: fn_user_org_ids, fn_is_platform_admin, fn_user_role_in_org, fn_role_at_least
- 4 migrations applied: 0001 platform_base, 0002 event_log+compat, 0003 customer_360,
  0004 whatsapp_waha

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:00:26 -03:00