# CLAUDE.md — DeskcommCRM > Instruções pra futuras sessões Claude trabalhando neste repo. Leitura obrigatória antes de qualquer task de código. **Este arquivo é a doutrina — a autoridade final sobre convenção e anti-pattern.** Complementos, na ordem em que ajudam: - [`AGENTS.md`](AGENTS.md) — mesmo contrato em forma portável (para Codex/Cursor/Copilot e afins). É derivado deste arquivo, não o substitui. **Ao mudar doutrina aqui, verifique se `AGENTS.md` desatualizou.** - [`docs/index.md`](docs/index.md) — índice dos 149 docs, com regra de precedência quando dois docs discordam. Use antes de sair varrendo `docs/`. - [`docs/current-state.md`](docs/current-state.md) — o que está pronto, incompleto e quebrado. **Leia antes de estimar ou prometer qualquer coisa.** - [`docs/harness-audit.md`](docs/harness-audit.md) — onde a verificação tem buraco. Importante: `pnpm gov:verify` **não** cobre `test:db` nem `test:e2e` — verde ali não é prova para mudança de schema ou de UI. - [`docs/threat-model.md`](docs/threat-model.md) — superfície de ataque real do self-host. --- ## Visão (1 parágrafo) DeskcommCRM é um sistema operacional de vendas open source com agentes de IA nativos — multi-nicho (e-commerce, clínicas, imobiliárias, infoprodutos, serviços), com WhatsApp como canal primário (via WAHA). Agentes com RAG por tenant atendem, qualificam e movem o funil junto com humanos; CRM inteiro exposto via MCP. Monetização = self-host em VPS (parceria HostGator), não assinatura. Arquitetura multi-tenant com RLS desde o dia 1; LGPD nativa. Posicionamento completo: `VISION.md`. --- ## Stack canônica - **Frontend:** Next.js 16 App Router (Turbopack) + React 19 + TypeScript 6 estrito + Tailwind 4 (config em CSS — ver abaixo) + shadcn/ui (style: `new-york`, neutral) - **Backend:** Next.js Route Handlers (mesmo repo); workers via `event_log` table + cron - **DB:** Supabase (Postgres). RLS em toda tabela tenant-aware. Extensions: `uuid-ossp`, `pgcrypto`, `vector` - **Auth:** Supabase Auth via `@supabase/ssr`. Cookie SameSite=Strict, HttpOnly, Secure - **Realtime:** Supabase Realtime (postgres_changes + broadcast) - **Storage:** Supabase Storage (bucket `whatsapp-media` privado, URLs assinadas) - **WhatsApp:** WAHA Plus, engine NOWEB - **Filas/eventos:** `event_log` table + workers (não usar Inngest/Trigger no MVP) - **Rate limit:** Upstash Redis sliding window - **AI:** Vercel AI Gateway (Anthropic primário; OpenAI backup pra embeddings); strings tipo `"anthropic/claude-sonnet-4-6"` - **Validação:** Zod em todo input externo (request body, webhook payload, env) - **Observability:** Sentry com `beforeSend` sanitizado --- ## Convenções críticas (NÃO NEGOCIÁVEIS) ### Multi-tenancy - `organization_id uuid not null references organizations(id) on delete cascade` em **toda** tabela tenant-aware - RLS policy `tenant_isolation__all` aplicada via helper `fn_user_org_ids()` - Service role bypassa RLS — handlers que usam admin client **DEVEM** filtrar `organization_id` manualmente, resolvido de fonte confiável (cookie/JWT/webhook secret/path token), **NUNCA do body** - Toda query que cruza tabelas tenant-aware filtra `organization_id` explicitamente - Teste de isolamento (cria 2 tenants, verifica não-vazamento) é obrigatório no CI antes de merge ### Idempotência & event sourcing leve - Mensagens WhatsApp e eventos externos: `unique (organization_id, external_id)` + captura `code === '23505'` no INSERT - POSTs de criação na API aceitam header `Idempotency-Key: ` (TTL 24h via Upstash) - **Trigger Postgres NUNCA faz HTTP.** Trigger emite linha em `event_log`; worker (cron / Realtime listener) consome e dispara side effect ### API REST `/api/v1/` - Versionamento por path. JSON snake_case. UUID v4. ISO-8601 UTC. Dinheiro em `_cents` + `currency` ISO-4217 - Wrapper sucesso: `{ data, meta?: { cursor, has_more, total } }` - Wrapper erro: `{ error: { code, message, details? } }` — usar helpers `ok()` / `fail()` de `lib/api/wrappers.ts` - Paginação: cursor opaco base64+HMAC por default - Auth dual: cookie session (frontend) OU `Authorization: Bearer tok_...` (server-to-server) - **API key NUNCA em query string** (vaza em logs Vercel/CF). Sempre header - Plaintext de bearer token mostrado **uma vez** na criação; depois apenas hash SHA256 no DB - Rate limit headers: `X-RateLimit-*` + `Retry-After` em 429 - `X-Request-Id` em toda response (correlaciona com audit log) ### Auth & RBAC - Sempre `getUser()` (valida JWT no backend). NUNCA `getSession()` (confia no cookie local) - 4 roles dentro do tenant: `viewer` (1) < `agent` (2) < `manager` (3) < `admin` (4) - Super-admin de plataforma é uma role transversal — `is_platform_admin` (decisão final na Spec 01) - MFA TOTP é **opcional e ligado por quem administra** — não é mais forçado por papel. Quem exige são duas políticas independentes que SOMAM: `platform_admins.mfa_required` (para o super-admin) e `organizations.settings.security.mfa_required` (para o `admin` do tenant). O padrão de ambas é **não exigir**, e o `bootstrap-owner.ts` grava `false` explícito. Regra pura em `lib/auth/politica-mfa.ts` - **Por que mudou:** o gate era `isPlatformAdmin || role === "admin"`, sem opção, e o `install.sh` cria o dono como platform admin — então TODA instalação self-host recebia um bloqueador de tela cheia logo depois do onboarding, um passo que o wizard nunca anunciou. Decisão do dono do produto; segurança que expulsa o usuário na primeira tela não protege ninguém - **⚠️ CADASTRAR e PROVAR são perguntas diferentes.** A política decide o cadastro. Já `mfaEmDivida()` — o 403 `mfa_required` das rotas — NÃO consulta a política: quem TEM fator prova na sessão, sempre. Ligá-lo à política faria quem ativa a verificação por vontade própria ter o fator ignorado - Ligar/desligar vive em **Configurações › Segurança**; desligar o próprio fator exige sessão `aal2` (senão uma sessão roubada desliga a proteção com um clique) - Permissão por pipeline (`user_pipeline_access`) **NÃO** entra no MVP ### Audit log - Toda mutação POST/PATCH/DELETE bem-sucedida → 1 entrada em `api_audit_log` (fire-and-forget, p99 ≤500ms) - **Rodada de cron que não fez nada NÃO é mutação e não audita** — e a que fez, audita. `routing-worker` (1×/min) e `attendant-heartbeat` (1×/5min) auditavam incondicionalmente: ~51.840 linhas/mês numa instalação que não atende ninguém, e numa VPS real **95% do audit log** era batida de cron vazia (`docs/testing/user-journey-map.md`, achado 17). A guarda certa é *auditar quando houve efeito*, nunca *parar de auditar* — as duas direções são medidas por `tests/unit/cron-audita-so-quando-ha-efeito.test.ts`, que varre o AST de **toda** rota de `app/api/v1/cron/` - Audit é append-only, e isso é do SCHEMA e não da prosa: nenhum papel tem GRANT de UPDATE/DELETE em `api_audit_log` — **nem `service_role`**. Para conferir na fonte em vez de acreditar nesta linha: ```bash psql "$SUPABASE_DB_URL" -c "select grantee, privilege_type from information_schema.role_table_grants where table_name='api_audit_log' and privilege_type in ('DELETE','UPDATE','TRUNCATE');" ``` **`TRUNCATE` entra na consulta de propósito, e o resultado não é vazio.** Ele está concedido a `anon`, `authenticated` e `service_role` — resíduo de o dump enumerar os privilégios desta tabela (as demais recebem `GRANT ALL`, e quem as protege é a RLS). Uma sonda que pergunte só por `DELETE`/`UPDATE` devolve zero linhas e deixa quem leu concluindo que a tabela não pode ser esvaziada, quando o privilégio que a esvazia INTEIRA está lá. Não é alcançável pela REST (o PostgREST não emite `TRUNCATE`), então não é buraco de superfície — mas a frase "append-only é do schema" só é inteira com esta ressalva escrita. - **Retenção default de 5 anos, configurável, e agora EXECUTADA.** O expurgo é `public.fn_expurgar_auditoria_vencida` (`security definer`, **piso de 90 dias dentro do corpo**, revogada de anon/authenticated), chamada em lotes pelo cron `app/api/v1/cron/data-retention` (diário). O knob é `AUDIT_LOG_RETENTION_DAYS`. **Não há camada cold/S3** — o "hot 90 dias, cold (S3) o resto" que este arquivo afirmava por meses nunca existiu em código (auditoria de 2026-08-14: zero ocorrência de arquivamento), e um self-host não tem para onde arquivar: o Storage do cliente é a MESMA cota de 1 GB, já dividida com `whatsapp-media`. Para ver o que está em vigor: `grep -n "RETENCAO_AUDITORIA_DIAS" lib/retencao/politica.ts` - Por que uma `security definer` de expurgo não é porta de adulteração (o argumento inteiro está no cabeçalho da migration 0167): ela **não tem seletor de linha** — nenhum parâmetro de org, ator, ação ou id, e o único predicado é `created_at < now() - N dias`; o piso mora **no corpo**, não em quem chama; não é alcançável pela REST; não amplia o raio de quem já tem a service key; e **registra a própria erosão** (`retention.sweep_run`, com a contagem, numa linha nova demais para a chamada seguinte alcançar) - Falha de write em audit gera alerta Sentry, não bloqueia mutação principal ### LGPD - Anonimização preferida sobre delete. Nome do contato vira `Cliente Anonimizado #N` - Cascade de redact: contact + conversations + messages (mídia removida do storage) + activities (preserva timestamps) - Reversão de anonimização: 403 `lgpd_anonymization_irreversible` - SLA: data_request entregue D+7; redact executado D+15 - Action audit obrigatória: `lgpd.data_request_received`, `lgpd.export_generated`, `lgpd.redact_executed`, `lgpd.consent_changed` ### WAHA - Plus obrigatório (Core não suporta multi-tenant, sem retry, sem S3) - Engine NOWEB default; WEBJS apenas se precisar stickers animados / botões - Auth: env do WAHA recebe **hash SHA512 hex** da api key; cliente envia plaintext em `X-Api-Key` - Webhooks: HMAC SHA512 com `crypto.timingSafeEqual` - Anti-banimento: throttle 1 msg/1.2s + jitter ≤800ms. Campanha 1 msg/5s. Warm-up 7-14d. Spinning de copy. Janela 7h-22h (domingo LIBERADO por default desde 2026-08-20; a janela é knob por canal) - STOP detection: a regra mora em `lib/opt-out/deteccao.ts` e é a MESMA nos dois lados — a ingestão (que grava `is_blocked=true`) e o runtime do agente. **Não é mais a palavra solta:** só bloqueia palavra ISOLADA (mensagem inteira = a palavra) ou verbo de cessação com OBJETO DE COMUNICAÇÃO ("parar de me mandar", "sair da lista"). Enquanto eram duas regras, a ingestão bloqueava paciente que perguntou "tem como parar a dor?" — medido em clínica, 12 falsos positivos num corpus de 32 frases de nicho. Cobre português e espanhol, nos dois níveis (inequívoco e ambíguo) — foi preciso um PR além do #275 (que só tinha coberto o vocabulário inequívoco) para o espanhol ganhar a camada ambígua e as construções com pronome preso ("escribirme"). Para ver o vocabulário em vigor sem confiar nesta linha: `grep -n 'PALAVRAS_DE_OPT_OUT' -A20 lib/opt-out/deteccao.ts`, e as frases de controle em `tests/unit/opt-out-deteccao.test.ts`. - Mídia: subir pro Supabase Storage primeiro, passar URL ao WAHA (não inline base64) - Multi-device: assinar `message.any` (não só `message`); tratar `fromMe=true` sem duplicar - Grupos: SKIP CRM binding se `chatId.endsWith('@g.us')`. Sender é `p.author`, não `p.from` - Cron `recover-stuck-messages` (`app/api/v1/cron/recover-stuck-messages/route.ts`, agendado no `scheduler` do `docker-compose.prod.yml`): marca `status='sending'` há >5min como `failed` **e abre aviso na Central** (`agent_inbox_items` kind `message_send_stuck`). Não toca em `queued`: esse estado tem dono (o agent-engine reagenda por `SEND_QUEUED_RETRY_MS`), e falhá-lo perderia mensagem que ia sair. Não reenvia — envio em dobro é pior que não-envio ### Marca própria (white-label) - **Uma imagem Docker serve todas as marcas.** Nada de `NEXT_PUBLIC_*` para marca, nada de `public/favicon.ico`, nada de imagem por revendedor — a imagem é pré-buildada e o `update.sh` regrava `APP_IMAGE` incondicionalmente - **O banco está ACIMA do `.env`.** `platform_branding` (instalação) e `organizations.settings.branding` (organização) são a fonte; `APP_NAME`/`APP_LOGO_URL`/`APP_ACCENT_HEX` são **semente e piso de rollback** (o `agent.sh` reverte a imagem, nunca o banco) - **Resolvedor NUNCA lança.** `lib/branding/instalacao.ts` e `lib/branding/saida.ts` degradam para o padrão do produto e seguem: `branding()` roda em `app/layout.tsx`, e um throw ali é 500 em todas as telas - **Saída sem DOM usa `marcaDaSaida()`** (`lib/branding/saida.ts`) — e-mail, remetente, ícone, `issuer` do MFA. Um hex e uma frente legível, tema **claro** sempre. Nunca passe `MarcaResolvida` a template de e-mail - **O PDF de LGPD NUNCA leva marca.** Ele nomeia o **controlador** (`organizations.legal_name`) e o DPO resolvido. Nomear ali o revendedor — que é operador — inverteria papéis num documento que responde a direito legal. Vigiado em `tests/unit/mapas-de-arquitetura.test.ts` - Vazamento de marca no código é vigiado por `tests/unit/branding.test.ts` (varre `app|components|lib|workers|hooks`), com allowlist que **só encolhe**. Contexto de venda em [`docs/white-label.md`](docs/white-label.md); mapa em `docs/architecture/marca-propria.architecture.json` ### Doutrina DIRC (antes de adicionar campo) - **D**uplicar — vive aqui mesmo? - **I**ntegrar — vem de outra tabela via FK? - **R**eferenciar — só ponteiro? - **C**alcular — pode ser computado on-demand? ### Modelagem - 5 tabelas core CRM: `crm_pipelines`, `crm_stages`, `crm_leads`, `crm_lead_activities` (polimórfica timeline), `crm_lead_links` (polimórficos vínculos) - `position_in_stage numeric` (fractional indexing via `midpoint()`) — **NUNCA `int`** - `external_id` nullable (mensagem outbound `sending` ainda não tem ID WAHA) - `type` é `text` + `check constraint`, **não enum** (enum é difícil de estender) - **Exceção deliberada — colunas de vocabulário ABERTO:** onde um clone pode ter linhas com valor legado (ex.: `crm_lead_activities.type`), o CHECK **não** entra: a constraint faria o `update.sh` do clone quebrar, e a doutrina de migrations proíbe. Nesses casos o vocabulário vive só no TypeScript, o emissor usa **constante compartilhada, nunca string literal**, e a coluna fica **fora** do invariante `tests/invariants/vocabulario-banco-x-typescript.test.ts` — que cobre apenas colunas que JÁ têm CHECK. Ver o cabeçalho desse arquivo antes de "completar" o schema. - `tags text[]` + GIN index; promove pra coluna gerada apenas quando vira hot path - `custom_fields jsonb` com schema declarativo em `pipeline.settings.fields`; Zod construído dinamicamente - `vocabulary jsonb` em pipeline permite renomear lead/deal/won/lost (e-commerce: lead=Cliente, deal=Pedido, won=Pago, lost=Cancelado) --- ## Anti-patterns proibidos 1. String que deveria ser FK (ex: `owner_email text` em vez de `owner_user_id uuid`) 2. Duplicação sem source of truth declarado 3. Evento sem consumer (emite e ninguém escuta) 4. FK ausente que vira inferência por nome 5. Campo sincronizado por cron quando devia ser realtime/trigger 6. `jsonb` lock-in (UI lê path direto sem schema central) 7. Cascade fantasma (deletar contact cascade em messages perde histórico) 8. Polimórfico sem padronização (`target_kind` cada lugar grava diferente) 9. **Trigger Postgres faz HTTP** (letal — espera rede dentro da transação) 10. Service role usado em request handler sem filtrar `organization_id` manualmente 11. `getSession()` no backend 12. API key em query string 13. Bearer plaintext armazenado no DB (deve ser hash SHA256) 14. `console.log` deixado em código merged (use logger estruturado ou Sentry breadcrumb) --- ## Paths importantes | Path | Conteúdo | |---|---| | `docs/prd/00-prd-master.md` | Visão geral, escopo MVP, KPIs | | `docs/prd/01-prd-platform-base.md` | Auth, tenancy, RBAC, LGPD framework | | `docs/prd/02-...06-` | Customer 360, WhatsApp, Pipeline, IA-RAG, Nuvemshop | | `docs/specs/` | Specs técnicas detalhadas (schema SQL, payloads exatos) | | `docs/business-rules/` | Regras de negócio fora do código | | `docs/research/reference-synthesis.md` | Arquitetura herdada do curso WAHA | | `tasks/todo.md` | Workflow de construção atual | | `app/globals.css` | **Tailwind 4 é CSS-first: não existe `tailwind.config.ts`.** Tokens em `:root` / `[data-theme]`, ponte token → utilitário no `@theme inline`, alcance do scanner nos `@source`. Vigiado por `tests/unit/tailwind-tokens.test.ts` | | `lib/api/wrappers.ts` | `ok()`, `fail()`, tipos `ApiSuccess` / `ApiError` | | `lib/api/errors.ts` | Códigos de erro canônicos | | `lib/env.ts` | Validação Zod das env vars (lança no startup se faltar crítica) | | `lib/supabase/{browser,server,admin}.ts` | Clients canônicos | | `app/api/v1/health/route.ts` | Health check (Supabase + Redis + WAHA) | | `supabase/migrations/` | Schema versionado | | `docs/runbooks/deploy.md` | **Deploy em produção — leia ANTES de mexer na VPS** | --- ## Deploy em produção (NÃO NEGOCIÁVEL) **Numa VPS que já tem proxy reverso próprio (Hostinger, Coolify, Dokploy…), todo `up -d` leva os DOIS arquivos de compose:** ```bash docker compose -f docker-compose.prod.yml -f docker-compose.traefik.yml --env-file .env up -d app ``` Omitir `-f docker-compose.traefik.yml` recria o contêiner sem as labels de roteamento; o Traefik da hospedagem deixa de enxergá-lo e **o domínio inteiro responde `404 page not found`** — com o contêiner `healthy`, porque o healthcheck é um probe TCP interno e não sabe nada de roteamento. Depois de qualquer deploy, confirme que o domínio responde **307** (redireciona pro login) e não 404. Verificações e o caso de build local em `docs/runbooks/deploy.md`. O caminho normal **não constrói nada na VPS**: commit → push → PR → merge na `main` → o CI publica no GHCR → a VPS puxa. Imagem construída na VPS é exceção de emergência e é dívida: existe só naquele disco e qualquer `up -d` sem `APP_PULL_POLICY=never` a substitui em silêncio. Essa frase já foi meia-verdade: valia para o `app` e era falsa para o produto, porque o serviço `worker` não tinha `image:` — era construído na VPS de todo cliente e nunca reconstruído por nenhum `update.sh`. Hoje os três serviços nossos (`app`, `worker`, `scheduler`) são imagens publicadas, e um teste reprova o retorno do padrão. Ver a doutrina abaixo. --- ## Packaging e distribuição — DOUTRINA (NÃO NEGOCIÁVEL) Lei completa em [`docs/doctrine/packaging.md`](docs/doctrine/packaging.md); decisões estruturais e o que foi recusado em [`docs/adr/0001-packaging-e-distribuicao.md`](docs/adr/0001-packaging-e-distribuicao.md). O não-negociável, em quatro linhas: 1. **Nenhum serviço de `docker-compose.prod.yml` constrói na máquina do cliente.** Todo serviço declara `image:` de uma imagem publicada; `build:` só existe **ao lado**, como escape. Serviço `build:`-only é invisível para `docker compose pull` e imune a `up -d` sem `--build` — ele não é só caro de instalar, ele **nunca é atualizado**. 2. **Publicação é ato do CI.** Nunca da sua máquina: build ARM local não roda na VPS amd64 do cliente, e a falha só aparece no `up -d` dele. O job `imagens-ok` reprova quando qualquer uma das três imagens não constrói, e **é status check obrigatório desde 2026-08-13** — a branch protection tem `verify, build-and-size, invariants, e2e, imagens-ok`. (Este parágrafo dizia "ainda não é obrigatório" até 2026-08-14; a ativação era o passo final do merge da doutrina e aconteceu.) Confira na fonte antes de confiar nesta linha. 3. **Instalação de cliente aponta para número de versão, nunca para tag móvel.** `latest` aqui significa **topo da `main`**, não última release — quem quer a última release usa `stable`. `pull_policy` acompanha a mutabilidade da tag: imutável → `missing`, móvel → `always`. 4. **Dependência upstream é referenciada com tag fixa, nunca republicada.** Vale para WAHA (licenciado — republicar é passivo jurídico), Redis, Caddy e `serverless-redis-http`. Bump de versão **não pode** exigir que o operador da VPS edite `.env`, compose ou qualquer arquivo à mão. Se exigir, não entra: vira issue com plano de migração e vai para uma major. --- ## Como rodar local ```bash nvm use # node 22 npm install cp .env.example .env.local # preencher docker compose up -d # WAHA local npm run dev # http://localhost:3000 ``` Ver `README.md` pra detalhes de setup. --- ## Testes ```bash pnpm typecheck # tsc --noEmit (estrito) pnpm lint # eslint next/core-web-vitals pnpm test:unit # Vitest (NÃO inclui tests/invariants/** — ver abaixo) pnpm test:db # Postgres efêmero + baseline install/update + 364 invariantes pnpm test:e2e # Playwright (requer dev server) ``` **⚠️ `test:unit` NÃO é `tests/unit/`.** O script é `vitest run` **sem caminho**, e ele alcança o repositório inteiro — os testes co-localizados em `lib/`, `app/`, `components/` e `hooks/` inclusive. Medido em 2026-08-28: `vitest run` alcança **566 arquivos**; `tests/unit/` tem **388**. Os 178 de fora são 133 em `lib/`, 37 em `app/`, 3 em `components/`, 1 em `hooks/` e 4 em `tests/`. Quem lê o nome do script e roda `vitest run tests/unit` obtém um **verde menor e mais fácil** sem perceber que obteve — e foi o que aconteceu num PR: a suíte foi reportada como verde, e o que estava verde era o recorte. O comando que vale é `pnpm test:unit`, sem caminho. Duas armadilhas irmãs, as duas pagas no mesmo dia: - **Gate escolhido não é suíte.** `typecheck`, `lint`, `lint:channels` e os arquivos de cerca podem estar todos verdes enquanto a suíte tem 17 falhas — nenhum deles toca o arquivo que quebrou. Antes de abrir PR, rode a suíte, não os gates que você lembra. - **Não corte a saída.** `| tail -8` guarda o rodapé e joga fora os NOMES dos arquivos que falharam, que é o único dado que permite reconciliar depois. Redirecione e filtre: ```bash pnpm test:unit > /tmp/vt.log 2>&1; echo "exit=$?" grep -aE "Test Files|Tests " /tmp/vt.log | tail -2 # ← a AUTORIDADE grep -aE "^ *FAIL " /tmp/vt.log | sed 's/ > .*//' | sort | uniq -c # arquivos + contagem ``` **O rodapé é a autoridade; o `grep FAIL` é conveniência — e ele pode devolver vazio COM falhas.** Medido: em execução sem TTY o reporter padrão às vezes imprime só o resumo, e os nomes dos arquivos vermelhos nunca chegam a ser escritos. Uma rodada com `3 failed` produziu um log de 629 bytes onde `FAIL` não aparece em posição nenhuma — e o vazio dessa sonda lê exatamente como "nenhuma falha". Por isso **compare as duas saídas antes de concluir** — e compare a linha certa: `Test Files N failed` conta ARQUIVOS, `Tests N failed` conta CASOS, e o `uniq -c` do `grep` soma CASOS. O controle é contra a segunda linha: ```bash r=$(grep -aE "^ *Tests " /tmp/vt.log | tail -1 | grep -oE "[0-9]+ failed" | head -1) g=$(grep -acE "^ *FAIL " /tmp/vt.log) echo "rodapé: ${r:-0 failed} | grep contou: $g" # têm de bater ``` Se não baterem, a sonda está cega — troque por `--reporter=verbose` e rode de novo, em vez de acreditar no silêncio. (Comparar contra `Test Files` dá divergência falsa: `2 failed` de arquivos contra `7` de casos parece defeito da sonda e é só régua trocada.) **Vermelho local que NÃO é seu:** `lib/ai/dispatcher/rate-limit.test.ts` falha em 5 casos, com 15s de timeout cada, quando o `.env.local` tem `UPSTASH_REDIS_REST_URL`/`TOKEN` e o Redis para o qual eles apontam **não está de pé** (neste repo é o `serverless-redis-http` local, não a nuvem). O `tests/setup/vitest.setup.ts` carrega o `.env.local` para dentro do `process.env`, e o módulo só usa o contador em memória quando essas variáveis estão **ausentes**. Provado nos dois sentidos. No CI não há `UPSTASH` nenhum, então lá o caminho é o contador em memória e o arquivo passa. **Os invariantes não estão no `test:unit`.** `vitest.config.ts` exclui `tests/invariants/**` de propósito: essa suíte precisa de um Postgres real e roda via `vitest.db.config.ts`, orquestrada por `scripts/test-db.sh`. Rodar só `pnpm test:unit` e concluir "está tudo verde" é um falso verde — o isolamento RLS não foi exercitado. Checks **obrigatórios** na branch protection da `main` (verificado na configuração, não só no papel): - **`verify`** (`ci.yml`) — typecheck + lint + test:unit. - **`invariants`** (`ci.yml`) — `pnpm test:db`: sobe `pgvector/pgvector:pg15` — o PISO que dizemos suportar, não a versão mais rica que temos à mão —, aplica `supabase/baseline.sql` em modo install (`ON_ERROR_STOP=1`) e update (idempotência), e roda os testes de invariante, incluindo o de isolamento RLS entre 2 organizações. - **`build-and-size`** (`perf.yml`) — `pnpm build` em Node 22. - **`e2e`** (`e2e.yml`) — sobe Supabase local, aplica o `baseline.sql` e roda **todas as specs Playwright menos as que `FORA_DO_CI` declara**. O número saiu daqui de propósito: ele apodreceu **cinco** vezes (a quinta em 2026-08-24, quando `inbox-quem-manda.spec.ts` entrou), e a condição que o PR #242 pôs para parar de recontar já tinha vencido na quarta. Quem precisa do número roda o comando abaixo — comando não envelhece. Quais ficam de fora, e por quê, é o que a própria variável diz — **não confie nesta linha, leia-a**: ```bash git show origin/main:.github/workflows/e2e.yml | \ python3 -c "import sys,re; y=sys.stdin.read(); print(sorted({s for _,c in re.findall(r'(FORA_DO_CI):\s*>-\n((?:[ ]{8,}.*\n)+)',y) for s in re.findall(r'[a-z0-9-]+\.spec\.ts',c)}))" ``` Esta frase já dizia "a **única** de fora é `vps-fresh-onboarding`" e estava errada: em 2026-09-04 a variável listava **duas** (`inbox-tempo-real` entrou depois). É o mesmo defeito que o parágrafo acima descreve — afirmação de estado que envelhece —, cometido na frase seguinte à que o denuncia. O que continua verdade e é o que importa: `vps-fresh-onboarding` é a **P0** da doutrina de QA Visual, então `e2e` verde **não** prova a jornada de instalação fresca, que é o produto que se vende. **Não confie em `grep` no arquivo inteiro.** `grep -oE '[a-z0-9-]+\.spec\.ts' .github/workflows/e2e.yml | sort -u | wc -l` conta quem é CITADO, não quem é INVOCADO: a `FORA_DO_CI` é uma variável YAML como as outras e entra na conta. (Até 2026-08-14 este parágrafo culpava "menções em comentários", e isso é falso — medido, o conjunto de specs citadas fora de variável é **vazio**.) O que roda são as `SPECS_PARTE_*`: ```bash ls tests/e2e/*.spec.ts | wc -l # quantas existem python3 - <<'PY' # quantas o CI invoca import re y = open(".github/workflows/e2e.yml", encoding="utf-8").read() print(len({s for _, c in re.findall(r'(SPECS_PARTE_\d+):\s*>-\n((?:[ ]{8,}.*\n)+)', y) for s in re.findall(r'[a-z0-9-]+\.spec\.ts', c)})) PY ``` **Por que não há mais número aqui.** O conserto que este parágrafo pedia era pôr a prosa sob gate — `tests/unit/e2e-cobertura-completa.test.ts` cobrando também o texto daqui. Tirar o número é melhor e mais barato: não há o que policiar, e a diferença entre disco e CI segue vigiada onde importa, no próprio teste, que reprova toda spec nova que não esteja em `SPECS_PARTE_*` ou em `FORA_DO_CI` **com motivo escrito**. Prosa que nenhum gate lê é prosa que diverge; prosa que não afirma número não tem como divergir. - **`imagens-ok`** (`publish-image.yml`) — reprova quando qualquer uma das três imagens Docker não constrói. **É obrigatório desde 2026-08-13**; este arquivo dizia o contrário em outro parágrafo (ver a doutrina de packaging acima, já corrigida). Todos os **cinco** são **obrigatórios** — medido em 2026-08-14 na branch protection: ```console $ gh api repos/melgarafael/DeskcommCRM/branches/main/protection --jq '.required_status_checks.contexts|join(", ")' verify, build-and-size, invariants, e2e, imagens-ok ``` Duas correções que este bloco já pagou: o `e2e` entrou para a lista depois de o arquivo ser escrito, e a versão anterior dizia que ele "ainda não é obrigatório"; depois o `imagens-ok` entrou e o arquivo seguiu dizendo "quatro". Uma triagem que leia qualquer uma dessas versões mede contra a régua errada — que é o modo de falha nº 1 do procedimento de triagem. **Reconfira na fonte antes de confiar em qualquer lista aqui**, com o comando acima. Ao mexer em schema, RLS, RBAC, atribuição, escopo, roteamento, follow-up, webhooks ou automações: rode `pnpm test:db` **localmente** antes de abrir PR. É o único caminho que exercita o `baseline.sql` que o self-hoster realmente aplica. --- ## QA Visual com Recursos Reais — DOUTRINA (produto self-host) **O DeskcommCRM é distribuído open-source: a experiência de quem instala numa VPS É o produto.** Toda feature nova (ou fix de comportamento visível) DEVE ser provada como um **usuário leigo a usaria de verdade** — pelo frontend, num ambiente que imita a instalação fresca — antes de "pronto". Não é opcional; é critério de aceite de toda sessão que toca UI ou fluxo de usuário. **O que "recurso real" significa (e o que NÃO conta):** - **Conta.** Prova pela tela, dirigindo o browser (Playwright), logando com conta de teste real. `curl`/chamada de API **não** provam UX — validam o backend, mas não o que o usuário vê, clica e entende. Use curl só como diagnóstico. - **Banco fresco estilo VPS.** Postgres limpo aplicado do `supabase/baseline.sql` (não das `migrations/` — a cadeia fresh não sobe) + `scripts/bootstrap-owner.ts` (o que o `install.sh` faz). O ambiente do teste = o que o clone recém-instalado tem: sem os seus dados, sem os seus envs opcionais. - **Dependências como na VPS.** WAHA local, Redis local (`redis` + `serverless-redis-http`), cron drain via endpoint. E **teste com os envs opcionais AUSENTES** (ex.: sem `RESEND_API_KEY`) — é o estado real de um primeiro deploy, e é onde moram os piores bugs de primeira impressão. - **Efeito colateral externo provado com receiver real.** Webhook outbound, envio — suba um receiver HTTP de verdade e prove o que chegou (ou que foi barrado). Mock não estressa o egress real (anti-SSRF, projeção de payload, https em prod). **Prioridade: primeira impressão acima de tudo.** Onboarding e as primeiras ações (criar conta, conectar canal, primeiro lead, primeiro convite) são a primeira impressão do usuário — bug ali é abandono. Teste esses caminhos primeiro e com o maior rigor. **Registro obrigatório (senão o progresso é invisível):** - Mapa de jornadas vivo em `docs/testing/user-journey-map.md` — casos por jornada, prioridade (`[P0]` primeira impressão), e achados. Atualize quando adicionar cobertura ou achar bug. - Specs em `tests/e2e/*.spec.ts` que dirigem o **frontend** (não só API). Evidência visual (screenshot/trace) em `.superpowers/evidence/`. - Bug achado executando → **conserta na causa raiz**, com migration versionada se tocar schema (ver doutrina abaixo), commit próprio, e re-teste verde como prova. **Medidas de front-end por ferramenta, nunca a olho** (`getBoundingClientRect`/`getComputedStyle` no Playwright). Ver `feedback_protocolo_execucao_visivel` na memória. **Receita de ambiente fresco (não-óbvia):** banco = `baseline.sql` num Supabase local **pg15** (`config.toml major_version = 15`). Já foi pg17, por causa de 9 `GRANT MAINTAIN` que o `pg_dump` emitiu sozinho; hoje quem guarda o piso é `tests/unit/baseline-no-piso-do-postgres.test.ts`; `next build` + `next start` (produção — `next dev` compila lento demais e o Turbopack quebra `cookies()`); **worktree com `node_modules` real, nunca symlink** (Turbopack rejeita symlink "out of filesystem root") e **fora de `/tmp`** (é limpo no meio da sessão — commite cada marco). Detalhes em [[project_invite_e2e_and_bugs]]. --- ## Higiene de branches — DOUTRINA (NÃO NEGOCIÁVEL) **`main` é produção e é a fonte da verdade. Toda branch começa e se mantém atualizada com a `main`.** Trabalho iniciado numa branch atrasada gera conflito e retrabalho — é a causa número um de "cagada" em ambiente multi-sessão. Regra: 1. **ANTES de começar QUALQUER trabalho numa branch, atualize-a com a `main`:** `git fetch origin && git merge origin/main` (traz produção pra dentro). Se a branch ainda não tem commits próprios, é fast-forward puro (`git merge --ff-only origin/main`). Não codar antes disso. 2. **NUNCA `reset --hard`/force pra "atualizar"** — apaga trabalho. Só dois caminhos: **fast-forward** (branch sem commits próprios) ou **merge da `main` pra dentro** (preserva os dois lados). `main` nunca é reescrita. 3. **NUNCA toque numa branch/worktree com working tree sujo que não é seu.** Antes de atualizar qualquer branch, cheque `git status` e `git worktree list` — se está suja e é de outra sessão, **deixe quieto** e avise. Merge só entra em árvore limpa. 4. **Quando uma feature entra na `main`, todas as outras branches ficam atrasadas na hora.** Quem for retomar qualquer uma delas aplica a regra 1 primeiro. Ao fim de uma feature, considere propagar a `main` para as branches vivas limpas (FF as sem trabalho próprio; merge nas divergentes limpas; pular as sujas/conflitantes e reportar). 5. **Conflito ao atualizar = pare e resolva com cabeça** (ou escale), nunca escolha um lado no automático numa branch que não é sua. Preservar trabalho > branch "verde rápido". --- ## Migrations & Banco — DOUTRINA (projeto open-source) **Este projeto é open-source. Toda mudança de schema DEVE sair como migration versionada** — quem clonou uma versão antiga do banco precisa conseguir atualizar aplicando as migrations em ordem. **Nunca** aplique `ALTER`/`CREATE` solto no banco sem o arquivo correspondente. Isto é critério de aceite de TODA sessão, não opcional. Processo padrão (siga sempre): 1. **Arquivo versionado** em `supabase/migrations/` com o padrão do repo: `__.sql` (ex.: `20260706210000_0027_whatsapp_conversation_unification.sql`). `NNNN` é o próximo número sequencial (veja o último em `ls supabase/migrations/`). 2. **Idempotente sempre que possível**: `add column if not exists`, `create ... if not exists`, `create or replace function`. Uma migration deve poder ser re-aplicada sem quebrar nem duplicar efeito. 3. **Portável em `psql` puro** (clones podem não usar o MCP/CLI Supabase): **sem** `create temporary table ... on commit drop` fora de transação explícita; **sem** `BEGIN`/`COMMIT` explícito (o runner já envolve em transação, como as demais migrations). Prefira CTEs, subqueries de janela e colunas-mapa (ex.: `is_merged_into`) a temp tables. 4. **Data migrations genéricas**: se a migration corrige/deduplica dados, escreva pensando em QUALQUER banco de clone (não hardcode IDs do seu tenant). Repointe FKs conferindo o catálogo (`information_schema` FK map) para não perder histórico. 5. **Registre no MANIFEST**: adicione uma linha em `supabase/migrations/MANIFEST.md` (tabela "Applied") descrevendo versão, nome e o QUÊ/PORQUÊ. 6. **Reflita no `supabase/baseline.sql` (OBRIGATÓRIO — é o que o kit self-host aplica).** O baseline é um dump `--schema-only` + um **apêndice idempotente** no fim do arquivo (blocos rotulados `-- ---- (migration NNNN) ----`). O kit HostGator aplica **só o baseline.sql**, tanto no `install.sh` (banco novo, `ON_ERROR_STOP=1`) quanto no `update.sh` (re-aplica em banco existente, **sem** `ON_ERROR_STOP`). Então toda mudança de schema pós-snapshot DEVE ser acrescentada ao apêndice, **idempotente e auto-curativa**: `add column if not exists`, `create ... if not exists`, `create or replace function`, e — se a mudança adiciona constraint — **deduplicar/corrigir os dados ANTES** de criar a constraint (senão o `update.sh` de um clone bugado quebra). Sem isto, clones não recebem a mudança (ou quebram ao atualizar). Migração adicionada só em `migrations/` mas não no baseline **não chega aos self-hosters**. 7. **Aplique e prove**: aplique via `mcp__plugin_supabase_supabase__apply_migration` (ou `supabase db push`), capture o estado ANTES/DEPOIS e prove invariantes (ex.: contagem de linhas que não pode mudar). Se mexeu em contrato, regenere `lib/database.types.ts`. Para mudanças de schema no kit, valide o baseline num Postgres descartável (`pgvector/pgvector:pg15` + extensões) aplicando `install` (fresh, `ON_ERROR_STOP=1`) e `update` (re-aplicar, sem a flag) — ambos têm que passar. 8. **Backfill de dados quebrados existentes**: constraint nova falha se os dados atuais a violam — a migration (e o apêndice do baseline) deve deduplicar/corrigir ANTES de criar a constraint. 9. **Função nova em `public` nasce EXPOSTA — revogue as DUAS origens.** Toda `create function` no schema `public` termina com: ```sql revoke execute on function public.fn_x(...) from public, anon; grant execute on function public.fn_x(...) to ; ``` São duas origens distintas de `EXECUTE`, e tratar só uma deixa a função exposta com o gate verde: **(A)** o grant direto a `anon` do `ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon` do baseline, que vale para toda função criada depois dele — isto é, para todo apêndice novo — e que `revoke from public` **não** remove; **(B)** o grant a `PUBLIC` que o Postgres dá a qualquer função ao criá-la, que `revoke from anon` **não** remove. Sem os dois, o PostgREST expõe a função como RPC alcançável pela anon key, que vai para o browser. Vigiado por `tests/invariants/hardening-definer-varredura.test.ts`, que varre todas as `security definer` de `public` (issue #128 — a versão anterior checava uma lista fixa de 6, e 8 de 25 estavam expostas). **Resumo do fluxo de uma mudança de schema:** arquivo em `migrations/` (fonte da verdade p/ Supabase CLI) **+** apêndice idempotente no `baseline.sql` (p/ o kit self-host) **+** linha no MANIFEST. Os dois artefatos de schema andam juntos. Nunca edite migrations já aplicadas — corrija com uma "forward-fix" nova (e mais um apêndice no baseline). --- ## Skills relevantes a usar (Claude Code) - `superpowers:brainstorming` — antes de implementar feature não-trivial - `superpowers:writing-plans` — pra task com mais de 1 etapa de DB/API - `superpowers:test-driven-development` — feature crítica (LGPD, RLS, anti-banimento) - `superpowers:systematic-debugging` — bugs reportados - `superpowers:verification-before-completion` — antes de declarar "pronto" - `tomik-db-doctrine` — referência cruzada de doutrina de schema - `supabase:supabase` — qualquer task com Supabase - `vercel:nextjs` — App Router, Server Components, edge runtime - `vercel:ai-gateway` — config de fallback de provider - `frontend-design` — UI distinta (não cair em shadcn-default genérico) --- ## Definition of Done Antes de declarar uma task pronta: 1. `npm run typecheck` passa zerado 2. `npm run lint` zerado 3. Testes unit/e2e relevantes existem e passam 4. RLS testada se feature toca tabela tenant-aware 5. Audit log emitido se há mutação relevante 6. Rate limit aplicado se rota é pública 7. Zod valida todo input externo 8. Sem `console.log` esquecido 9. Env vars novas adicionadas em `.env.example` + `lib/env.ts` 10. Doc atualizada se mudou contrato (PRD/spec) 11. **Mudança de schema saiu como migration versionada + linha no MANIFEST** (ver Doutrina de Migrations) — clones conseguem atualizar 12. **Se tocou UI/fluxo de usuário: provado pela tela como um leigo faria**, em ambiente fresco estilo VPS, com evidência visual (ver Doutrina de QA Visual com Recursos Reais) — curl não conta 13. **Living System Checklist respondido** (lei em `docs/doctrine/sistema-vivo.md`; racional no manual `docs/doctrine/sistema-vivo/`) — a feature não é ilha: tem entrada + saída, emite atividade/log, aparece na tela, tem porta na navegação, tem mecanismo anti-morte, **declara seu laço de retorno** (invariante 7 — o que muda no sistema quando ela erra), e o mapa vivo (`docs/architecture/`) reflete peça nova com ≥2 arestas. Resposta que não **nomeia o artefato concreto** (consumidor real, tela real, log real) não conta 14. **Tela nova tem porta** — declarada em `lib/navigation/registry.ts` com seu grupo, ou na allowlist de `tests/unit/navegacao-completude.test.ts` **com justificativa escrita**. Ter tela e ser alcançável são coisas diferentes: o CI reprova tela que existe mas em que só se chega digitando a URL 15. **Se tocou Dockerfile, compose ou setup kit: a mudança chega a quem já instalou** (lei em `docs/doctrine/packaging.md`) — nenhum serviço de produção ficou `build:`-only; variável nova tem default que não quebra `.env` antigo; a atualização não pede edição manual de arquivo; e, se mudou o que a imagem contém, o `update.sh` alcança essa peça. Rode `pnpm test:shell` — é o único gate que exercita o kit 16. **Se o PR muda comportamento, procure a afirmação de estado sobre esse comportamento.** Só sobre o que você mudou, e só nos documentos de autoridade — não saia caçando pelo repo. A documentação afirma como o mundo *está*, e uma auditoria de 2026-08-14 achou **227 afirmações desatualizadas em 393 medidas** ([`docs/audits/2026-08-14-afirmacoes-de-estado.md`](docs/audits/2026-08-14-afirmacoes-de-estado.md)). Onde a afirmação puder virar **comando**, troque em vez de corrigir: um número corrigido envelhece de novo; um `rode isto para saber` não envelhece nunca 17. **Se o PR muda comportamento visível a quem opera uma VPS, ele traz o seu fragmento em `.changes/`** (lei em [`docs/doctrine/versionamento.md`](docs/doctrine/versionamento.md)). O fragmento declara **o efeito no operador** — `nada_mudou` / `capacidade_nova` / `exige_acao` —, nunca o número: o número é calculado a partir do conjunto, e é por isso que duas sessões paralelas não colidem mais. Confira com `pnpm release:conferir`. O CI valida a FORMA de todo fragmento, mas **não** cobra a presença de um — cobrar presença num check obrigatório reprovaria PR de Dependabot, PR de fork, e o próprio PR de release, que consome os fragmentos e deixa o diretório vazio. A presença é cobrada aqui, e por quem revisa. Um staff engineer aprovaria? Se não, itera.