Ctrl K
ComunidadGuías…

AGENTS.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Repository Guidelines

> Contrato portável para **qualquer** agente de código (Codex, Cursor, OpenCode, Antigravity, Copilot).
> Este arquivo é o núcleo. A **doutrina completa e não-negociável vive em [`CLAUDE.md`](CLAUDE.md)** —
> leia-o antes de tocar em código. O mapa de toda a documentação está em [`docs/index.md`](docs/index.md).
> Precedência quando dois documentos discordam: `CLAUDE.md` > `docs/specs/` > `docs/prd/` >
> `HANDOFF-*.md` > `README.md`.

## Project Overview

Sistema operacional de vendas open source com agentes de IA nativos, multi-nicho (e-commerce,
clínicas, imobiliárias, infoprodutos, serviços), WhatsApp como canal primário via WAHA, CRM
inteiro exposto por MCP. Multi-tenant com RLS desde o dia 1; LGPD nativa. Monetização =
**self-host em VPS**, não assinatura. Posicionamento: [`VISION.md`](VISION.md); estado real de
implementação: [`docs/current-state.md`](docs/current-state.md).

**Consequência que muda como você trabalha:** o produto é distribuído como código. Quem instala
numa VPS **é** o usuário. Uma mudança que funciona na máquina do dev e quebra no clone fresco é
**bug de produto**, não detalhe de ambiente. Nada que exija edição manual de arquivo na VPS entra.

Stack canônica (major; a versão exata é o `package.json`):

Next.js 16 (App Router, Turbopack) · React 19 · TypeScript 6 estrito · Tailwind 4 (config em CSS) ·
shadcn/ui (`new-york`) · Supabase (Postgres + Auth + Realtime + Storage) · Zod 4 · Vitest 4 ·
Playwright 1 · Sentry 11 · WAHA 2026.7.2 (engine NOWEB) · Upstash Redis · Vercel AI Gateway
(`@ai-sdk/anthropic|openai|google`).

> As majors acima são verificadas contra o `package.json` por
> [`tests/unit/agents-md-versoes.test.ts`](tests/unit/agents-md-versoes.test.ts) — declare **só a
> major**; afirmar minor em prosa cria débito que nenhum gate cobre e trava bump do Dependabot.
> Um teste irmão,
> [`tests/unit/documentacao-aponta-para-o-que-existe.test.ts`](tests/unit/documentacao-aponta-para-o-que-existe.test.ts),
> reprova todo path citado aqui que não exista no disco.

## Architecture & Data Flow

- **App** — Next.js 16 App Router: UI + Route Handlers no mesmo repo. Server Components por
  default; `"use client"` só com estado/evento/API de browser. Middleware de borda em `proxy.ts`
  (Next 16 renomeou `middleware.ts` → `proxy.ts`; ele injeta `X-Request-Id` e `x-pathname` e
  autentica a sessão antes da rota).
- **DB** — Supabase Postgres. RLS em toda tabela tenant-aware via helper
  (`fn_user_org_ids()`/`fn_user_role_in_org()`), a mesma função SECURITY DEFINER que o RBAC de
  aplicação usa. Schema versionado em `supabase/migrations/`; o que o self-host aplica é
  `supabase/baseline.sql`.
- **Auth** — Supabase Auth + `@supabase/ssr`, cookie `SameSite=Strict`. Sempre `getUser()` no
  server; **nunca** `getSession()`. MFA TOTP é opcional e ligado por quem administra
  (duas políticas que somam: plataforma e organização), regra pura em
  `lib/auth/politica-mfa.ts`.
- **Filas** — event sourcing leve: `event_log` + workers drenados por cron. Trigger Postgres
  **nunca** faz HTTP.
- **IA** — Vercel AI Gateway (Anthropic primário; embeddings pela OpenAI ou pelo Google, escolha da organização), RAG por tenant,
  guardrails before-send.
- **Tempo real** — Supabase Realtime (`postgres_changes` para inbox/kanban, `broadcast` para
  sinais leves). **Storage** — bucket privado `whatsapp-media`, URL assinada.

Fluxo de uma rota autenticada de tenant:

```mermaid
flowchart LR
  A[request] --> B[proxy.ts: X-Request-Id, sessao]
  B --> C[1. Zod valida input]
  C --> D[2. guard: requireRole / requirePlatformAdmin / secret]
  D --> E[3. organization_id de fonte confiavel]
  E --> F[4. query: RLS ou filtro manual de org]
  F --> G[5. audit se mutacao]
  G --> H[6. ok / fail]
```

Superfícies **não-cookie** (cada uma com guard próprio, nunca o cookie de sessão):
`app/api/v1/cron/` (Bearer `INTERNAL_CRON_SECRET`, fail-closed),
`app/api/mcp/` (Bearer `dsk_...` contra `api_tokens`), `app/api/v1/webhooks/`
(HMAC + path token), e **parte de `app/api/v1/`** — rotas que aceitam cookie OU bearer pelo helper
`lib/api/auth-dual.ts`. Esta última cresce rota por rota (decisão do dono em 17/09/2026: converter
o que cada integração precisar), então o inventário é um comando e não uma lista:
`git grep -ln "auth-dual" -- app/api/v1`. Rota com o helper **também** precisa de entrada em
`lib/auth/public-paths.ts`, senão o `proxy.ts` devolve 401 antes do handler. Inventário e superfície de ataque: [`docs/threat-model.md`](docs/threat-model.md).

Turno do agente de IA: inbound WhatsApp → HMAC + idempotência → `event_log` → worker →
`runAgentTurn` (RAG + tools MCP) → guardrails → adapter WAHA → handoff humano se o gatilho
disparar. Entrada do turno em `lib/agent-engine/agent/inbound-turn.ts`; diagrama em
`docs/architecture/agent-turn.html`.

| Path | O quê |
|---|---|
| `app/api/v1/` | Route handlers REST (versionado por path) — **reconte, não cite**: `git ls-files 'app/api/v1/**/route.ts' \| wc -l` (e `git ls-files 'app/api/**/route.ts' \| wc -l` para o total de `app/api/**`) |
| `app/api/mcp/`, `app/api/v1/cron/` | superfícies não-cookie (secret/bearer próprio) |
| `app/app/` | UI autenticada do tenant · `app/admin/` UI de plataforma |
| `app/actions/` | Server Actions (auth, onboarding, team, settings) |
| `lib/agent-engine/`, `lib/ai/` | runtime do agente, guardrails, RAG, dispatcher |
| `lib/api/wrappers.ts` | `ok()` / `fail()` — **use sempre**, não monte Response na mão |
| `lib/auth/require-role.ts` | `requireRole()` — guard canônico de RBAC |
| `lib/supabase/{browser,server,admin}.ts` | clients canônicos |
| `workers/` | workers de `event_log` + crons |
| `supabase/migrations/` | schema versionado · `supabase/baseline.sql` = o que o self-host aplica |
| `proxy.ts` | middleware do Next 16 (auth de borda, `X-Request-Id`) |
Idempotência de worker: `unique (organization_id, external_id)` + captura de `code === '23505'`.
Contrato completo em [`docs/specs/07-spec-events-workers.md`](docs/specs/07-spec-events-workers.md).

## Key Directories

| Path                    | O quê                                                                                                           |
| ----------------------- | --------------------------------------------------------------------------------------------------------------- |
| `app/api/`              | Route handlers REST, versionados por path. Conte quantos existem: `git ls-files 'app/api/**/route.ts' \| wc -l` |
| `app/app/`              | UI autenticada do tenant                                                                                        |
| `app/admin/`            | UI de plataforma (platform admin)                                                                               |
| `app/actions/`          | Server Actions (auth, onboarding, team, settings)                                                               |
| `lib/agent-engine/`     | Runtime do agente: turno inbound/outbound, playbooks, handoff, follow-up, memória da org                        |
| `lib/ai/`               | Modelos, custo, orçamento, RAG, dispatcher, catálogo de providers                                               |
| `lib/channels/`         | Abstração de canal (invariante de restrição de canal)                                                           |
| `lib/api/`              | `wrappers.ts` (`ok()`/`fail()`) e `errors.ts` (catálogo de códigos)                                             |
| `lib/auth/`             | `server.ts` (sessão/org), `require-role.ts` (`requireRole`), `public-paths.ts` (borda)                          |
| `lib/supabase/`         | Clients canônicos: `browser.ts`, `server.ts`, `admin.ts` (service role)                                         |
| `lib/branding/`         | Marca própria (white-label) — resolve do banco, nunca do .env                                                   |
| `workers/`              | Workers de `event_log` + crons                                                                                  |
| `components/`, `hooks/` | React compartilhado; convenções nos README de cada pasta                                                        |
| `supabase/migrations/`  | Schema versionado (`MANIFEST.md` = histórico); `supabase/baseline.sql` é o que o self-host aplica              |
| `hostgator-setup-kit/`  | Kit de instalação/atualização da VPS (`install.sh`, `update.sh`, `diagnostico.sh`, `healthcheck.sh`)            |
| `scripts/`              | CLIs de operação e QA — ver `scripts/README.md`                                                                 |
| `tests/`                | `unit/`, `invariants/`, `e2e/`, `shell/`, `journeys/`, `fixtures/`                                              |
| `docs/`                 | Doutrina, PRDs, specs, regras de negócio, runbooks, design system — entrada em `docs/index.md`                  |
| `.agents/skills/`       | Guias do assistente embutidos (espelho em `.claude/skills/`, regerado por `pnpm skills:sync`)                   |

## Development Commands

```bash
pnpm install          # deps (frozen-lockfile no CI)
pnpm dev              # dev server
pnpm build            # next build
pnpm lint             # eslint
pnpm typecheck        # tsc --noEmit -p tsconfig.typecheck.json (inclui tests/)
pnpm test:unit        # vitest — EXCLUI tests/invariants, tests/e2e e tests/journeys (lista viva em vitest.config.ts → exclude)
pnpm test:db          # invariantes de banco + gate do baseline (PRECISA de Docker)
pnpm test:e2e         # Playwright (PRECISA de app rodando + banco semeado)
pnpm gov:verify       # typecheck + lint + lint:channels + lint:role-rank + test:unit
                      # ← verificação única atual; o encadeamento real sai de:
                      #   node -e "console.log(require('./package.json').scripts['gov:verify'])"
pnpm lint             # eslint (flat config)
pnpm typecheck        # tsc --noEmit -p tsconfig.typecheck.json
pnpm test:unit        # vitest run — EXCLUI tests/e2e, tests/invariants, tests/journeys
pnpm test:db          # invariantes de banco + gate do baseline (PRECISA de Docker)
pnpm test:e2e         # Playwright (PRECISA de app buildado + .env.e2e)
pnpm test:shell       # scripts do kit self-host (bash)
pnpm gov:verify       # typecheck + lint + lint:channels + lint:role-rank + test:unit
```

Um teste só:

```bash
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
```

⚠️ **`pnpm gov:verify` não cobre tudo.** Ele omite `test:db`, `test:e2e` **e** `test:shell`.
Se a mudança toca schema/RLS/tabela tenant-aware, rode `pnpm test:db`. Se toca UI ou fluxo de
usuário, rode `pnpm test:e2e` com evidência visual. Se toca `Dockerfile*`, `docker-compose*` ou
`hostgator-setup-kit/`, rode `pnpm test:shell` — é o único gate que exercita o kit.

**O que o CI cobre.** `.github/workflows/ci.yml`: `verify` = os passos do job, na ordem —
typecheck, lint, `lint:channels`, `test:unit` e `test:shell` hoje, e `pnpm lint` sozinho **não**
cobre os dois últimos (liste em vez de acreditar nesta linha:
`awk '/^  verify:/,/^  invariants-majors:/' .github/workflows/ci.yml | grep -A1 'name:'`);
`invariants` = fachada sobre a matriz `invariants-majors`, que roda `pnpm test:db` (isolamento
RLS + invariantes de governança) e `pnpm test:db:update` (atualizar banco COM dados) uma vez por
major de Postgres suportado. `.github/workflows/perf.yml`: `build-and-size` = `pnpm build`.
`.github/workflows/e2e.yml` roda as specs Playwright contra um Supabase local de verdade com
o `baseline.sql` aplicado — o mesmo banco que o self-hoster tem. **É check obrigatório** — a
data de ativação não é auditável pelo repositório, e a lista viva está logo abaixo, com o
comando ao lado. **Não há número aqui de propósito**: esta linha já afirmou uma contagem exata
de specs e "a única de fora", e as duas envelheceram — a suíte cresce toda semana e a lista de
exceções muda com ela. Quem fica de fora é o que a própria variável declara; leia, não confie:
O CI tem cinco checks obrigatórios na `main`: `verify`, `build-and-size`, `invariants`, `e2e`,
`imagens-ok`. Não confie nesta lista — reconte antes de citar:

```bash
gh api repos/melgarafael/DeskcommCRM/branches/main/protection \
  --jq '.required_status_checks.contexts|join(", ")'
```

`e2e` roda as partes da sua matrix em paralelo (quantas:
`git show origin/main:.github/workflows/e2e.yml | grep -E '^ +parte: \['` — esta linha dizia
"três" até o PR #983 acrescentar a quarta); as specs de fora estão declaradas, **com motivo
escrito**, em `FORA_DO_CI` dentro de `.github/workflows/e2e.yml`. Leia em vez de supor:

```bash
git show origin/main:.github/workflows/e2e.yml | grep -A4 'FORA_DO_CI:'
```

## Embedded Assistant Guides

O repositório embute guias em `.agents/skills/` (lidos por Codex, Cursor, OpenCode, Antigravity;
o Claude Code lê o espelho em `.claude/skills/`). Carregue o guia quando o pedido casar, mesmo que
a pessoa não saiba que ele existe — `tests/unit/skills-embutidas.test.ts` exige que este arquivo
cite cada um:

**Os cinco são checks obrigatórios** na branch protection da `main` — medido em 2026-08-14 @ `741c4ec8` (o comando exige permissão de **admin** no repositório: com token de contribuidor ele devolve `404`, medido em 2026-09-13):
| Situação                                                                            | Guia                    |
| ----------------------------------------------------------------------------------- | ----------------------- |
| Instalar, atualizar ou consertar a instalação numa VPS; domínio, Supabase, WhatsApp | `deskcomm-instalar`     |
| Usar o CRM no dia a dia; encontrar telas, fluxos e configurações pela interface     | `deskcomm-operacao`     |
| Atualizar aulas, traduções e capturas do curso com as mudanças do produto           | `deskcomm-curso`        |
| Configurar o CRM para um cliente ou nicho: agentes, roteadores, follow-ups, base    | `deskcomm-cliente-novo` |
| Revender como SaaS: planos, cobrança pela Stripe ou Asaas, régua, contrato          | `deskcomm-saas`         |
| Desempenho, conversão, custo de IA, funil, relatório                                | `deskcomm-metricas`     |
| O agente responde errado, passa tudo para humano, não usa a agenda; afinar o prompt | `deskcomm-prompt`       |
| Contribuir: corrigir bug, abrir ou atualizar PR, migration, conflito com a `main`   | `deskcomm-contribuir`   |
| Criar extensão/plugin/módulo de nicho, ou transformar um PR de nicho em pacote      | `deskcomm-extensao`     |
| Escrever ou revisar código aqui                                                     | `deskcomm-doutrina`     |

O gate de arquitetura de qualquer peça que atende pessoas é a skill `sistema-vivo` (lei em
[`docs/doctrine/sistema-vivo.md`](docs/doctrine/sistema-vivo.md)).

## Code Conventions & Common Patterns

**Receita de route handler** — nesta ordem, sem atalho:

1. Zod valida **todo** input externo (body, query, path).
2. Guard canônico: `requireRole()` de `lib/auth/require-role.ts`,
   `requirePlatformAdmin`, ou secret/HMAC. Nunca reimplemente a comparação de rank na mão.
   Handler **mutante** de `app/api/v1` declara ainda `requireSupportWrite(` (`lib/impersonate/support.ts`)
   antes do efeito: barra escrita em `support_readonly` e não substitui RBAC/MFA.
3. `organization_id` resolvido de **fonte confiável** (cookie/JWT/webhook secret/path token) —
   **nunca do body**.
4. Query: RLS pelo client de sessão, ou filtro manual de `organization_id` quando usa service role.
5. `audit()` (fire-and-forget) se houve mutação.
6. Responda com `ok()` / `fail()` de `lib/api/wrappers.ts` — nunca monte `Response` na mão.

```ts
const authz = await requireRole("manager", { requestId });
if (!authz.ok) return authz.response;
```

**Erros** — `fail(code, message, status)` com código de `lib/api/errors.ts`. Nunca `throw` cru na
borda. Toda resposta de `ok()`/`fail()` leva o `X-Request-Id`, e é o mesmo do registro e da auditoria;
as rotas que montam resposta à mão estão sendo migradas (issue #4107).

**Nomes e dados** — arquivos e símbolos em PT-BR são a norma (mantenha o idioma do arquivo que
editar). JSON da API em **snake_case**; dinheiro em `_cents` + `currency`; datas ISO-8601 UTC;
UUID v4. Testes ao lado do código (`lib/foo/bar.test.ts`) ou em `tests/`.

**Log** — `lib/logger.ts` (estruturado, JSON). `console.log` é proibido em código merged —
`no-console` é `warn` no ESLint e o DoD cobre o resto. Nunca logue segredo, token, CPF, telefone
ou e-mail; o `beforeSend` do Sentry higieniza, mas não é a única camada.

**Multi-tenancy** — `organization_id uuid not null references organizations(id) on delete cascade`
em toda tabela tenant-aware. `lib/supabase/admin.ts` **bypassa RLS**: toda query com service role
filtra `organization_id` manualmente. Sem gate automático para isso — a responsabilidade é sua.

**Migrations** — mudança de schema sai **sempre** como tripla: migration versionada em
`supabase/migrations/` com uma linha `-- manifest: <o quê e por quê>` no cabeçalho, e apêndice
idempotente em `supabase/baseline.sql`. **Não** acrescente linha no `MANIFEST.md`: ele é histórico,
e era o arquivo que fazia todo PR com migration conflitar no GitHub. Nunca edite migration já
aplicada; corrija com uma nova.
Função nova em `public` precisa de `revoke execute ... from public, anon` **e** `grant` — são
duas origens de `EXECUTE`.
⚠️ E **não leia o baseline com `grep` no arquivo inteiro**: ele é dump + apêndice, a mesma
função aparece várias vezes, e quem vale é a **última**. Pergunte ao banco depois de aplicar
(`pg_get_functiondef`) ou ancore no último `create or replace` (`rfind`, nunca `find`).
Contar no arquivo responde "o arquivo menciona", não "o banco faz".

**Marca própria (white-label)** — o produto é revendido e o nome não é seu. **Nunca** escreva
"Deskcomm"/"DeskcommCRM" em código que alcança o usuário: `tests/unit/branding.test.ts` varre
`app|components|lib|workers|hooks` e reprova (a allowlist só encolhe). A marca resolve do banco
(`platform_branding`, `organizations.settings.branding`); `APP_NAME`/`APP_LOGO_URL`/`APP_ACCENT_HEX`
no `.env` são semente e piso de rollback. Fora do DOM (e-mail, ícone, `issuer` do MFA) use
`marcaDaSaida()` de `lib/branding/saida.ts`. O resolvedor **nunca lança** — ele roda em
`app/layout.tsx` e um throw ali é 500 em todas as telas. O PDF de LGPD não leva marca: ele nomeia
o controlador (`organizations.legal_name`) e o DPO.

- **`supabase/baseline.sql`** — é o que o `install.sh`/`update.sh` do self-host aplicam.
  Toda mudança de schema tem que aparecer aqui **como apêndice idempotente**, senão
  não chega em quem instalou. Ver doutrina de Migrations em `CLAUDE.md`.
- **`supabase/migrations/*.sql` já aplicadas** — nunca edite. Corrija com migration nova.
- **`lib/supabase/admin.ts`** — service role **bypassa RLS**. Boa parte dos handlers de
  `app/api/**` o usa — reconte em vez de citar:
  `grep -rl createAdminClient app/api --include='route.ts' | wc -l` contra
  `git ls-files 'app/api/**/route.ts' | wc -l`. Toda query precisa filtrar
  `organization_id` manualmente, resolvido de fonte confiável
  (cookie/JWT/webhook secret/path token), **nunca do body**.
- **`lib/auth/public-paths.ts`** — adicionar path aqui remove a checagem de auth de borda.
  Só com guard próprio dentro da rota.
- **`.env*`** — não abra, não copie valor, não logue. Só `.env.example` é template.
- **`docker-compose.traefik.yml`** — numa VPS que já tem proxy reverso próprio
  (Hostinger, Coolify, Dokploy…), é o único lugar que dá ao contêiner `app` as labels
  de roteamento. Todo `up -d` leva os **dois** arquivos de compose:
**Anti-patterns proibidos** — string que deveria ser FK (lista `uuid[]` pequena, lida junto com a
linha e conferida no handler é exceção escrita em `CLAUDE.md`, seção Modelagem); duplicação sem source of truth declarado;
feature nomeando um provider de canal (gate `pnpm lint:channels`); tela nova sem porta declarada em
`lib/navigation/registry.ts` (gate `tests/unit/navegacao-completude.test.ts`); `getSession()` no
server; segredo em query string; `throw` cru na borda da API.

## Important Files

| Arquivo                                    | Por quê                                                                      |
| ------------------------------------------ | ---------------------------------------------------------------------------- |
| `proxy.ts`                                 | Middleware de borda do Next 16: auth, `X-Request-Id`, impersonation          |
| `lib/api/wrappers.ts`                      | `ok()` / `fail()` — formato de resposta e `X-Request-Id`                     |
| `lib/api/errors.ts`                        | Catálogo de códigos de erro                                                  |
| `lib/auth/require-role.ts`                 | `requireRole()` — guard canônico de RBAC                                     |
| `lib/auth/server.ts`                       | `loadAuthUser()`, `resolveActiveOrg()` — sessão e org ativa                  |
| `lib/auth/public-paths.ts`                 | Allowlist de paths sem auth de borda (só com guard próprio dentro da rota)   |
| `lib/supabase/admin.ts`                    | Service role — **bypassa RLS**                                               |
| `lib/logger.ts`, `lib/env.ts`              | Log estruturado; contrato de env vars validado por Zod                       |
| `lib/audit/index.ts`                       | `audit()` — trilha de auditoria                                              |
| `lib/database.types.ts`                    | **Gerado** do schema — não edite à mão                                       |
| `supabase/baseline.sql`                    | O que o `install.sh`/`update.sh` aplicam — toda mudança de schema entra aqui |
| `workers/agent-worker/main.ts`             | Entry point do worker de agente                                              |
| `docker-compose.prod.yml`                  | Topologia de produção (imagens publicadas)                                   |
| `docker-compose.traefik.yml`               | Labels de roteamento para VPS que já tem proxy próprio                       |
| `docker-compose.cloudflared.yml`           | Cloudflare Tunnel (VPS atrás de NAT): sobe `cloudflared`, desliga o Caddy    |
| `instrumentation.ts`, `sentry.*.config.ts` | Boot de observabilidade                                                      |

## Runtime/Tooling Preferences

- **Node ≥ 22** (`engines`, `.nvmrc` = 22; os workflows fixam `node-version: 22`). Gerenciador:
  **pnpm 9.15.9** (`packageManager`). Não use npm/yarn.
- **TypeScript estrito** via `tsconfig.typecheck.json`; `strict`, `noUncheckedIndexedAccess`,
  `isolatedModules`, alias `@/*` → raiz. `pnpm typecheck` é a régua.
- **ESLint flat config** (`eslint.config.mjs`, ESLint 10): `next/core-web-vitals`,
  `react-hooks`, `typescript-eslint`. `next lint` foi removido no Next 16 — o script chama o CLI.
- **Prettier** com `prettier-plugin-tailwindcss`; classes Tailwind em ordem canônica.
- **Tailwind 4** — configuração em CSS (`app/globals.css`), não em `tailwind.config.js`.
- **Sentry** — coleta restrita (`dataCollection`) + scrub num ponto só, `lib/sentry/privacidade.ts`,
  provado pelo envelope do SDK em `privacidade.sdk.test.ts`; `tunnelRoute: "/monitoring"` evita ad-blocker.
- **Packaging (não-negociável; lei em [`docs/doctrine/packaging.md`](docs/doctrine/packaging.md))** —
  nenhum serviço de `docker-compose.prod.yml` constrói na máquina do cliente: todo serviço declara
  `image:` de imagem publicada, e `build:` existe só ao lado, como escape. Serviço `build:`-only é
  pulado por `docker compose pull` e **nunca é atualizado**. Publicação é ato do CI
  (`.github/workflows/publish-image.yml`), nunca da sua máquina. Instalação aponta para número de
  versão; `latest` significa topo da `main`, a última release é `stable`. Dependência upstream é
  referenciada com tag fixa, nunca republicada (WAHA é licenciado). Bump de versão não pode exigir
  edição manual de arquivo na VPS.
- **Deploy em VPS com proxy próprio** — 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`.
  Esquecer o segundo `-f` recria o contêiner sem labels: o domínio inteiro responde `404` com o
  contêiner `healthy` (o healthcheck é um probe TCP interno). Runbook:
  [`docs/runbooks/deploy.md`](docs/runbooks/deploy.md).
- **VPS atrás de NAT** — `REVERSE_PROXY=cloudflared` entra o
  `docker-compose.cloudflared.yml`: desliga o Caddy e sobe o `cloudflared` (túnel
  *remotely-managed*, só `CLOUDFLARE_TUNNEL_TOKEN` no `.env`; rotas no painel da Cloudflare).
  Sem porta publicada. O webhook global do WAHA perde o 403 do Caddy; quem recusa o que chega
  pela borda é o próprio app (`chegouPelaBorda`). Não é suportado no single-server (o instalador recusa). Ver
  `docs/runbooks/deploy.md` §5.
- **Env vars** — nova variável entra em `.env.example` **e** em `lib/env.ts`. Nunca leia nem logue
  valor de `.env*`; só `.env.example` é template. Segredo/token só em header, nunca em query string.
- **Gerados — não edite** — `lib/database.types.ts`, `graphify-out/`, `pnpm-lock.yaml`, `.next/`.

## Testing & QA

- `lib/database.types.ts` (gerado do schema Supabase — o tamanho de hoje sai de `wc -l lib/database.types.ts`)
- `graphify-out/` (grafo de conhecimento local; ignorado pelo git e **ausente num clone fresco** — só existe depois de rodar `/graphify .`)
- `pnpm-lock.yaml`, `tsconfig.tsbuildinfo`, `next-env.d.ts`, `.next/`
| Camada                        | Comando              | O que cobre                                                                    |
| ----------------------------- | -------------------- | ------------------------------------------------------------------------------ |
| Unit (vitest, jsdom)          | `pnpm test:unit`     | `tests/unit/**` + todo `*.test.ts(x)` ao lado do código. Timeout 15s por teste |
| Invariantes de banco (Docker) | `pnpm test:db`       | Isolamento cross-tenant/RLS, RBAC e governança contra Postgres efêmero         |
| E2E (Playwright + axe-core)   | `pnpm test:e2e`      | Jornadas reais contra app buildado e o banco do `baseline.sql`                 |
| Kit self-host (bash)          | `pnpm test:shell`    | `scripts` do kit, `install.sh`, `update.sh` — o único gate do kit              |
| Jornadas de canal             | `pnpm test:journeys` | `tests/journeys/` (config Playwright própria)                                  |

Convenções: teste ao lado do código (`lib/foo/bar.test.ts`) ou em `tests/{unit,api,invariants,e2e}`.
`tests/e2e/**` e `tests/invariants/**` são **excluídos** do vitest de propósito — não os mova para
dentro do include do unit. Fixtures em `tests/fixtures/`, helpers em `tests/helpers/`, setup global
em `tests/setup/vitest.setup.ts`. Determinismo é regra: teste que depende de ordem ou de rede
quebra a suíte inteira.

**Locator de tela compartilhada é contrato da suíte, não detalhe do teste.** `getByRole("button",
{ name: "Entrar" })` casa por **substring** — e `/entrar/i`, que era a forma do login, também: um
segundo botão com essa palavra na mesma tela ("Entrar com Google") torna o locator ambíguo, e o
Playwright **recusa clicar** (`strict mode violation`) em vez de escolher. O alcance não fica na
tela: `/login` é a porta de quase toda spec. Medido em 2026-09-21, um botão a mais ali pôs as 5
partes do `e2e` vermelhas — 320 violações do mesmo erro em 306 casos, 140 specs citadas no log.
Quem acrescenta botão ou link numa tela já coberta assume os locators que já existem: ancore com
`{ name: "Entrar", exact: true }`, forma que a suíte já usa 282× para outros rótulos, e meça antes
de empurrar:

```bash
git grep -nE "name: *(\"Entrar\"|'Entrar'|/entrar)" -- tests scripts | grep -vE 'exact: *true'
```

O conserto é no locator, **nunca** no produto: esconder um botão real para agradar regex de teste
troca um defeito de teste por um defeito de tela.

O `.env.e2e` é obrigatório e é recusado se apontar para Supabase que não seja `127.0.0.1`/
`localhost` — a proteção existe porque sem ela a suíte rodaria contra produção (`pnpm e2e:env`
gera o arquivo).

**QA visual com recursos reais (doutrina).** O produto é self-host: a experiência de quem instala
numa VPS **é** o produto. Toda feature nova, ou fix de comportamento visível, deve ser provada
pela tela como um usuário leigo faria, em ambiente fresco estilo VPS, com evidência visual.
`curl` não conta como prova de UX. Quando o caminho passa por um agente de IA, o caso de aceite
mede o **par** (tela pelo agente + ferramenta chamada direto, com o mesmo texto cru) e só conta
quando os dois concordam: [`docs/doctrine/prova-em-par.md`](docs/doctrine/prova-em-par.md). Mapa de jornadas:
[`docs/testing/user-journey-map.md`](docs/testing/user-journey-map.md).

Cada linha abaixo traz o comando que a mede — **rode o comando em vez de citar número**. Este
bloco já foi datado num SHA uma vez e não funcionou: os itens envelhecem em ritmos diferentes, e o
cabeçalho passava a mentir por todos eles.

- Arquivos de teste em `tests/unit/` (`git ls-files 'tests/unit/*.test.ts' 'tests/unit/*.test.tsx' | wc -l`). O total do repositório sai de `git ls-files '*.test.ts' '*.test.tsx' | wc -l` — a diferença vive junto ao código, fora de `tests/`, e também roda em `test:unit`.
- Arquivos de invariante de banco em `tests/invariants/` — RLS/isolamento cross-tenant, RBAC,
  governança (G1–G6). Excluídos do `test:unit` de propósito; rodam via `pnpm test:db` **e no job
  `invariants` do CI**. Quantos: `git ls-files 'tests/invariants/*.test.ts' | wc -l`.
- Specs Playwright em `tests/e2e/`, quase todas no CI (via `e2e.yml`, **obrigatório**), em todo PR que
  alcança o que elas medem — PR só de documentação/teste de outra suíte pula as partes
  (`scripts/pr-alcanca-o-e2e.sh`), e ali o `e2e` verde não prova tela. As que
  ficam de fora estão declaradas em `FORA_DO_CI`, **com o motivo escrito ao lado**. Esta linha
  já afirmou "menos uma" depois de deixarem de ser uma — por isso não conta mais. A issue #63,
  que originou a discussão, está **fechada** e o título dela descreve um estado que já não vale.
  Quantas existem: `ls tests/e2e/*.spec.ts | wc -l`. Quantas ficam fora:
  `git show origin/main:.github/workflows/e2e.yml | grep -A4 'FORA_DO_CI:'`.
**Antes de declarar pronto**, siga a **Definition of Done de [`CLAUDE.md`](CLAUDE.md)** — não
confie na memória, conte lá:

```bash
sed -n '/^## Definition of Done/,/^Um staff engineer/p' CLAUDE.md | grep -cE '^[0-9]+\. '
```

Em resumo: typecheck/lint zerados, testes relevantes verdes, RLS testada se tocou tabela
tenant-aware, `audit()` se houve mutação, Zod em todo input externo, migration + baseline +
`-- manifest:` da tripla se mudou schema, prova visual se mudou UI, `pnpm test:shell` se tocou packaging,
Living System Checklist respondido (lei em `docs/doctrine/sistema-vivo.md`) e mapa vivo em
`docs/architecture/` atualizado para peça nova.

## Limitações conhecidas

Cada item abaixo carrega o comando que o mede — item sem comando é suspeito de estar podre,
e a régua da casa é medir antes de repassar. Datar o bloco inteiro num SHA foi abandonado: os
itens envelhecem em ritmos diferentes, e o cabeçalho passava a mentir por todos eles.
(O SHA `789dfa6`, que ficava aqui, ficou para trás — meça com
`git rev-list --count 789dfa6..origin/main`.)

- **As specs E2E fora do CI são exatamente as declaradas em `FORA_DO_CI`**, e o `e2e` **é**
  check obrigatório — um PR que quebre o `e2e` não entra. **Quais estão de fora é pergunta de
  comando, não de leitura:** esta linha já afirmou por semanas que a jornada de instalação
  fresca seguia sem gate, e em 2026-09-19 o #983 pôs `vps-fresh-onboarding.spec.ts` no CI.

  ```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)}))"
  ```

  Gate não substitui prova: se você mexeu numa jornada, a prova pela tela continua sendo sua
  (DoD 12). E o gate só vale em PR que alcança o `e2e` (regra em `scripts/pr-alcanca-o-e2e.sh`):
  o que pula as partes sai com `e2e` verde sem ter provado tela nenhuma, a da instalação fresca
  inclusive. O número e a contagem que ficavam aqui eram de uma fotografia de agosto.
- Rate limit HTTP: `lib/auth/rate-limit.ts` cobre **login, signup, recuperação de senha e
  aceite de convite** (contando por IP **e** por identificador hasheado); `checkRateLimit` cobre
  o webhook de captação e outros pontos de `app/` e `lib/` (a régua abaixo lista). **Crons seguem sem.** O MCP conta em dois pontos:
  a recusa de token, antes da autenticação (`lib/mcp/auth.ts`, #1449), e o teto de chamadas de
  token válido — por token, por organização e de escrita, Spec 11 §7 (`lib/mcp/rate-limit.ts`,
  #1446). Meça antes de agir:
  `grep -rln 'authRateLimited\|checkRateLimit(' app lib --include='*.ts' --include='*.tsx'`.
  Esta linha dizia "existe em 2 pontos; login e signup estão sem" — era o estado anterior à
  issue #64, e o `docs/threat-model.md` ainda carrega a versão velha, com nota de reauditoria.
- Fallback do rate limit é **em memória** — mas o gatilho não é "sem Upstash": `lib/env.ts`
  declara as duas variáveis do Upstash como `required()` e o app não sobe sem elas
  (`grep -n UPSTASH lib/env.ts`). O que cai para a memória é Redis **inalcançável** com a
  variável presente, e aí o limite passa a ser por processo.
- `Idempotency-Key` é lido por **4** rotas e o contrato promete nos POSTs de criação. Há duas
  implementações com recibo (`lgpd/requests/[id]/approve` e `admin/tenants`) e, desde este
  commit, uma reutilizável em `lib/api/idempotency.ts`, aplicada em `message-templates`.
  Reconte antes de citar: `grep -rln 'Idempotency-Key' app/api/v1 --include='route.ts'`.
  No helper reutilizável, **a corrida entre duas requisições simultâneas com a mesma chave
  está fechada** (issue #778, migration 0321): a chave é reservada ANTES do efeito e quem
  perde recebe 409 `idempotency_in_progress`. Isso vale para quem usa `comIdempotencia` —
  hoje só `message-templates`; as outras rotas mantêm o recibo delas.
- **`.env.example` está completo** — medido em 2026-08-14: das 45 chaves de `lib/env.ts`, a
  única ausente é `NODE_ENV`, que não é configuração do operador. Esta linha dizia que faltavam
  6, "incluindo 3 secrets"; os três (`IMPERSONATE_COOKIE_SECRET`, `INTERNAL_CRON_SECRET`,
  `LGPD_SIGNING_KEY`) estão lá. Se você adicionar env var, adicione nos dois lugares (item 9 do
  DoD) — a regra continua valendo, o que caiu foi a dívida.
- `lib/auth/invite-token.ts` cai em `"dev-fallback"` como secret HMAC se nenhum secret existir
  (inalcançável em produção, porque `INTERNAL_SECRET` é obrigatório e derruba o boot).
- **Boa parte dos handlers de `app/api/**` usa service role** — reconte:
  `grep -rl createAdminClient app/api --include='route.ts' | wc -l` contra
  `git ls-files 'app/api/**/route.ts' | wc -l`. Não há gate automático para o filtro de
  `organization_id`. Escrevendo handler novo, o filtro é responsabilidade sua.
- Detalhes e prioridade: [`docs/harness-audit.md`](docs/harness-audit.md),
  [`docs/current-state.md`](docs/current-state.md) e [`docs/threat-model.md`](docs/threat-model.md).

## Regras de segurança

- Sempre `getUser()` no backend. **Nunca `getSession()`** (confia no cookie sem revalidar).
- API key/token **nunca** em query string — só header. Plaintext do bearer é mostrado
  **uma vez**; no banco só hash SHA256.
- HMAC de webhook com `crypto.timingSafeEqual`. Fail-closed quando o secret falta.
- Nunca logue segredo, token, CPF, telefone ou e-mail. Sentry tem `beforeSend` que
  higieniza — não confie nele como única camada.
- Não commite screenshot/dump com dado real de cliente.
- Descadastro (STOP): quem bloqueia é só a regra de `lib/opt-out/deteccao.ts`, quando o próprio cliente
  manda o STOP (não há bloqueio à mão no produto — `lib/channels/pos-entrada.ts` é o único escritor); o Jev
  (`lib/ai/decisao/pedidos.ts`) só é perguntado onde ela disse não, e nunca bloqueia ninguém — no
  máximo abre um aviso na Central ("Avisar a equipe").

## Packaging — se você tocou `Dockerfile*`, `docker-compose*.yml` ou `hostgator-setup-kit/`

Lei completa em [`docs/doctrine/packaging.md`](docs/doctrine/packaging.md). O não-negociável:

- **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 é pulado por `docker compose pull` e imune a `up -d` sem `--build` —
  ele não é só caro de instalar, ele **nunca é atualizado**.
- **Publicação é ato do CI**, nunca da sua máquina: as imagens publicadas atendem linux/amd64 e linux/arm64.
- **Instalação de cliente aponta para número de versão**, nunca para tag móvel. Aqui `latest`
  significa **topo da `main`**, não última release — quem quer a última release usa `stable`.
- **Dependência upstream é referenciada com tag fixa, nunca republicada** (WAHA é licenciado).
- **Bump de versão não pode exigir que o operador da VPS edite arquivo à mão.**

`pnpm test:shell` é o único gate que exercita o kit. Rode-o.

## Extensões — se sua mudança muda comportamento

Lei completa em [`docs/doctrine/extensoes.md`](docs/doctrine/extensoes.md); contrato vigente em
[`docs/specs/extensoes-declarativas-v1.md`](docs/specs/extensoes-declarativas-v1.md). Declare o
destino (núcleo, extensão, ambos ou infraestrutura) respondendo: **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.** Nenhuma jornada do núcleo depende de extensão ativa.
- **Extensão pede capacidade nomeada** e não importa código interno, não lê o banco e não recebe
  cliente Supabase, ambiente ou dados do CRM. Instalar não concede autoridade.
- **A instância decide o pacote; a organização decide o uso.** A plataforma não reativa decisão da
  organização.
- **Toda operação é recibo idempotente com saída pela tela; toda troca de ponteiro exige a revisão
  que a tela viu.** Remover é lógico e preserva dados e configuração.
- **Não anuncie SDK, código isolado ou marketplace público**, e não extraia 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](docs/adr/0002-tabelas-de-modulo-num-banco-so.md)): um banco só, `public`, tabelas criadas
  por função provisionadora fixa quando o módulo é instalado na instância.

## Critério de conclusão

Vale a **Definition of Done em [`CLAUDE.md`](CLAUDE.md)** — conte lá em vez de confiar num número aqui (`sed -n '/^## Definition of Done/,/^Um staff engineer/p' CLAUDE.md | grep -cE '^[0-9]+\. '`; esta linha já disse 15 quando o DoD tinha 16). A régua tem que DELIMITAR a seção: a primeira versão desta linha oferecia `grep -c '^[0-9]\+\. \*\*' CLAUDE.md`, que devolve **25** — casa toda linha numerada em negrito do arquivo (anti-patterns, packaging, higiene de branches, migrations) e perde os itens 1–10 do próprio DoD, que não são negrito. Trocar o número pelo comando só ajuda se o comando responder à pergunta. Não declare pronto
sem: typecheck/lint zerados, testes relevantes verdes, RLS testada se tocou tabela
tenant-aware, migration (com `-- manifest:`) + baseline se mudou schema, prova visual se mudou UI, e a
regra de packaging acima se mudou o artefato que o self-hoster instala.

## Guias do assistente (skills embutidas)

O repositório embute guias em `.agents/skills/` — lidos por Codex, Cursor, OpenCode e
Antigravity; o Claude Code lê o espelho em `.claude/skills/` (`pnpm skills:sync` regrava, e
`tests/unit/skills-embutidas.test.ts` reprova divergência). Carregue o guia quando o pedido
casar, mesmo que a pessoa não saiba que ele existe. Fora de um clone (ou num clone antigo),
`bash scripts/instalar-guias.sh` liga os guias nas pastas globais dos cinco CLIs:

| situação | guia |
|---|---|
| instalar, atualizar ou consertar a instalação numa VPS; domínio, Supabase, WhatsApp que não conecta | `deskcomm-instalar` |
| configurar o CRM para um cliente ou nicho: agentes, roteadores, follow-ups, base de conhecimento | `deskcomm-cliente-novo` |
| revender como SaaS: painel do dono, planos, cobrança pela Stripe ou pelo Asaas, régua, contrato com o cliente final | `deskcomm-saas` |
| desempenho, conversão, custo de IA, funil, relatório | `deskcomm-metricas` |
| atualizar aulas, traduções e capturas do curso com as mudanças do produto | `deskcomm-curso` |
| o agente responde errado, passa tudo para humano, não usa a agenda; melhorar o prompt | `deskcomm-prompt` |
| contribuir: corrigir bug, abrir ou atualizar PR, migration, conflito com a `main` | `deskcomm-contribuir` — que fica quieto quando `bash .agents/skills/deskcomm-contribuir/scripts/quem-sou.sh` responde `mantenedor` |
| escrever ou revisar código aqui | `deskcomm-doutrina` (as três regras que mais custam) e `sistema-vivo` (o gate de arquitetura) |

Os guias têm vitrine pública em [deskcomm.com.br/guias](https://www.deskcomm.com.br/guias) (pt-BR,
en, es), escrita à mão no repositório `deskcomm-site` (`conteudo/guias.ts`). Guia criado,
renomeado ou com comando novo → o PR avisa que aquela página precisa acompanhar. Ela sai do mesmo
PR do `deskcomm-site` que a página de changelog; quem quiser saber se já está no ar usa o `curl` da
seção "A vitrine" em [`docs/doctrine/versionamento.md`](docs/doctrine/versionamento.md).

## Regra final — não invente

Este repositório tem PRDs, specs, regras de negócio e doutrina escritos
(`docs/prd/`, `docs/specs/`, `docs/business-rules/`, `docs/doctrine/`).
**Nunca invente regra de negócio, número, SLA ou comportamento de produto.**
Se a regra não está escrita, diga que não está e pergunte — não preencha a lacuna com
suposição plausível. Ao documentar, marque o que é `CONFIRMADO` (provado por código) e o
que é `INFERIDO`.
**Release** — mudança de comportamento visível a quem opera uma VPS traz o fragmento em
`.changes/` declarando o **efeito no operador** (`nada_mudou` / `capacidade_nova` / `exige_acao`),
nunca o número. O número é calculado a partir do conjunto; confira com `pnpm release:conferir` e
corte com `pnpm release:cortar`. Régua e porquê: [`docs/doctrine/versionamento.md`](docs/doctrine/versionamento.md).
Quem instalou lê o [`CHANGELOG.md`](CHANGELOG.md) antes de rodar `update.sh` — mudança que exige
ação manual aparece sob "⚠️ Requer atenção".
Toda versão publicada aparece também em [deskcomm.com.br/changelog](https://www.deskcomm.com.br/changelog)
(pt-BR, en, es): a LP lê o `CHANGELOG.md` da `main`, ninguém escreve release no site, e o último
passo do corte reprova se a versão não chegou lá. Mudar o cabeçalho `## [X.Y.Z] — AAAA-MM-DD` quebra
essa página — ver "A vitrine" em `docs/doctrine/versionamento.md`. Enquanto as três páginas não
responderem 200, esse passo reprova TODO corte; o `curl` que mede isso abre aquela seção.

**Regra final — não invente.** Este repositório tem PRDs, specs, regras de negócio e doutrina
escritos. Nunca invente regra de negócio, número, SLA ou comportamento de produto. Se a regra não
está escrita, diga que não está e pergunte. Ao documentar, marque o que é **CONFIRMADO** (provado
por código) e o que é **INFERIDO**.

<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->