Files
DeskcommCRM/docs/specs/14-contrato-governanca-agentes-externos.md
Rafael Melgaço f25cd3e2ee feat(G6-04): governance contract for external agents — spec 14 [gov-loop]
docs/specs/14 — the contract the Vendaval FG phase consumes. Edge-contract
style, header verified against gov/G6 @ ddcc511. Documents the 8 governance
tools with exact Zod I/O, assignee_kind/handoff/visibility_mode semantics,
ai_dispatch_mode (external → event stays pending), and prohibitions (never
cross-org, never bypass is_blocked/force_human/bot_silenced_until — 4 distinct
guards). Bidirectional 1:1 coverage (all 8 governance tools documented, none
omitted); ~20 file:line refs opened and verified. 'Consumer changes' section
feeds FG-01.
Verified-by: gov-verifier PASS 2026-07-18T21:53:23-0300
2026-07-18 21:53:24 -03:00

23 KiB
Raw Permalink Blame History

Spec 14 — Contrato de Governança para Agentes Externos (MCP)

Verificado em 2026-07-18 contra gov/G6 @ ddcc511. Toda referência arquivo:linha abaixo foi aberta e conferida nesta data/commit. Se o código mudar, a linha muda — reverifique antes de confiar.

Autocontido. Um agente externo (o Vendaval — runtime de agentes de IA 1:N, fora deste repo) lê SÓ este documento para saber como falar com o DeskcommCRM via MCP: quais tools existem, o input/output exato de cada uma, a semântica de governança que ele precisa respeitar e o que ele nunca pode fazer. Estilo edge-contract: refs reais, proibições explícitas, zero remissão obrigatória a outro doc.

Complementa a 13-spec-governanca-atendimento.md (a doutrina de governança) e a 11-spec-mcp-server-internal.md (o servidor MCP). Doutrina soberana: CLAUDE.md do repo (multi-tenancy, RLS, RBAC, LGPD, audit, migrations).


0. Modelo mental (leia primeiro)

O DeskcommCRM expõe um servidor MCP org-scoped. O agente externo se autentica com um bearer token que carrega uma organização e um conjunto de scopes (mcp:read / mcp:write) + um role (viewer<agent<manager<admin). A partir daí:

  • organization_id vem SEMPRE do contexto de autenticação (ctx.organizationId), NUNCA do input da tool. As tools de governança carimbam isto explicitamente no cabeçalho do módulo (lib/mcp/tools/governance.ts:9). O agente externo não consegue "escolher" a org por parâmetro — ela é fixada pelo token.
  • Toda leitura passa pela RLS org-scoped + visibilidade (§4). Service role bypassa RLS, por isso cada handler ainda filtra organization_id manualmente como defesa em profundidade (ex.: lib/mcp/tools/governance.ts:72-74).
  • Toda escrita relevante gera audit (api_audit_log, fire-and-forget) — o agente externo é auditado como ator não-humano (actor_type no metadata, lib/mcp/tools/governance.ts:21-34).
  • Idempotência é responsabilidade do contrato: comandos de atribuição são idempotentes sob replay e corrida (§3.1); envio aceita idempotency_key.

Quando o agente externo assume o dispatch de uma org (§5, ai_dispatch_mode='external'), o dispatcher nativo deste repo para de tocar os eventos daquela org — o Vendaval os consome. O resto do contrato (tools, RLS, proibições) continua valendo igual.


1. Catálogo de tools (fonte única: lib/mcp/tools/catalog.ts)

O catálogo estático TOOL_CATALOG (lib/mcp/tools/catalog.ts:19-39) é a lista canônica. Um sanity check em runtime (lib/mcp/tools/index.ts:62-75) falha o boot em dev se um handler existir sem entrada no catálogo — ou vice-versa —, garantindo 1:1 entre handler e catálogo. Nenhuma tool citada aqui existe sem handler; nenhuma tool de governança do catálogo ficou de fora deste contrato.

Tool Categoria catalog.ts handler Governança de atendimento? Neste contrato
crm_search_contacts read L21 contacts.ts não (busca genérica) fora do escopo
crm_get_contact read L22 contacts.ts não (leitura genérica) fora do escopo
crm_list_conversations read L23 conversations.ts:31 sim (assignee_kind, tags, queue_position) §3.5
crm_get_conversation read L24 conversations.ts:94 sim §3.5
crm_get_conversation_history read L25 conversations.ts:148 adjacente (contexto de mensagens) §3.5 (menção)
crm_get_queue_status read L26 governance.ts:236 sim (fila) §3.3
crm_list_leads read L27 leads.ts:82 sim (owner, stage, tags) §3.6
crm_get_lead read L28 leads.ts:124 sim §3.6
crm_list_pipelines read L29 pipelines.ts não (metadados de pipeline) fora do escopo
crm_create_lead write L31 leads.ts:173 não (CRUD de lead) fora do escopo
crm_update_lead write L32 leads.ts:227 não (CRUD de lead) fora do escopo
crm_move_lead_stage write L33 leads.ts:263 não (CRUD de lead) fora do escopo
crm_send_whatsapp_message write L34 messages.ts:38 não (envio) — mas tocado pelas proibições (is_blocked) §6.2
crm_assign_conversation write L35 governance.ts:54 sim §3.1
crm_manage_tags write L36 governance.ts:174 sim §3.2
crm_request_human_handoff handoff L38 handoff.ts:38 sim §3.4

As 8 tools de governança que o Vendaval consome (todas documentadas em §3): crm_assign_conversation, crm_manage_tags, crm_get_queue_status, crm_request_human_handoff, crm_get_conversation, crm_list_conversations, crm_get_lead, crm_list_leads.

As tools fora do escopo deste contrato de governança são leitura/CRUD genérico (contatos, pipelines, CRUD de lead) e não expõem nem alteram estado de governança de atendimento. crm_send_whatsapp_message não é de governança, mas as proibições §6.2 a tocam (guard is_blocked), então está documentada lá.


2. Convenções de todas as tools

  • Role/scope por tool são checados no server core antes do handler. As tools de governança exigem role>=agent + o scope da categoria (mcp:read para read, mcp:write para write/handoff) — ver requiresRole/requiresScope em cada definição (ex.: governance.ts:63-64). As tools de CRUD de lead exigem role>=manager (leads.ts:179-180).
  • Input é validado por Zod no handler; violação lança erro (o agente recebe a mensagem de erro da tool). Campos com .default(...) são opcionais.
  • organization_id nunca é input. Não existe em nenhum inputShape de governança — é injetado do ctx.
  • Erros são strings de código (ex.: conversation_not_found, assignment_conflict, target_not_found) — sem PII (LGPD).

3. As tools de governança (input/output exatos)

3.1 crm_assign_conversation — atribuir / transferir / liberar

Handler: lib/mcp/tools/governance.ts:54. Categoria write, role>=agent, scope mcp:write.

Input (governance.ts:40-45):

Campo Tipo Default Regra
conversation_id uuid — obrigatório
to_user_id uuid | null null null = release (volta à fila)
reason "transfer" | "release" "transfer" cross-field abaixo

Cross-field (governance.ts:48-52): release ⇔ to_user_id null; transfer ⇔ to_user_id preenchido. Violar isso rejeita antes de qualquer efeito.

Output — sempre um objeto (governance.ts:141-147 / 84-90 / 118-124):

{ "assigned": true, "conversation_id": "<uuid>", "assigned_to_user_id": "<uuid|null>",
  "reason": "transfer|release", "idempotent": true|false }

Semântica de idempotência e corrida (crítica para um runtime 1:N):

  • Replay (comando repetido após já aplicado): a conversa já está no dono alvo ⇒ retorna idempotent: true sem novo assignment event (governance.ts:83-91).
  • Corrida (dois assigns idênticos concorrentes): optimistic lock via p_expected_assignee — o 1º vence com 1 evento; o 2º vê 0 rows, re-lê e, se o dono já é o alvo, devolve idempotent: true; senão assignment_conflict (governance.ts:98-127).
  • O efeito real (UPDATE + assignment event na mesma transação) é a RPC fn_conversation_assign (migration 0031/0032). Recusa cross-org — ver §6.1.
  • Sucesso não-idempotente grava audit conversation.transferred|released (governance.ts:130-139).

3.2 crm_manage_tags — add/remove tags

Handler: lib/mcp/tools/governance.ts:174. Categoria write, role>=agent, scope mcp:write.

Input (governance.ts:167-172):

Campo Tipo Regra
target_kind "conversation" | "contact" | "lead" obrigatório
target_id uuid obrigatório; alvo tem que ser da org (governance.ts:196-197)
add string[] opcional
remove string[] opcional

Validações (mesma regra G3-05, governance.ts:185-205): cada tag é normalizada (lowercase, trim, ≤40 chars) por conversationTagSchema; o conjunto final é deduplicado e limitado a ≤20 tags por conversationTagsSchema (rejeita se estourar). É obrigatório ao menos um de add/remove (governance.ts:187-189).

Output (governance.ts:226):

{ "target_kind": "conversation|contact|lead", "target_id": "<uuid>", "tags": ["..."] }

Grava audit conversation.tags_changed|contact.tags_changed|lead.tags_changed (governance.ts:214-224).

3.3 crm_get_queue_status — snapshot da fila

Handler: lib/mcp/tools/governance.ts:236 → getQueueStatus (lib/routing/queue.ts:27). Categoria read, role>=agent, scope mcp:read.

Input: vazio (governance.ts:234).

Output (lib/routing/queue.ts:21-25, 53-57):

{ "queue_size": 0, "avg_wait_seconds": 0, "online_eligible_count": 0 }
  • queue_size: conversas na fila = sem dono ∧ status='open' (queue.ts:32-37).
  • avg_wait_seconds: média de now − last_inbound_at das conversas na fila, em segundos, arredondada; 0 se fila vazia (queue.ts:42-49).
  • online_eligible_count: atendentes elegíveis AGORA (disponível ∧ horário ∧ com folga de capacidade) — quem pode puxar da fila (queue.ts:51).

3.4 crm_request_human_handoff (v2) — bot → humano

Handler: lib/mcp/tools/handoff.ts:38. Categoria handoff, role>=agent, scope mcp:write.

Input (handoff.ts:29-36):

Campo Tipo Default Nota
conversation_id uuid — obrigatório
reason string (1..500) "requested_human"
urgency "low"|"normal"|"high" "normal"
target_user_id uuid — opcional: só atribui se elegível agora; senão rodízio G5
metadata record — opcional

Semântica v2 — um algoritmo de destino (roteamento G5) (handoff.ts:94-160):

  1. Marca a conversa pending, silencia o bot (bot_silenced_until='infinity'), registra activity + event_log (ai.handoff_triggered) + audit — via triggerHandoff (handoff.ts:76-88, cabeçalho handoff.ts:1-18).
  2. Escolhe o destino entre os elegíveis (loadEligibleAttendants): usa target_user_id se elegível agora, senão rodízio real selectRoundRobin (handoff.ts:97-101).
  3. Se escolheu alguém: reassignment auditado kind ai→'user', evento reason='handoff' na mesma transação (fn_conversation_assign, handoff.ts:104-121).
  4. Sem ninguém elegível: fila — limpa assignee_kind (ai→null), insere evento reason='handoff' (from/to null), calcula posição (handoff.ts:123-160).

Output estruturado (handoff.ts:163-176) — um dos dois lados é populado:

{ "handoff_recorded": true, "conversation_id": "<uuid>",
  "assigned_to": "<uuid|null>", "queued": false, "position": null,
  "assigned_to_user_id": "<uuid|null>", "idempotent": false,
  "next_action": "Avise o cliente ..." }
  • Atribuído: assigned_to = uuid, queued=false, position=null.
  • Enfileirado: assigned_to=null, queued=true, position=<1-based>.
  • assigned_to_user_id é alias de compat com o contrato anterior (handoff.ts:170-171).
  • idempotent=true quando o handoff foi suprimido pela janela de 5s (handoff.ts:172).

3.5 Read tools de conversa (campos de governança expostos — G6-03)

crm_list_conversations (lib/mcp/tools/conversations.ts:31) e crm_get_conversation (conversations.ts:94). Categoria read, role>=agent, scope mcp:read.

Cada conversa no payload expõe os campos de governança (conversations.ts:66-83 para list; 117-138 para get):

Campo Fonte Semântica
assignee_kind conversations.assignee_kind 'user' (humano atende) | 'ai' (IA atende) | null (fila/sem dono) — §3.7
assigned_to_user_id coluna uuid do dono ou null
assigned_to_user_name resolvido só o nome do atendente, sem email/telefone (LGPD, conversations.ts:74-76)
tags conversations.tags string[]
queue_position getQueuePositions posição 1-based na fila do inbox; null quando não está na fila (conversations.ts:19-22, 78)

crm_get_conversation_history (conversations.ts:148) é adjacente (histórico de mensagens para contexto) — não expõe campos de governança novos.

Como ler assignee_kind (uso pelo Vendaval): antes de responder, o agente externo lê assignee_kind. 'user' ⇒ um humano já assumiu — não intervir (§6.2). 'ai' ⇒ a IA é a dona. null ⇒ fila/sem dono.

3.6 Read tools de lead (campos de governança expostos — G6-03)

crm_list_leads (lib/mcp/tools/leads.ts:82) e crm_get_lead (leads.ts:124). Categoria read, role>=agent, scope mcp:read.

Enriquecimento aditivo (enrichLeads, leads.ts:34-67):

Campo Fonte Semântica
owner_user_id crm_leads.owner_user_id uuid do dono ou null
owner_user_name resolvido só o nome do dono, sem email/telefone (LGPD, leads.ts:62-64)
stage join crm_stages { id, name } legível (além do stage_id cru, leads.ts:65)
stage_id, status, tags row (select *) inalterados

3.7 assignee_kind — semântica canônica

Coluna conversations.assignee_kind (migration 0032_conversation_assignee_kind, supabase/migrations/20260717150000_0032_conversation_assignee_kind.sql:33-34), com constraint de coerência (0032:57-62):

  • 'user' ⇔ assigned_to_user_id is not null (humano atende);
  • 'ai' ⇔ assigned_to_user_id is null (IA atende);
  • null (fila/sem dono).

Handoff = reassignment auditado. IA→humano muda assignee_kind de 'ai' para 'user' com reason='handoff' na RPC fn_conversation_assign (§3.4). A conversa com assignee_kind='user' silencia o bot nativo deterministicamente (workers/ai-response-worker.ts:287 → skip("assigned_to_human")), na mesma família de guard que force_human e bot_silenced_until (§6.2).


4. visibility_mode — o que o agente externo PODE ler

visibility_mode vive em organizations.settings (jsonb), valores 'all' | 'own_and_unassigned' | 'own', default 'own_and_unassigned' (migration 0035, supabase/migrations/20260717200000_0035_visibility_mode_conversation_rls.sql:1-3). Restringe apenas o role agent; viewer/manager/admin veem toda a org.

O agente externo lê via MCP org-scoped e sujeito à RLS. A visibilidade é imposta no banco, não na aplicação — o agente não vê além do escopo:

  • Conversas: policy conversations_select usa fn_can_view_conversation(org, assigned_to_user_id) (0035:25-57). Para agent: 'all' = tudo da org; 'own_and_unassigned' = suas + fila não-atribuída; 'own' = só as suas (0035:36-44). Mensagens herdam (conversa oculta ⇒ mensagens ocultas, 0035:91-93).
  • Leads: policy crm_leads_select usa fn_can_view_lead(org, owner_user_id) (migration 0036, supabase/migrations/20260717210000_0036_visibility_mode_lead_rls.sql:32-70), mesma lógica sobre owner_user_id.
  • Filhas de lead: crm_lead_activities e crm_lead_links só são visíveis se o lead-pai é visível — SELECT via EXISTS na mesma fn_can_view_lead (migration 0042, G6-00/INB-10, supabase/migrations/20260718160000_0042_lead_children_visibility_rls.sql:41-56). Fecha o vazamento de herança (activities/links de lead invisível).

Regra para o Vendaval: trate o que a tool retorna como o teto do que existe para aquele token. Se uma conversa/lead não veio, para aquele escopo ela não existe — não infira nem tente acessá-la por id direto (a RLS + o filtro manual de org no handler barram, ex.: governance.ts:72-74, leads.ts:143-146).


5. ai_dispatch_mode — quem despacha os eventos de IA (G6-02)

organizations.settings.ai_dispatch_mode, valores 'native' | 'external', default 'native', com fail-safe .catch("native") para chave ausente/null/inválida (schema Zod aiDispatchModeSchema, lib/schemas/settings.ts:22-24).

Semântica (dispatcher nativo, lib/ai/dispatcher/index.ts):

  • O dispatcher nativo resolve o modo de todas as orgs do batch numa query (loadDispatchModes, index.ts:352-371), aplicando o mesmo schema Zod (index.ts:368).
  • Org 'external': o dispatcher nativo PULA o evento ANTES do claim — skipped_external_dispatch, sem claim, sem mudança de status, consumed_by intacto (index.ts:143-151). O evento permanece pending no event_log para o Vendaval consumir.
  • Org 'native' (ou default): processa como hoje (claim + dispatch, index.ts:153-171).

Regra para o Vendaval: nas orgs que ele atende, setar settings.ai_dispatch_mode='external' e consumir os eventos ai_agent.dispatch_requested pending do event_log (§7). Se esquecer de setar, o dispatcher nativo processa o evento primeiro (default 'native') — não há dupla-execução, mas o Vendaval não recebe o evento.


6. Proibições explícitas (o que o agente externo NUNCA pode fazer)

6.1 NUNCA cross-org

Toda tool é org-scoped pelo ctx — organization_id vem do token, nunca do input (governance.ts:9). Além disso:

  • crm_assign_conversation: a conversa tem que ser da org (filtro manual, governance.ts:72-74) e o destino tem que ser membro ativo agent+ da MESMA org — guard INB-06a dentro de fn_conversation_assign v2, que faz RAISE se o destino não é membro (migration 0032, supabase/migrations/20260717150000_0032_conversation_assignee_kind.sql:101-145, comentário 0032:117). O membership/role não é enumerável cross-org (a validação vive DENTRO da função, 0032:15-22).
  • crm_manage_tags: filtro organization_id explícito no fetch e no update (governance.ts:196-197, 210-211).
  • crm_get_lead: defesa em profundidade — 404 se a row não é da org (leads.ts:143-146).

Um agente externo nunca atribui, lê ou etiqueta recurso de outra org.

6.2 NUNCA furar is_blocked / force_human / bot silenciado

  • contacts.is_blocked guarda o SEND outbound. crm_send_whatsapp_message (messages.ts:38) roteia por sendMessageHandler, que rejeita com 403 forbidden "Contato bloqueou o atendimento." se o contato está bloqueado (app/api/v1/messages/_handler.ts:151-159). O agente externo não pode enviar a contato bloqueado — a guarda é server-side, não confie em checar no seu lado. (O STOP inbound seta is_blocked=true automaticamente — CLAUDE.md §WAHA.)
  • contacts.force_human e conversations.assignee_kind='user' e conversations.bot_silenced_until > now() são os guards de "não deixe o bot responder": o worker nativo pula nos três casos (workers/ai-response-worker.ts:283-297). Para o Vendaval, a regra equivalente é: se assignee_kind='user' (§3.5) um humano assumiu — não intervenha; e ele não pode reativar um bot silenciado (o bot_silenced_until='infinity' do handoff, lib/ai/handoff/orchestrator.ts:96, só o desfaz uma ação humana no CRM).

6.3 Outras invariantes que o código impõe

  • Audit obrigatória em escrita. Toda mutação de governança grava api_audit_log (fire-and-forget) com o ator carimbado (governance.ts:130-139, 214-224). O agente externo não desliga isso.
  • Idempotência sob replay/corrida em crm_assign_conversation (§3.1): não conte com "aplicar duas vezes" — o contrato deduplica.
  • Tags normalizadas e limitadas (§3.2): não envie tags fora do formato — são rejeitadas, não truncadas silenciosamente.
  • PII fora de payloads: as read tools devolvem só o nome de atendente/dono, nunca email/telefone (conversations.ts:74-76, leads.ts:62-64) — o agente externo não deve tentar re-derivar PII.

7. Mudanças requeridas em consumidores (insumo do FG-01)

O que o Vendaval precisa fazer do seu lado para consumir este contrato:

  1. Habilitar o modo externo por org. Setar organizations.settings.ai_dispatch_mode='external' (§5) nas orgs que o Vendaval atende. Sem isso, o dispatcher nativo processa os eventos (default 'native').
  2. Consumir os eventos pending do event_log. Para orgs 'external', o evento ai_agent.dispatch_requested permanece pending com consumed_by intacto (lib/ai/dispatcher/index.ts:143-151). O Vendaval faz o claim/consumo do seu lado (a mesma tabela event_log, filtrando event_type + status='pending'). Deve marcar o evento como consumido para não reprocessar.
  3. Autenticar org-scoped. Um token MCP por org, com role>=agent e scopes mcp:read+mcp:write. organization_id NUNCA vai no input das tools — é o token que fixa a org.
  4. Ler assignee_kind antes de responder (§3.5/§3.7): 'user' ⇒ humano assumiu, não intervir; 'ai'/null ⇒ pode agir.
  5. Usar crm_request_human_handoff v2 para o fallback humano (§3.4) — deixar o roteamento G5 escolher o destino (passar target_user_id só como preferência); tratar os dois formatos de retorno (assigned_to OU queued+position).
  6. Respeitar visibilidade (§4): tratar o retorno das read tools como o teto do que existe; não tentar acessar id fora do escopo.
  7. Respeitar as proibições §6: não enviar a is_blocked; não intervir em conversa assignee_kind='user'; não reativar bot silenciado; nunca cross-org.
  8. Idempotência no envio: passar idempotency_key em crm_send_whatsapp_message (TTL 24h, messages.ts:26-31) em retries.

Fora do escopo deste contrato (decisões do FG do Vendaval): o modelo de claim do Vendaval sobre o event_log (visibilidade/lock do consumo externo) e o mapeamento de credenciais org→token. Este contrato define a superfície do DeskcommCRM; o lado do Vendaval é o FG-01.


Apêndice A — Refs verificadas em 2026-07-18 @ ddcc511

Ref Confere
lib/mcp/tools/catalog.ts:19-39 TOOL_CATALOG (16 entradas)
lib/mcp/tools/index.ts:62-75 sanity handler↔catalog 1:1
lib/mcp/tools/governance.ts:9 org do ctx, nunca do input
lib/mcp/tools/governance.ts:40-52 input crm_assign_conversation + cross-field
lib/mcp/tools/governance.ts:83-127 idempotência replay + corrida
lib/mcp/tools/governance.ts:167-205,226 input/validações/output crm_manage_tags
lib/mcp/tools/governance.ts:234-248 crm_get_queue_status
lib/routing/queue.ts:21-57 shape QueueStatus + getQueueStatus
lib/mcp/tools/handoff.ts:29-176 input/semântica/output crm_request_human_handoff v2
lib/mcp/tools/conversations.ts:66-83,117-138 campos de governança expostos
lib/mcp/tools/leads.ts:34-67 enrichLeads (owner_user_name, stage)
supabase/migrations/…0032…:33-62,101-145 assignee_kind + guard INB-06a
supabase/migrations/…0035…:1-57 visibility_mode + fn_can_view_conversation
supabase/migrations/…0036…:32-70 fn_can_view_lead
supabase/migrations/…0042…:41-56 filhas de lead visibility (G6-00)
lib/schemas/settings.ts:22-24 aiDispatchModeSchema + .catch("native")
lib/ai/dispatcher/index.ts:143-151,352-371 skip antes do claim (org external)
app/api/v1/messages/_handler.ts:151-159 guard is_blocked no SEND
workers/ai-response-worker.ts:283-297 guards is_blocked/force_human/assignee_kind/bot_silenced_until
lib/ai/handoff/orchestrator.ts:96 bot_silenced_until='infinity' no handoff