Destrava o #1747 (706 commits atras), a pedido do dono. - A migration da onda 3 sai de 0433 (20260926210000) para 0500 (20260930170000): estava fora de ordem, com a main ate a 0499. Arquivo, rotulos do baseline, linha do MANIFEST (depois da 0499), testes e comentarios. - A lista do CHECK de agent_inbox_items.kind na migration ganha os cinco kinds da proposta (0464/0466/0475): rodando depois delas, a lista antiga os apagaria. Igual a do baseline. - Conflitos: baseline partiu da main com o delta do PR reaplicado (kinds na lista unica; apendice antes da VARREDURA anon); InboxKind, rotulos e destino da Central ficam com os kinds do Jev e os da proposta; mapa vivo com as arestas da main (e121-e123) e as do Jev renumeradas e124-e129; selo das traducoes do white-label regravado sobre o original mesclado; e2e.yml com os dois comentarios. O caminho da migration dentro de tests/invariants/jev-aviso-fecha-sozinho-e-e-unico.test.ts muda no commit seguinte, separado: o guard freeze-invariants barra edicao de invariante dentro da resolucao.
63 KiB
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— mesmo contrato em forma portável (para Codex/Cursor/Copilot e afins). É derivado deste arquivo, não o substitui. Ao mudar doutrina aqui, verifique seAGENTS.mddesatualizou.docs/index.md— índice dos 149 docs, com regra de precedência quando dois docs discordam. Use antes de sair varrendodocs/.docs/current-state.md— o que está pronto, incompleto e quebrado. Leia antes de estimar ou prometer qualquer coisa.docs/harness-audit.md— onde a verificação tem buraco. Importante:pnpm gov:verifynão cobretest:dbnemtest:e2e— verde ali não é prova para mudança de schema ou de UI.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_logtable + 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-mediaprivado, URLs assinadas) - WhatsApp: WAHA Plus, engine NOWEB
- Filas/eventos:
event_logtable + workers (não usar Inngest/Trigger no MVP) - Rate limit: Upstash Redis sliding window
- AI: Vercel AI Gateway (Anthropic primário; embeddings pela OpenAI ou, por escolha da organização, pelo Google —
lib/ai/embeddings/chave.ts); strings tipo"anthropic/claude-sonnet-4-6" - Validação: Zod em todo input externo (request body, webhook payload, env)
- Observability: Sentry com
beforeSendsanitizado
Convenções críticas (NÃO NEGOCIÁVEIS)
Multi-tenancy
organization_id uuid not null references organizations(id) on delete cascadeem toda tabela tenant-aware- RLS policy
tenant_isolation_<tabela>_allaplicada via helperfn_user_org_ids() - Service role bypassa RLS — handlers que usam admin client DEVEM filtrar
organization_idmanualmente, resolvido de fonte confiável (cookie/JWT/webhook secret/path token), NUNCA do body - Toda query que cruza tabelas tenant-aware filtra
organization_idexplicitamente - 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)+ capturacode === '23505'no INSERT - POSTs de criação na API aceitam header
Idempotency-Key: <uuid>(TTL 24h). O recibo mora no Postgres (public.idempotency_keys, único por organização + chave + endpoint), não no Upstash — verlib/api/idempotency.ts. Quais rotas leem o header:grep -rln 'Idempotency-Key' app/api/v1 --include='route.ts' - 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+currencyISO-4217 - Wrapper sucesso:
{ data, meta?: { cursor, has_more, total } } - Wrapper erro:
{ error: { code, message, details? } }— usar helpersok()/fail()delib/api/wrappers.ts - Paginação: cursor opaco base64+HMAC por default
- Auth dual é a direção do produto, e ela se cumpre rota por rota. Cookie de sessão para o
frontend;
Authorization: Bearer dsk_...(linha deapi_tokens, resolvida no servidor) para chamada de servidor. O prefixo édsk_, e quem o exige élib/mcp/auth.ts—tok_nunca existiu no código e estava escrito aqui, emAGENTS.mde na Spec 09 até 17/09/2026- O helper é
lib/api/auth-dual.ts, e habilitar uma rota é por rota: não há chave geral. Para saber quais já aceitam bearer — o número muda, o comando não:git grep -ln "auth-dual" -- app/api/v1(maisapp/api/v1/contacts/route.ts, que implementou o padrão inline e deu origem ao helper) - Chamar o helper na rota não basta: o
proxy.tsglobal roda antes de qualquer handler e só reconhece cookie. Sem uma entrada emlib/auth/public-paths.tspara o caminho, todo bearer recebe 401 do proxy antes de chegar ao handler. "Público" ali quer dizer "o proxy não decide", nunca "sem autenticação" - Decisão do dono do produto em 17/09/2026: converter as rotas que cada integração precisar, conforme aparecerem, em vez de namespace paralelo por cliente. Uma rota convertida serve a todo integrador. Contexto: PR #1008, que escreveu 26 rotas paralelas porque não achou por onde entrar
- O helper é
- 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-Afterem 429 X-Request-Idem toda response (correlaciona com audit log)
Auth & RBAC
- Sempre
getUser()(valida JWT no backend). NUNCAgetSession()(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) eorganizations.settings.security.mfa_required(para oadmindo tenant). O padrão de ambas é não exigir, e obootstrap-owner.tsgravafalseexplícito. Regra pura emlib/auth/politica-mfa.ts- Por que mudou: o gate era
isPlatformAdmin || role === "admin", sem opção, e oinstall.shcria 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 403mfa_requireddas 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)
- Por que mudou: o gate era
- Permissão por pipeline (
user_pipeline_access) NÃO entra no MVP - Suporte temporário: todo handler mutante de
app/api/v1declararequireSupportWrite(delib/impersonate/support.tsantes do efeito. É guarda de efeito, não de papel — não substituirequireRole/RBAC/MFA — e é cobrada pelo gatetests/unit/suporte-cobertura-de-efeitos.test.ts
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 o extintoattendant-heartbeat(1×/5min, removido no #720) 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 portests/unit/cron-audita-so-quando-ha-efeito.test.ts, que varre o AST de toda rota deapp/api/v1/cron/ -
Audit é append-only para os papéis do PostgREST, e isso é do SCHEMA e não da prosa:
anon,authenticatedeservice_rolenão têm GRANT de UPDATE, DELETE nem TRUNCATE emapi_audit_log— nemservice_role(migration 0258). O dono (postgres) pode tudo, como em qualquer tabela: a garantia é sobre os papéis que o PostgREST assume, nunca absoluta. Para conferir na fonte em vez de acreditar nesta linha:psql "$SUPABASE_DB_URL" -c "select grantee, privilege_type from information_schema.role_table_grants where table_schema='public' and table_name='api_audit_log' and privilege_type in ('DELETE','UPDATE','TRUNCATE') and grantee in ('anon','authenticated','service_role','PUBLIC');"O resultado esperado é vazio. Sem o filtro de
granteeaparecem as linhas do donopostgres— e elas não são defeito.Até a 0258 a primeira frase deste item era falsa no Supabase real, com o gate verde. Todo projeto Supabase nasce com um default ACL de TABELAS em
publicque concede tudo aos três papéis — confira comselect defaclacl from pg_default_acl where defaclobjtype = 'r' and defaclnamespace = 'public'::regnamespace;—, e oGRANTenumerado que o dump emite para esta tabela só ACRESCENTA, não retira. Com a service key, que ignora RLS, uma linha escolhida da auditoria era apagada ou reescrita pela REST;anon/authenticatedsó não o faziam porque a RLS não tem policy de UPDATE/DELETE.A lição que sobrevive ao conserto é sobre a régua, não sobre o grant. A sonda de
tests/invariants/retencao-poda-e-expurgo.test.tsficou verde duas vezes medindo o universo errado: primeiro perguntando só por DELETE/UPDATE com TRUNCATE concedido ao lado; depois perguntando pelos três num Postgres onde o prelude descripts/test-db.shreproduzia o default ACL do Supabase só para FUNÇÕES — um banco onde o defeito não podia existir. Desde a issue #887 o prelude reproduz também o de TABELAS, e aquela sonda passou a medir o Supabase. A prova com controle próprio segue sendotests/invariants/audit-log-sob-o-default-acl-do-supabase.test.ts: concede o default ACL à tabela, reaplica o bloco da 0258 extraído do baseline e só então sonda. Enumerar privilégios no dump não protege tabela nenhuma no Supabase real; o que protege érevokeexplícito no apêndice. Quais tabelas o dump enumera em vez deGRANT ALL:grep -nE '^GRANT [A-Z,]+ ON TABLE' supabase/baseline.sql | grep -v 'GRANT ALL' -
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 cronapp/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 comwhatsapp-media. Para ver o que está em vigor:grep -n "RETENCAO_AUDITORIA_DIAS" lib/retencao/politica.ts -
Por que uma
security definerde 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; é, desde a 0258, o único apagamento de auditoria ao alcance da service key — e não escolhe linha, só alcança a ponta mais velha que o piso; 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
- Imagem NOWEB pinada por arquitetura:
latest-2026.7.2em x86 enoweb-arm-2026.7.2em ARM64. A prova local criou duas sessões CORE simultâneas atéSCAN_QR_CODE; não prova pairing, duas contasWORKINGnem envio. Não bloquear segunda sessão por tier: conferir resposta estruturada e pós-condição da operação. - 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 de disparo 7h-22h (domingo LIBERADO por default desde 2026-08-20; a janela é knob por canal). Janela de RESPOSTA por canal (0495,
channel_knobs.resposta_*), que herda a de disparo quando vazia — sóinbound_turn/case_reply_turna leem - STOP detection: a regra mora em
lib/opt-out/deteccao.tse é a MESMA nos dois lados — a ingestão (que gravais_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:sed -n '/PALAVRAS_DE_OPT_OUT/,/^]/p' lib/opt-out/deteccao.ts | grep -E '^ *"', e as frases de controle emtests/unit/opt-out-deteccao.test.ts. O Jev (lib/ai/decisao/pedidos.ts) só é perguntado onde esta regra disse não, e nunca bloqueia ninguém — no máximo abre um aviso na Central ("Avisar a equipe"): a regra continua a única que bloqueia (cerca:tests/unit/jev-nunca-cala-bloqueia-nem-responde.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); tratarfromMe=truesem duplicar - Grupos: entram só os ligados em Conexões › Grupos (
channel_session_groups). O grupo ligado vira conversais_groupcom um contatokind = 'whatsapp_group'que nunca entra em funil, lista, campanha ou IA; o remetente ép.author(nuncap.from), gravado emmessages.metadata.group_sender(lib/messaging/remetente-de-grupo.ts). Para conversa de grupo o banco emitemessage.group_received, e nãomessage.received, e o roteamento automático pula grupo. O filtroignore.groupsdo WAHA é propriedade desta funcionalidade (definirRecebimentoDeGrupos); compatibilidade e convergência não o tocam. Spec:docs/superpowers/specs/2026-09-23-grupos-na-inbox-design.md - Cron
recover-stuck-messages(app/api/v1/cron/recover-stuck-messages/route.ts, agendado noschedulerdodocker-compose.prod.yml): marcastatus='sending'há >5min comofailede abre aviso na Central (agent_inbox_itemskindmessage_send_stuck). Não toca emqueued: esse estado tem dono (o agent-engine reagenda porSEND_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 depublic/favicon.ico, nada de imagem por revendedor — a imagem é pré-buildada e oupdate.shregravaAPP_IMAGEincondicionalmente - O banco está ACIMA do
.env.platform_branding(instalação) eorganizations.settings.branding(organização) são a fonte;APP_NAME/APP_LOGO_URL/APP_ACCENT_HEXsão semente e piso de rollback (oagent.shreverte a imagem, nunca o banco) - Resolvedor NUNCA lança.
lib/branding/instalacao.tselib/branding/saida.tsdegradam para o padrão do produto e seguem:branding()roda emapp/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,issuerdo MFA. Um hex e uma frente legível, tema claro sempre. Nunca passeMarcaResolvidaa 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 emtests/unit/mapas-de-arquitetura.test.ts - Vazamento de marca no código é vigiado por
tests/unit/branding.test.ts(varreapp|components|lib|workers|hooks), com allowlist que só encolhe. Contexto de venda emdocs/white-label.md; mapa emdocs/architecture/marca-propria.architecture.json
Doutrina DIRC (antes de adicionar campo)
- Duplicar — vive aqui mesmo?
- Integrar — vem de outra tabela via FK?
- Referenciar — só ponteiro?
- Calcular — 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 viamidpoint()) — NUNCAintexternal_idnullable (mensagem outboundsendingainda 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 oupdate.shdo 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 invariantetests/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.
- Exceção deliberada — colunas de vocabulário ABERTO: onde um clone pode ter linhas com valor
legado (ex.:
tags text[]+ GIN index; promove pra coluna gerada apenas quando vira hot pathcustom_fields jsonbcom schema declarativo empipeline.settings.fields; Zod construído dinamicamentevocabulary jsonbem pipeline permite renomear lead/deal/won/lost (e-commerce: lead=Cliente, deal=Pedido, won=Pago, lost=Cancelado)
Anti-patterns proibidos
- String que deveria ser FK (ex:
owner_email textem vez deowner_user_id uuid) - Duplicação sem source of truth declarado
- Evento sem consumer (emite e ninguém escuta)
- FK ausente que vira inferência por nome
- Campo sincronizado por cron quando devia ser realtime/trigger
jsonblock-in (UI lê path direto sem schema central)- Cascade fantasma (deletar contact cascade em messages perde histórico)
- Polimórfico sem padronização (
target_kindcada lugar grava diferente) - Trigger Postgres faz HTTP (letal — espera rede dentro da transação)
- Service role usado em request handler sem filtrar
organization_idmanualmente getSession()no backend- API key em query string
- Bearer plaintext armazenado no DB (deve ser hash SHA256)
console.logdeixado 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<T> / 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:
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;
decisões estruturais e o que foi recusado em
docs/adr/0001-packaging-e-distribuicao.md.
O não-negociável, em quatro linhas:
- Nenhum serviço de
docker-compose.prod.ymlconstrói na máquina do cliente. Todo serviço declaraimage:de uma imagem publicada;build:só existe ao lado, como escape. Serviçobuild:-only é invisível paradocker compose pulle imune aup -dsem--build— ele não é só caro de instalar, ele nunca é atualizado. - Publicação é ato do CI. Nunca da sua máquina: o CI publica as imagens
nativas para linux/amd64 e linux/arm64, e a falha de arquitetura precisa
aparecer antes do
up -ddo cliente. O jobimagens-okreprova quando qualquer uma das três imagens não constrói, e é status check obrigatório desde 2026-08-13 — a branch protection temverify, 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. - Instalação de cliente aponta para número de versão, nunca para tag móvel.
latestaqui significa topo damain, não última release — quem quer a última release usastable.pull_policyacompanha a mutabilidade da tag: imutável →missing, móvel →always. - 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.
Extensões — DOUTRINA (NÃO NEGOCIÁVEL)
Lei completa em docs/doctrine/extensoes.md; o
contrato que existe hoje em
docs/specs/extensoes-declarativas-v1.md.
A pergunta que decide o destino de uma mudança não é "isto serve a muita gente?",
e sim "se nenhuma organização ativar isto, a operação comum continua inteira?".
O não-negociável:
- O núcleo continua útil com zero extensões. Identidade, autorização, isolamento, auditoria, contratos e cadeia de envio são núcleo; jornada de nicho, aparência e integração com dados e manutenção próprios podem ser extensão.
- Extensão pede capacidade nomeada; não importa código interno nem lê o banco. Instalar não concede autoridade: toda escrita revalida ator, organização e papel atuais no banco.
- A instância decide o pacote; a organização decide o uso. Instalar, atualizar, desfazer e remover são do administrador da instalação; ativar e configurar, do administrador da organização. A plataforma não reativa decisão da organização.
- Toda operação é recibo idempotente com saída pela tela, e toda troca de ponteiro exige a revisão que a tela viu. Tirar é lógico e preserva dados.
- Não anunciar o que não existe (SDK, código isolado, marketplace público), e não extrair do núcleo recurso já distribuído sem equivalência e migração.
- Módulo oficial com dados não põe tabela no baseline para todos
(ADR-0002, aceita em 17/09/2026). Um banco
só, schema
public; as tabelas nascem por função provisionadora fixa do módulo, quando ele é instalado na instância. Ninguém opera segundo banco — é decisão do dono, e seria impossível com chave estrangeira para o núcleo.
Como rodar local
nvm use # node 22
pnpm install # o gerenciador é pnpm (packageManager no package.json), não npm
cp .env.example .env.local # preencher
docker compose up -d # WAHA local
pnpm dev # http://localhost:3000
pnpm worker # agent-worker (processo separado do Next)
pnpm dev:crons # chama localmente só os crons de PATHS em scripts/dev-crons.ts (não todos)
Schema: aplique supabase/baseline.sql, não as migrations (a cadeia não sobe do zero).
Stack local completa (Supabase via scripts/local-supabase.sh; app, worker, scheduler, WAHA e Redis via docker-compose.local.yml), depois de rodar
./ubuntu-local-installer.sh uma vez: pnpm local:up | local:down | local:status | local:logs.
Ver README.md pra detalhes de setup.
Testes
pnpm typecheck # tsc --noEmit -p tsconfig.typecheck.json (inclui tests/)
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)
pnpm gov:verify # typecheck + lint + lint:channels + lint:role-rank + test:unit
pnpm cercas # só as cercas estruturais (projeto vitest "cercas")
pnpm test:journeys # Playwright com tests/journeys/playwright.config.ts
pnpm format:check # prettier
Um teste só:
pnpm vitest run lib/foo/bar.test.ts # um arquivo unit
pnpm vitest run -t "nome do caso" # um caso pelo nome
pnpm test:db tests/invariants/x.test.ts # um invariante (o script repassa os args ao vitest)
pnpm playwright test tests/e2e/x.spec.ts # uma spec e2e
⚠️ 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:channelse 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 -8guarda o rodapé e joga fora os NOMES dos arquivos que falharam, que é o único dado que permite reconciliar depois. Redirecione e filtre: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 + contagemO 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 com3 failedproduziu um log de 629 bytes ondeFAILnã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 failedconta ARQUIVOS,Tests N failedconta CASOS, e ouniq -cdogrepsoma CASOS. O controle é contra a segunda linha: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 baterSe não baterem, antes de diagnosticar "sonda cega" e re-rodar a suíte inteira com
--reporter=verbose, confira se a divergência é explicada por falha de coleta ou de hook (que o Vitest imprime na seção dedicadaFailed Suites, somando às linhasFAILsem entrar no rodapé de casosTests ... failed):grep -aoE "Failed Suites [0-9]+" /tmp/vt.log | grep -oE "[0-9]+" # > 0 ⇒ arquivo/suíte falhou SEM ser por caso grep -aqE "^ *Test Files" /tmp/vt.log && echo "log inteiro" || echo "log truncado — o zero não vale"O segundo comando é necessário: a seção só aparece quando existe falha de suíte, então log truncado ou comando que não rodou também devolvem zero. Se
Failed Suitesfor > 0 (e o log estiver inteiro), a conta fecha:Tests failed+Failed Suites=grep FAIL. A causa (falha de sintaxe na coleta ouHook timed outembeforeAll) está no próprio log. Reserve o diagnóstico de "sonda cega" (trocar por--reporter=verbose) para quando as seções também não explicarem a divergência.E as duas podem bater em zero com a suíte reprovada. O Vitest sai com
exit=1quando há erro não tratado durante a execução, mesmo com todos os testes passando — e esse erro aparece numa TERCEIRA linha do rodapé, que nenhuma das duas sondas acima lê:Test Files 866 passed (866) Tests 8904 passed | 1 expected fail (8905) Errors 1 error ← só o exit code viu istoMedido em 2026-09-15:
EnvironmentTeardownError: [vitest-worker]: Closing rpc while "onUserConsoleLog" was pending, sob carga. Rodapé0 failed,grep FAILvazio, exit 1. O exit code é a autoridade; o rodapé e o grep são explicação dele. Quando o exit diverge dos dois, leia a linhaErrorsantes de concluir qualquer coisa:grep -aE "^ *Errors " /tmp/vt.log # vazio é o esperado ``` (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) — job de fachada: ele não roda suíte nenhuma; reprova quando a matrizinvariants-majorsnão fecha emsuccess. Quem roda é a matriz, uma perna por major do Postgres que o produto diz suportar, e cada perna faz duas passadas:pnpm test:db(baseline em modo install comON_ERROR_STOP=1e update, mais os invariantes, incluindo o isolamento RLS entre 2 organizações) epnpm test:db:update(atualização de um banco COM dados). Para saber quais majors hoje, pergunte ao arquivo em vez de a esta linha:awk '/^ invariants-majors:/,/^ [a-z-]+:/' .github/workflows/ci.yml | grep -A6 'matrix:'. -
build-and-size(perf.yml) —pnpm buildem Node 22. -
e2e(e2e.yml) — sobe Supabase local, aplica obaseline.sqle roda todas as specs Playwright menos as queFORA_DO_CIdeclara — em PR que alcança algo que ele mede. PR só de documentação, teste de outra suíte, fragmento ou workflow alheio pula as partes (regra emscripts/pr-alcanca-o-e2e.sh, na dúvida roda), e ali oe2everde não prova tela nenhuma. O número saiu daqui de propósito: ele apodreceu cinco vezes (a quinta em 2026-08-24, quandoinbox-quem-manda.spec.tsentrou), 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: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á envelheceu TRÊS vezes, e é o parágrafo que denuncia afirmações que envelhecem. Ela dizia "a única de fora é
vps-fresh-onboarding" quando a variável já listava duas (2026-09-04,inbox-tempo-real); depois seguiu dizendo que a jornada de instalação fresca estava sem gate — e em 2026-09-19 o #983 (@webtecnica) pôsvps-fresh-onboarding.spec.tspara rodar no CI, com WAHA e Redis de verdade, então a frase virou o contrário do estado.Por isso ela sai e não volta: a pergunta "a jornada de instalação fresca tem gate?" se responde por comando, com o de cima (o que
FORA_DO_CIdeclara) e com este, que diz quem o CI invoca:git show origin/main:.github/workflows/e2e.yml | python3 -c "import sys,re; y=sys.stdin.read(); print('vps-fresh-onboarding no CI:', 'vps-fresh-onboarding.spec.ts' in {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)})"As duas saídas se fecham uma contra a outra porque
tests/unit/e2e-cobertura-completa.test.tsreprova spec que não esteja nem numaSPECS_PARTE_*nem naFORA_DO_CI: ausência da primeira saída é presença na segunda, e nenhuma spec cai no vão entre as duas.O que não envelhece e é o que importa:
vps-fresh-onboardingé a P0 da doutrina de QA Visual porque a instalação fresca é o produto que se vende. Ter gate não dispensa a prova pela tela (DoD 12) — gate prova que não regrediu, não que a experiência ficou boa. E a ressalva do começo do item continua de pé: em PR que pula as partes, o verde não prova tela nenhuma, a da instalação fresca inclusive.Não confie em
grepno arquivo inteiro.grep -oE '[a-z0-9-]+\.spec\.ts' .github/workflows/e2e.yml | sort -u | wc -lconta quem é CITADO, não quem é INVOCADO: aFORA_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 asSPECS_PARTE_*: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)})) PYPor 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.tscobrando 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 emSPECS_PARTE_*ou emFORA_DO_CIcom 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:
$ 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.
Onde os jobs rodam. A conta tem o plano Pro: até 40 jobs simultâneos nas máquinas do GitHub
(medidos 39 em 18/09/2026, com 180 na fila). Os jobs pesados do trabalho nosso (push na main
e PR de branch deste repositório) podem ir para o executor próprio (infra/executor-proprio/)
quando a variável de repositório EXECUTOR_PROPRIO vale ligado; PR de fork roda sempre no GitHub,
e a publicação da main também. Duas regras que não se negociam:
- A guarda contra fork mora na máquina, não no YAML. Em PR de fork o GitHub roda o workflow do
fork, que pode reescrever
runs-on:. Quem recusa éinfra/executor-proprio/so-o-que-e-nosso.sh, gravado na imagem como hook de entrada do runner. Mudar a expressão deruns-onnão é mudar a segurança — e afrouxar a guarda é. - Imagem que o parque instala nunca se constrói na máquina nossa.
build-and-pushepromover-stableficam emubuntu-latest; os jobs*-sobesó vão para a máquina em PR.
Vigiado por tests/unit/executor-proprio-so-roda-o-que-e-nosso.test.ts. Botão de emergência:
apagar a variável EXECUTOR_PROPRIO — os jobs novos voltam na hora para o GitHub. A fila de merge
(merge queue) do GitHub não está disponível neste repositório (conta pessoal; medido em 18/09/2026:
a regra é recusada com 422 e uma regra comum no mesmo formato é aceita) — a integração em lote da
triagem (triagem/TRIAGEM.md §3-quinquies) é o que cumpre esse papel.
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 dasmigrations/— a cadeia fresh não sobe) +scripts/bootstrap-owner.ts(o que oinstall.shfaz). 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.: semRESEND_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.tsque dirigem o frontend (não só API). Evidência visual (screenshot/trace) emevidence/<entrega>/, que é versionada; nunca em pasta que o.gitignoreignora, senão a prova não sai da sua máquina. - 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:
- 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. - NUNCA
reset --hard/force pra "atualizar" — apaga trabalho. Só dois caminhos: fast-forward (branch sem commits próprios) ou merge damainpra dentro (preserva os dois lados).mainnunca é reescrita. - NUNCA toque numa branch/worktree com working tree sujo que não é seu. Antes de atualizar qualquer branch, cheque
git statusegit worktree list— se está suja e é de outra sessão, deixe quieto e avise. Merge só entra em árvore limpa. - 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 amainpara as branches vivas limpas (FF as sem trabalho próprio; merge nas divergentes limpas; pular as sujas/conflitantes e reportar). - 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):
-
Arquivo versionado em
supabase/migrations/com o padrão do repo:<timestamp>_<NNNN>_<slug>.sql(ex.:20260706210000_0027_whatsapp_conversation_unification.sql).NNNNé o próximo número sequencial — e não é o do último arquivo da listagem:# A POPULAÇÃO da pergunta: main do PRODUTO (o remoto que aponta para # melgarafael/DeskcommCRM, com qualquer nome) + TODO PR ABERTO, inclusive de # fork. O `ls` abaixo mede o DISCO, que responde uma pergunta menor. pnpm checar:colisao-de-migration # declara o que mediu e o que não mediuSe a leitura for manual, três coisas são obrigatórias: um
git fetchantes (a árvore em dia não é a main atual),git ls-treeda main do PRODUTO e nãolsdo disco, e oNNNNtirado com a âncora do nome canônico aplicada ao nome sem a pasta —sed 's#.*/##' | sed -nE 's#^[0-9]{14}_([0-9]{4})_.*#\1#p'. Ols | grep -oE '_[0-9]{4}_'que estava aqui pegava um_NNNN_do SLUG (com…_0326_relatorio_2024_anual.sqlo teto virava 2024) e media a árvore de trabalho, onde a 0336 já podia estar reservada por um PR aberto (#1273).O
checarmede o arquivo que você já acrescentou: sem migration nova, ele respondeOK — nenhuma migration acrescentadae não dá número. Crie o arquivo com um número provisório e rode; ou, para alocar antes, use a enumeração do que está em voo emtriagem/TRIAGEM.md(modo de falha 37). O teto é a main mais tudo em voo, em NNNN e em timestamp.O nome do arquivo começa pelo timestamp, e timestamp e
NNNNpodem discordar: em 09/09/2026 ols | tail -1devolvia o_0230_(timestamp de 07/09) enquanto o maiorNNNNera_0231_(timestamp de 05/09). Um contribuidor externo seguiu a instrução antiga ao pé da letra, escolheu0231, e omanifest-x-migrationsreprovou o PR dele por colisão — a instrução é que estava errada, não ele. Ordene pelo número, nunca pela listagem.E o número livre hoje pode estar tomado quando o seu PR entrar. A colisão só aparece quando o SEGUNDO PR de schema é mesclado — medido em 19/09/2026: 11 PRs abertos colidiam com a
maincom os cinco checks obrigatórios verdes. Overifyjá executa a guarda (pnpm checar:colisao-de-migration, o alias descripts/checar-colisao-de-migration.sh— procurar pelo nome do arquivo noci.ymldevolve zero e mente), e mesmo assim os 12 passaram: cada um mediu amaindo dia em que rodou — overifydo #965 terminou em 16/09 e segue verde. Por isso há duas camadas a mais: o CI reprova quando um número deste PR foi tomado por migration que entrou na base depois da prévia (colisão, nunca atraso — PR atrasado e sem colisão segue verde), e fora depull_requestele varre a árvore inteira — nenhumNNNNnem timestamp pode aparecer duas vezes namain. Antes de escolher o número quando houver outros PRs de schema em voo, peça-o a quem estiver alocando na rodada: não há reserva, quem mescla primeiro fica com o número. Para ver o que está tomado agora, incluindo o que ainda não foi mesclado:pnpm checar:colisao-de-migration # mede o SEU PR contra origin/main -
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. -
Portável em
psqlpuro (clones podem não usar o MCP/CLI Supabase): semcreate temporary table ... on commit dropfora de transação explícita; semBEGIN/COMMITexplí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. -
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_schemaFK map) para não perder histórico. -
Registre no MANIFEST: adicione uma linha em
supabase/migrations/MANIFEST.md(tabela "Applied") descrevendo versão, nome e o QUÊ/PORQUÊ. -
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-- ---- <coisa> (migration NNNN) ----). O kit HostGator aplica só o baseline.sql, tanto noinstall.sh(banco novo,ON_ERROR_STOP=1) quanto noupdate.sh(re-aplica em banco existente, semON_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 oupdate.shde um clone bugado quebra). Sem isto, clones não recebem a mudança (ou quebram ao atualizar). Migração adicionada só emmigrations/mas não no baseline não chega aos self-hosters. -
Aplique e prove: aplique via
mcp__plugin_supabase_supabase__apply_migration(ousupabase db push), capture o estado ANTES/DEPOIS e prove invariantes (ex.: contagem de linhas que não pode mudar). Se mexeu em contrato, regenerelib/database.types.ts. Para mudanças de schema no kit, valide o baseline num Postgres descartável (pgvector/pgvector:pg15+ extensões) aplicandoinstall(fresh,ON_ERROR_STOP=1) eupdate(re-aplicar, sem a flag) — ambos têm que passar. -
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.
-
Função nova em
publicnasce EXPOSTA — revogue as DUAS origens. Todacreate functionno schemapublictermina com:revoke execute on function public.fn_x(...) from public, anon; grant execute on function public.fn_x(...) to <só quem precisa>;São duas origens distintas de
EXECUTE, e tratar só uma deixa a função exposta com o gate verde: (A) o grant direto aanondoALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anondo baseline, que vale para toda função criada depois dele — isto é, para todo apêndice novo — e querevoke from publicnão remove; (B) o grant aPUBLICque o Postgres dá a qualquer função ao criá-la, querevoke from anonnã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 portests/invariants/hardening-definer-varredura.test.ts, que varre todas assecurity definerdepublic(issue #128 — a versão anterior checava uma lista fixa de 6, e 8 de 25 estavam expostas). -
Ler o baseline com
grepno arquivo inteiro mede a definição ERRADA. Obaseline.sqlé dump + apêndice, então a mesma função aparece várias vezes — e quem vale é a última, porque o arquivo é aplicado inteiro e em ordem. Medido em 2026-09-20:fn_meet_actiontinha quatro definições; a primeira (o corpo do dump) ainda traziaerrcode='40001'nas três recusas permanentes, e a última — a que o banco instala — traziaPT409. Uma sonda degrep/awkancorada na primeira ocorrência afirmou sobre o produto o oposto do que o produto faz. O mesmo vale parafn_lgpd_cascade_redact_contact, que tem oito.As duas formas certas, e a primeira decide:
# (a) PERGUNTE AO BANCO, depois de aplicar — é o que o cliente terá pnpm test:db tests/invariants/<um caso que consulte> # ou, num psql já com o baseline aplicado: psql "$URL" -Atc "select pg_get_functiondef(p.oid) from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname='public' and p.proname='fn_x'"# (b) ANCORE NA ÚLTIMA definição, quando só o arquivo estiver à mão python3 -c " s=open('supabase/baseline.sql').read() i=s.rfind('create or replace function public.fn_x') # rfind, nunca find print(s[i:s.index('\$\$;', i)+3])"Contar ocorrências no arquivo inteiro responde "o arquivo menciona", nunca "o banco faz". As duas perguntas divergem sempre que há apêndice — e apêndice é o mecanismo padrão desta casa.
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)
Guias embutidos neste repositório (.claude/skills/, espelho gerado de .agents/skills/ — a
mesma tabela vale para Codex, Cursor, OpenCode e Antigravity; ver AGENTS.md). Para tê-los em
qualquer pasta, bash scripts/instalar-guias.sh; editando um guia numa branch, rode com --fonte .
naquele clone — no Claude Code a skill GLOBAL vence a do projeto com o mesmo nome:
deskcomm-instalar— instalar, atualizar ou consertar a instalação numa VPSdeskcomm-cliente-novo— configurar o CRM para um cliente ou nicho (agentes, roteadores, follow-ups, conhecimento)deskcomm-metricas— desempenho, conversão, custo de IA, funil, relatóriodeskcomm-prompt— afinar o prompt de um agente que não performadeskcomm-contribuir— o espelho da triagem, antes do PR; fica quieto para o mantenedordeskcomm-extensao— criar extensão em vez de PR no núcleo: régua de destino, contrato do pacote e enviodeskcomm-doutrina— as três regras que mais custam, antes de escrever código
Os guias têm página pública em deskcomm.com.br/guias, escrita
à mão em deskcomm-site/conteudo/guias.ts: guia criado, renomeado ou com comando novo pede a mesma
mudança lá — senão a página ensina um guia que não existe. Ela e a de changelog saem do mesmo PR do
deskcomm-site; enquanto as duas não responderem 200, vale o curl que abre a seção "A vitrine" de
docs/doctrine/versionamento.md, não a frase acima.
superpowers:brainstorming— antes de implementar feature não-trivialsuperpowers:writing-plans— pra task com mais de 1 etapa de DB/APIsuperpowers:test-driven-development— feature crítica (LGPD, RLS, anti-banimento)superpowers:systematic-debugging— bugs reportadossuperpowers:verification-before-completion— antes de declarar "pronto"tomik-db-doctrine— referência cruzada de doutrina de schemasupabase:supabase— qualquer task com Supabasevercel:nextjs— App Router, Server Components, edge runtimevercel:ai-gateway— config de fallback de providerfrontend-design— UI distinta (não cair em shadcn-default genérico)
Definition of Done
Antes de declarar uma task pronta:
-
pnpm typecheckpassa zerado -
pnpm lintzerado -
Testes unit/e2e relevantes existem e passam
-
RLS testada se feature toca tabela tenant-aware
-
Audit log emitido se há mutação relevante
-
Rate limit aplicado se rota é pública
-
Zod valida todo input externo
-
Sem
console.logesquecido -
Env vars novas adicionadas em
.env.example+lib/env.ts -
Doc atualizada se mudou contrato (PRD/spec)
-
Mudança de schema saiu como migration versionada + linha no MANIFEST (ver Doutrina de Migrations) — clones conseguem atualizar
-
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. Quando o caminho passa por um agente de IA, o caso de aceite mede o par (a tela pelo agente + a ferramenta chamada direto, com o mesmo texto cru) e só conta como prova quando os dois concordam — emenda em
docs/doctrine/prova-em-par.md(#489) -
Living System Checklist respondido (lei em
docs/doctrine/sistema-vivo.md; racional no manualdocs/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 -
Tela nova tem porta — declarada em
lib/navigation/catalogo.ts(noNAV_CATALOG, com seu grupo), ou na allowlist detests/unit/navegacao-completude.test.tscom 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.⚠️ Esta linha dizia
lib/navigation/registry.ts, e quem a seguisse abria um arquivo sem um único lugar onde declarar: oregistry.tssó deriva (NAV_DESTINATIONSsai deNAV_CATALOG) e reexporta. Ele é a FACE do módulo — é dele que o teste importa, e por isso o engano é fácil. Quem declara é o catálogo. Para conferir sem acreditar nesta linha:grep -c 'href:' lib/navigation/catalogo.ts lib/navigation/registry.ts -
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 ficoubuild:-only; variável nova tem default que não quebra.envantigo; a atualização não pede edição manual de arquivo; e, se mudou o que a imagem contém, oupdate.shalcança essa peça. Rodepnpm test:shell— é o único gate que exercita o kit -
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). Onde a afirmação puder virar comando, troque em vez de corrigir: um número corrigido envelhece de novo; umrode isto para sabernão envelhece nunca -
Se o PR muda comportamento visível a quem opera uma VPS, ele traz o seu fragmento em
.changes/(lei emdocs/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 compnpm 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. Toda versão publicada aparece na página de changelog da LP (deskcomm.com.br/changelog, pt-BR/en/es). Ninguém escreve no site: a LP lê oCHANGELOG.mddamain, e o último passo do corte (release.yml, jobcortar-tag) reprova quando a versão não chegou. O texto do fragmento é, portanto, nota pública. Enquanto as três páginas não responderem 200 esse passo reprova TODO corte — a vitrine vem de um PR dodeskcomm-site, e ocurlque diz em que estado ela está abre a seção. Lei: seção "A vitrine" deversionamento.md. -
Se o PR muda comportamento, ele declara o destino: núcleo, extensão, ambos ou infraestrutura (lei em
docs/doctrine/extensoes.md), com a razão medida pela pergunta "se nenhuma organização ativar isto, a operação comum continua inteira?". "Ambos" traz o consumidor real do ponto novo do núcleo e a prova dos dois lados. Classificar como extensão não autoriza remover nem desligar o que já foi distribuído.
Um staff engineer aprovaria? Se não, itera.