feat(G3-02): first-class AI assignee — assignee_kind + bot veto + INB-06a guard [gov-loop]

Migration 0032: assignee_kind coherence CHECK + backfill; handoff writes
reason=handoff event (kind ai->user or queue); ai-response-worker vetoes bot
when kind='user'. INB-06a forward-fix: fn_conversation_assign validates dest
membership via fn_member_role_in_org (revoke execute from anon — closes the
cross-org enumeration leak the verifier caught). Ratchet flip: 1 GAP(G3)
it.fails in gov-6-ai-handoff.test.ts flipped (DESKCOMM_GOV_INVARIANTS_EDIT=1).
Verified-by: gov-verifier PASS 2026-07-17T08:38:44-0300 (repaired 1 round)
This commit is contained in:
Rafael Melgaço
2026-07-17 08:38:44 -03:00
parent 84f8d93eb8
commit c23f71ee44
14 changed files with 901 additions and 21 deletions
+6 -3
View File
@@ -102,17 +102,20 @@ pipeline do bot vetado deterministicamente (mesma família de guard de
`force_human`/`bot_silenced_until`).
```sql
-- RASCUNHO (migration real em G3-02)
-- Migration real: 0032_conversation_assignee_kind (G3-02)
alter table conversations
add column if not exists assignee_kind text
check (assignee_kind in ('user','ai'));
-- Coerência com assigned_to_user_id: null = sem atendente (fila).
-- Coerência com assigned_to_user_id em FORMA DE IMPLICAÇÃO (acceptance G3-02):
-- kind='user' ⇒ dono humano; kind='ai' ⇒ sem dono; kind null é livre — escritas
-- legadas que não conhecem a coluna (ex.: PATCH de status) continuam válidas e
-- a semântica forte chega pelos caminhos canônicos (fn_conversation_assign).
alter table conversations
add constraint conversations_assignee_kind_coherence check (
(assignee_kind = 'user' and assigned_to_user_id is not null) or
(assignee_kind = 'ai' and assigned_to_user_id is null) or
(assignee_kind is null and assigned_to_user_id is null)
(assignee_kind is null)
);
-- Backfill (na migration, ANTES da constraint — doutrina de migrations §8):
+1
View File
@@ -35,6 +35,7 @@ export type SkipReason =
| "kb_version_missing"
| "contact_blocked"
| "force_human"
| "assigned_to_human"
| "window_24h_expired"
| "budget_throttled"
| "silenced_post_handoff"
+8
View File
@@ -1267,6 +1267,7 @@ export type Database = {
Row: {
assigned_at: string | null;
assigned_to_user_id: string | null;
assignee_kind: string | null;
bot_silenced_until: string | null;
channel: string;
channel_session_id: string;
@@ -1295,6 +1296,7 @@ export type Database = {
Insert: {
assigned_at?: string | null;
assigned_to_user_id?: string | null;
assignee_kind?: string | null;
bot_silenced_until?: string | null;
channel?: string;
channel_session_id: string;
@@ -1323,6 +1325,7 @@ export type Database = {
Update: {
assigned_at?: string | null;
assigned_to_user_id?: string | null;
assignee_kind?: string | null;
bot_silenced_until?: string | null;
channel?: string;
channel_session_id?: string;
@@ -2743,6 +2746,7 @@ export type Database = {
Returns: {
assigned_at: string | null;
assigned_to_user_id: string | null;
assignee_kind: string | null;
bot_silenced_until: string | null;
channel: string;
channel_session_id: string;
@@ -2803,6 +2807,10 @@ export type Database = {
};
Returns: undefined;
};
fn_member_role_in_org: {
Args: { p_org: string; p_user: string };
Returns: string;
};
fn_publish_ai_agent_version: {
Args: { p_agent_id: string; p_org_id: string; p_version_id: string };
Returns: {
+55 -11
View File
@@ -7,7 +7,10 @@
* - event_log INSERT event_type='ai.handoff_triggered'
* - Realtime broadcast `org:<org>:queue` event=handoff_pending
* - api_audit_log action='ai.handoff_triggered'
* - conversations.assigned_to_user_id round-robin entre membros agent+ ativos
* - G3-02: handoff é reassignment auditado — com elegível (round-robin agent+),
* fn_conversation_assign move kind ai→'user' + evento reason='handoff' na
* MESMA transação; sem elegível, conversa vai à fila (assigned null, kind
* null) e o evento reason='handoff' é gravado do mesmo jeito.
*
* Nenhum mirror REST. Wave 4 introduz como tool MCP only.
*/
@@ -15,6 +18,7 @@ import { z } from "zod";
import type { SupabaseClient } from "@supabase/supabase-js";
import { triggerHandoff } from "@/lib/ai/handoff/orchestrator";
import { logger } from "@/lib/logger";
import type { McpToolDefinition } from "../types";
const inputShape = {
@@ -103,23 +107,63 @@ export const crmRequestHumanHandoff: McpToolDefinition<typeof inputShape> = {
let assignedUserId: string | null = null;
if (result.triggered) {
assignedUserId = await pickRoundRobinAssignee(
const picked = await pickRoundRobinAssignee(
ctx.supabase,
ctx.organizationId,
input.suggested_assignee_role ?? "agent",
);
if (assignedUserId) {
const { error: assignErr } = await ctx.supabase
if (picked) {
// G3-02: reassignment auditado — UPDATE (kind ai→'user') + evento
// reason='handoff' na MESMA transação (fn_conversation_assign, 0031/0032).
// Service role: auth.uid() null → changed_by null (sistema).
const { data: rows, error: assignErr } = await ctx.supabase.rpc(
"fn_conversation_assign",
{
p_organization_id: ctx.organizationId,
p_conversation_id: input.conversation_id,
p_to_user_id: picked,
p_reason: "handoff",
p_enforce_expected: false,
},
);
if (assignErr || !Array.isArray(rows) || rows.length === 0) {
logger.warn("[mcp.handoff] assignment failed", {
conversation_id: input.conversation_id,
error: assignErr?.message ?? "0 rows (conversation not found)",
});
} else {
assignedUserId = picked;
}
}
if (!assignedUserId) {
// Fila (roteamento vigente sem elegível): sem dono, kind sai de 'ai' →
// null; o handoff continua auditado (evento reason='handoff', from/to null).
const { error: kindErr } = await ctx.supabase
.from("conversations")
.update({
assigned_to_user_id: assignedUserId,
assigned_at: new Date().toISOString(),
})
.update({ assignee_kind: null })
.eq("id", input.conversation_id)
.eq("organization_id", ctx.organizationId);
if (assignErr) {
console.error("[mcp.handoff] assignment failed", assignErr.message);
assignedUserId = null;
if (kindErr) {
logger.warn("[mcp.handoff] assignee_kind clear failed", {
conversation_id: input.conversation_id,
error: kindErr.message,
});
}
const { error: eventErr } = await ctx.supabase
.from("conversation_assignment_events")
.insert({
organization_id: ctx.organizationId,
conversation_id: input.conversation_id,
from_user_id: null,
to_user_id: null,
changed_by: null,
reason: "handoff",
});
if (eventErr) {
logger.warn("[mcp.handoff] handoff event insert failed", {
conversation_id: input.conversation_id,
error: eventErr.message,
});
}
}
}
+8 -2
View File
@@ -303,8 +303,14 @@
"priority": 20,
"lane": "core",
"kind": "build",
"passes": false,
"verification": null
"passes": true,
"verification": {
"verdict": "PASS",
"by": "gov-verifier",
"at": "2026-07-17T08:38:44-0300",
"commit": "self",
"note": "repaired-1-round: anon leak on fn_member_role_in_org closed, re-verified fresh"
}
},
{
"id": "G3-03",
+22
View File
@@ -218,3 +218,25 @@
(schema_migrations parou na 0027; 0031 aplicada só pro screenshot).
- Próxima sessão: G3-02 (assignee_kind) destravou; G3-03 também elegível — a
regra manda menor priority ⇒ G3-02 (prio 20).
## 2026-07-17 — sessão 14 do loop (core) — G3-02 (1 rodada de reparo)
- G3-02 (assignee_kind): migration 0032 em tripla — coluna assignee_kind +
CHECK de coerência (forma de implicação, verbatim do acceptance) + backfill
antes da constraint. Handoff grava evento reason=handoff (kind ai→user com
elegível / fila sem elegível). Veto determinístico do bot no ai-response-worker
(kind='user' ⇒ skip 'assigned_to_human'). Forward-fix INB-06a: guard de
membership dentro de fn_conversation_assign via helper fn_member_role_in_org.
- FAIL na 1ª verificação: fn_member_role_in_org (SECURITY DEFINER) executável
por anon (grant herdado de ALTER DEFAULT PRIVILEGES do baseline) + ramo
auth.uid() null respondia a request anônimo → enumeração de role cross-tenant
sem autenticar. Reparo (1 rodada): revoke execute from anon explícito nas 2
cópias (migration+baseline) + invariante que prova permission denied SOB role
anon real + service_role ainda servido. Re-verificação FRESCA: PASS, hash OK.
- 47 invariantes + 135 unit verdes. database.types.ts editado à mão (gen do
container poluiria Functions com extensões).
- INB-07 aberto: varredura do verifier achou 6 SECURITY DEFINER de ESCRITA
anon-executáveis pré-existentes (fn_upsert_wa_*, emit_event, fn_log_event,
fn_audit_log_row, fn_mark_conversation_message) — gap do baseline, não da
G3-02. Os helpers RLS caller-scoped (fn_user_*) NÃO vazam (probe: anon → null).
- Próxima sessão: G3-03 (dono do lead no kanban, prio 30) — elegível.
+135
View File
@@ -4364,3 +4364,138 @@ $$;
revoke all on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) from public;
grant execute on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean)
to authenticated, service_role;
-- ---- assignee_kind + guard de membership na fn_conversation_assign (migration 0032) ----
-- G3-02 (gov-loop): IA como assignee de 1ª classe (spec 13 §3.2). Coluna
-- conversations.assignee_kind ('user'|'ai') + CHECK de coerência em forma de
-- implicação (kind='user' ⇒ dono humano; kind='ai' ⇒ sem dono; kind null livre
-- pra escritas legadas). Backfill ANTES da constraint (auto-curativo em clones).
-- Forward-fix INB-06a: fn_conversation_assign valida DENTRO da função que o
-- destino é membro ativo agent+ da org (via fn_member_role_in_org, SECURITY
-- DEFINER) e mantém assignee_kind coerente em claim/transfer/release/handoff.
-- fn_member_role_in_org é executável APENAS por authenticated (responde só a
-- membro ativo da org) e service_role (auth.uid() null); anon tem EXECUTE
-- revogado EXPLICITAMENTE — o default privilege do Supabase concede EXECUTE a
-- anon em toda função nova e o JWT anon também tem uid null.
alter table public.conversations
add column if not exists assignee_kind text
check (assignee_kind in ('user','ai'));
update public.conversations
set assignee_kind = 'user'
where assigned_to_user_id is not null
and assignee_kind is distinct from 'user';
update public.conversations
set assignee_kind = null
where assigned_to_user_id is null
and assignee_kind = 'user';
update public.conversations
set assignee_kind = 'ai'
where status = 'ai_handling'
and assigned_to_user_id is null
and assignee_kind is distinct from 'ai';
alter table public.conversations
drop constraint if exists conversations_assignee_kind_coherence;
alter table public.conversations
add constraint conversations_assignee_kind_coherence check (
(assignee_kind = 'user' and assigned_to_user_id is not null) or
(assignee_kind = 'ai' and assigned_to_user_id is null) or
(assignee_kind is null)
);
create or replace function public.fn_member_role_in_org(p_user uuid, p_org uuid)
returns text
language sql stable security definer
set search_path = public
as $$
select uo.role
from public.user_organizations uo
where uo.user_id = p_user
and uo.organization_id = p_org
and uo.revoked_at is null
and (
auth.uid() is null
or exists (
select 1 from public.user_organizations me
where me.user_id = auth.uid()
and me.organization_id = p_org
and me.revoked_at is null
)
)
limit 1;
$$;
revoke all on function public.fn_member_role_in_org(uuid, uuid) from public;
-- O revoke from public NÃO cobre o grant DIRETO que anon carrega via
-- ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon (padrão
-- Supabase). Sem esta linha, o PostgREST expõe a função como RPC pública
-- (anon key vai pro browser) e o ramo auth.uid() null responde a request
-- anônimo — enumeração de membership/role de qualquer tenant.
revoke execute on function public.fn_member_role_in_org(uuid, uuid) from anon;
grant execute on function public.fn_member_role_in_org(uuid, uuid)
to authenticated, service_role;
create or replace function public.fn_conversation_assign(
p_organization_id uuid,
p_conversation_id uuid,
p_to_user_id uuid,
p_reason text,
p_expected_assignee uuid default null,
p_enforce_expected boolean default false
) returns setof public.conversations
language plpgsql
set search_path = public
as $$
declare
v_from uuid;
v_conv public.conversations%rowtype;
begin
if p_to_user_id is not null then
if coalesce(public.fn_member_role_in_org(p_to_user_id, p_organization_id), 'none')
not in ('agent','manager','admin') then
raise exception 'assignee_not_eligible_member'
using hint = 'target must be an active agent+ member of the organization';
end if;
end if;
select assigned_to_user_id into v_from
from public.conversations
where id = p_conversation_id
and organization_id = p_organization_id
for update;
if not found then
return;
end if;
if p_enforce_expected and v_from is distinct from p_expected_assignee then
return;
end if;
update public.conversations
set assigned_to_user_id = p_to_user_id,
assigned_at = case when p_to_user_id is null then null else now() end,
assignee_kind = case when p_to_user_id is null then null else 'user' end,
status = case when p_to_user_id is null then 'open' else 'claimed' end,
status_changed_at = now(),
unread_count_for_assignee = 0,
updated_at = now()
where id = p_conversation_id
returning * into v_conv;
insert into public.conversation_assignment_events
(organization_id, conversation_id, from_user_id, to_user_id, changed_by, reason)
values
(p_organization_id, p_conversation_id, v_from, p_to_user_id, auth.uid(), p_reason);
return next v_conv;
end;
$$;
revoke all on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) from public;
grant execute on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean)
to authenticated, service_role;
@@ -0,0 +1,164 @@
-- 0032_conversation_assignee_kind
-- G3-02 (gov-loop): IA como assignee de 1ª classe (spec 13 §3.2) + forward-fix
-- INB-06a na fn_conversation_assign (migration 0031).
--
-- 1. conversations.assignee_kind ('user'|'ai') desambigua quem atende: humano
-- (assigned_to_user_id) ou o bot (status legado 'ai_handling'). CHECK de
-- coerência em forma de implicação (acceptance G3-02):
-- kind='user' ⇒ assigned_to_user_id not null;
-- kind='ai' ⇒ assigned_to_user_id null;
-- kind null ⇒ sem exigência (escritas legadas que não conhecem a coluna
-- continuam válidas — a semântica forte chega pelos caminhos canônicos).
-- Backfill ANTES da constraint (doutrina de migrations §8).
-- 2. fn_member_role_in_org(p_user, p_org): helper SECURITY DEFINER (família de
-- fn_user_role_in_org) — a RLS de user_organizations só mostra o próprio
-- membership a um agent, então a validação de destino DENTRO da função
-- invoker precisa deste bypass controlado. Executável APENAS por
-- authenticated (responde só quando o caller é membro ativo da org) e
-- service_role (worker/handoff, auth.uid() null). anon tem EXECUTE
-- revogado EXPLICITAMENTE: o ALTER DEFAULT PRIVILEGES do Supabase concede
-- EXECUTE a anon em toda função nova de public, e o JWT anon também tem
-- auth.uid() null — sem o revoke, o PostgREST exporia a função como RPC
-- pública e qualquer um enumeraria membership/role cross-org.
-- 3. fn_conversation_assign v2 (INB-06a): destino usuário DEVE ser membro
-- ativo agent+ da MESMA org — validado DENTRO da função (probe H8 do
-- verifier: rpc direto atribuía a viewer/usuário de outra org). Também
-- passa a manter assignee_kind coerente ('user' quando ganha dono, null
-- quando volta à fila) em claim/transfer/release/handoff.
--
-- Idempotente, portável em psql puro (sem BEGIN/COMMIT, sem temp tables).
-- A. Coluna
alter table public.conversations
add column if not exists assignee_kind text
check (assignee_kind in ('user','ai'));
-- B. Backfill (ANTES da constraint — corrige qualquer banco de clone):
-- dono humano ⇒ 'user'; 'user' órfão (sem dono) ⇒ null; ai_handling sem
-- dono ⇒ 'ai'.
update public.conversations
set assignee_kind = 'user'
where assigned_to_user_id is not null
and assignee_kind is distinct from 'user';
update public.conversations
set assignee_kind = null
where assigned_to_user_id is null
and assignee_kind = 'user';
update public.conversations
set assignee_kind = 'ai'
where status = 'ai_handling'
and assigned_to_user_id is null
and assignee_kind is distinct from 'ai';
-- C. Constraint de coerência (drop+add — re-aplicável)
alter table public.conversations
drop constraint if exists conversations_assignee_kind_coherence;
alter table public.conversations
add constraint conversations_assignee_kind_coherence check (
(assignee_kind = 'user' and assigned_to_user_id is not null) or
(assignee_kind = 'ai' and assigned_to_user_id is null) or
(assignee_kind is null)
);
-- D. Helper: role de QUALQUER membro da org (SECURITY DEFINER). Caller
-- authenticated precisa ser membro ativo da org; auth.uid() null é o path
-- do sistema (service_role) — e SÓ dele, porque anon (que também tem uid
-- null) tem EXECUTE revogado explicitamente abaixo.
create or replace function public.fn_member_role_in_org(p_user uuid, p_org uuid)
returns text
language sql stable security definer
set search_path = public
as $$
select uo.role
from public.user_organizations uo
where uo.user_id = p_user
and uo.organization_id = p_org
and uo.revoked_at is null
and (
auth.uid() is null
or exists (
select 1 from public.user_organizations me
where me.user_id = auth.uid()
and me.organization_id = p_org
and me.revoked_at is null
)
)
limit 1;
$$;
revoke all on function public.fn_member_role_in_org(uuid, uuid) from public;
-- O revoke from public NÃO cobre o grant DIRETO que anon carrega via
-- ALTER DEFAULT PRIVILEGES ... GRANT ALL ON FUNCTIONS TO anon (padrão
-- Supabase). Sem esta linha, o PostgREST expõe a função como RPC pública
-- (anon key vai pro browser) e o ramo auth.uid() null responde a request
-- anônimo — enumeração de membership/role de qualquer tenant.
revoke execute on function public.fn_member_role_in_org(uuid, uuid) from anon;
grant execute on function public.fn_member_role_in_org(uuid, uuid)
to authenticated, service_role;
-- E. fn_conversation_assign v2 — guard INB-06a + manutenção de assignee_kind.
create or replace function public.fn_conversation_assign(
p_organization_id uuid,
p_conversation_id uuid,
p_to_user_id uuid, -- null = release (volta à fila)
p_reason text, -- claim|transfer|release|routing|handoff (CHECK da tabela)
p_expected_assignee uuid default null,
p_enforce_expected boolean default false
) returns setof public.conversations
language plpgsql
set search_path = public
as $$
declare
v_from uuid;
v_conv public.conversations%rowtype;
begin
-- INB-06a: destino usuário DEVE ser membro ativo agent+ da mesma org —
-- validado aqui (não só na rota); rpc direto não atribui a viewer/estranho.
if p_to_user_id is not null then
if coalesce(public.fn_member_role_in_org(p_to_user_id, p_organization_id), 'none')
not in ('agent','manager','admin') then
raise exception 'assignee_not_eligible_member'
using hint = 'target must be an active agent+ member of the organization';
end if;
end if;
select assigned_to_user_id into v_from
from public.conversations
where id = p_conversation_id
and organization_id = p_organization_id
for update;
if not found then
return; -- inexistente / fora do escopo RLS → 0 rows
end if;
if p_enforce_expected and v_from is distinct from p_expected_assignee then
return; -- optimistic lock perdeu (spec 04 §9.2) → rota devolve 409
end if;
update public.conversations
set assigned_to_user_id = p_to_user_id,
assigned_at = case when p_to_user_id is null then null else now() end,
-- G3-02: quem atende é 'user' ou volta à fila (null); 'ai' nunca sai daqui.
assignee_kind = case when p_to_user_id is null then null else 'user' end,
status = case when p_to_user_id is null then 'open' else 'claimed' end,
status_changed_at = now(),
unread_count_for_assignee = 0, -- G3-01 acceptance 5: re-zera pro novo dono
updated_at = now()
where id = p_conversation_id
returning * into v_conv;
insert into public.conversation_assignment_events
(organization_id, conversation_id, from_user_id, to_user_id, changed_by, reason)
values
(p_organization_id, p_conversation_id, v_from, p_to_user_id, auth.uid(), p_reason);
return next v_conv;
end;
$$;
revoke all on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean) from public;
grant execute on function public.fn_conversation_assign(uuid, uuid, uuid, text, uuid, boolean)
to authenticated, service_role;
+1
View File
@@ -35,6 +35,7 @@ Migrations applied to Supabase project `rrydmwnporysaiysiztn` (sa-east-1, Postgr
| `20260706210000` | `0027_whatsapp_conversation_unification` | Bugfix de governança de conversas WhatsApp. Causa-raiz: contatos @lid sem unique key + resolução check-then-act, e o WAHA emitindo `message`+`message.any` por mensagem → 1 pessoa virava N contatos/conversas (medido: 1 lid = 12 contatos). Adiciona coluna gerada `contacts.wa_identity` (`phone:+E164`/`lid:<digits>`), faz merge idempotente do histórico duplicado repontando todas as FKs (usa `is_merged_into` como mapa, sem temp tables → portável em psql), cria `uniq_contacts_org_wa_identity` + `uniq_conversations_1to1_per_contact_session` (a antiga unique incluía group_chat_id NULL, que no Postgres não protege 1:1), e as funções de upsert atômico `fn_upsert_wa_contact`/`fn_upsert_wa_conversation`/`fn_mark_conversation_message` que a app passa a usar (lib/waha/ingest.ts) no lugar do check-then-act. Também corrige mapeamento de `type` WAHA→CRM (`chat`→`text` etc.) que fazia mensagens reais violarem messages_type_check e sumirem. |
| `20260716120000` | `0030_config_rls_role_policies` | G2-03 (gov-loop): RLS por role nas tabelas de config, fechando no banco o que a G2-01 fechou na API (spec 13 §4; auditoria em §4.1). crm_pipelines/crm_stages: policy ALL org-flat vira SELECT org + WRITE manager+ (`fn_role_at_least`, padrão de api_tokens/merge_queue) — agent deixa de escrever config de pipeline. conversations: WRITE vira agent+ (viewer é read-only); SELECT permanece org-flat com a mesma expressão (escopo own/unassigned é G4-01). NNNN pula 0028/0029 (ocupados nas branches vendaval/F2-*). Sem mudança de contrato — database.types.ts intocado. |
| `20260717120000` | `0031_conversation_assignment_events` | G3-01 (gov-loop): conversation_assignment_events (spec 13 §3.1 — org, conversation, from/to, changed_by, reason claim\|transfer\|release\|routing\|handoff; RLS org SELECT+INSERT, append-only sem UPDATE/DELETE, índice por conversa) + fn_conversation_assign (SECURITY INVOKER: SELECT FOR UPDATE + UPDATE condicional de assigned_to_user_id + INSERT do evento na MESMA transação; 0 rows = lock perdeu → rota 409; unread_count_for_assignee re-zerado; changed_by = auth.uid(), nunca input do caller). Rotas claim/release migram pra função; rota transfer nova. |
| `20260717150000` | `0032_conversation_assignee_kind` | G3-02 (gov-loop): IA como assignee de 1ª classe (spec 13 §3.2) — conversations.assignee_kind ('user'\|'ai') com CHECK de coerência em implicação (kind='user' ⇒ assigned_to_user_id not null; kind='ai' ⇒ null; kind null livre p/ escritas legadas), backfill ANTES da constraint (assigned ⇒ 'user'; status='ai_handling' sem dono ⇒ 'ai'). Forward-fix INB-06a: fn_conversation_assign valida DENTRO da função que o destino é membro ativo agent+ da MESMA org (helper novo fn_member_role_in_org, SECURITY DEFINER — RLS de user_organizations não mostra membership alheio a agent) e passa a manter assignee_kind coerente em claim/transfer/release/handoff. Handoff IA→humano (lib/mcp/tools/handoff.ts) vira reassignment auditado reason='handoff'. |
## Reproducibility
+4 -4
View File
@@ -28,10 +28,10 @@ describe("eixo 6 — handoff IA→humano", () => {
expect(def).toContain("ai_handling");
});
// GAP(G6): handoff ainda não é reassignment auditado de 1ª classe — a
// coluna conversations.assignee_kind ('user'|'ai', spec 13 §3) não existe;
// quem atende (humano vs IA) segue ambíguo entre status e ai_handling.
it.fails("conversations.assignee_kind ('user'|'ai') existe (spec 13 §3)", () => {
// G3-02 fechou o GAP: assignee_kind ('user'|'ai') existe (migration 0032);
// handoff IA→humano é reassignment auditado (reason='handoff') e kind='user'
// veta o bot deterministicamente (workers/ai-response-worker.ts).
it("conversations.assignee_kind ('user'|'ai') existe (spec 13 §3)", () => {
expect(columnExists("conversations", "assignee_kind")).toBe(true);
});
});
@@ -0,0 +1,186 @@
import { beforeAll, describe, expect, it } from "vitest";
import {
GOV_AGENT_A,
GOV_ORG,
GOV_SESSION,
GOV_VIEWER,
countAs,
lastLine,
seedGov,
sql,
} from "./gov-helpers";
/**
* Eixo 6 — G3-02: assignee_kind ('user'|'ai') + guard de membership na
* fn_conversation_assign (migration 0032, spec 13 §3.2; forward-fix INB-06a).
*
* Invariantes:
* - CHECK de coerência: kind='user' exige dono humano; kind='ai' exige sem dono;
* - fn_conversation_assign mantém assignee_kind ('user' no claim/handoff,
* null no release) e grava evento reason='handoff';
* - INB-06a: rpc direto NÃO atribui a viewer nem a usuário de outra org
* (raise assignee_not_eligible_member DENTRO da função — probe H8);
* - fn_member_role_in_org NEGA anon (revoke explícito de EXECUTE — o default
* privilege do Supabase concede EXECUTE a anon em toda função nova de
* public, e o JWT anon também tem auth.uid() null; sem o revoke, a anon
* key pública enumeraria membership/role cross-org via RPC do PostgREST).
*/
// Fixture própria (namespace eeeeeeee) — não toca nas conversas dos outros arquivos.
const AK_CONTACT = "eeeeeeee-3333-4000-8000-000000000001";
const AK_CONV = "eeeeeeee-4444-4000-8000-000000000001";
// Usuário de OUTRA org (org B) para a probe cross-org do INB-06a.
const AK_ORG_B = "eeeeeeee-0000-4000-8000-000000000002";
const AK_USER_ORG_B = "eeeeeeee-1111-4000-8000-000000000002";
function assignAs(userId: string, args: string): number {
return countAs(
userId,
`select count(*) from public.fn_conversation_assign(
'${GOV_ORG}'::uuid, '${AK_CONV}'::uuid, ${args})`,
);
}
/** Executa o assign esperando erro; devolve o stderr do psql (mensagem do raise). */
function assignRejected(userId: string, args: string): string {
try {
assignAs(userId, args);
return "";
} catch (err) {
return (err as { stderr?: string }).stderr ?? "";
}
}
function convState(): string {
return lastLine(
sql(
`select coalesce(assignee_kind, 'null') || '|' || coalesce(assigned_to_user_id::text, 'null')
from public.conversations where id = '${AK_CONV}';`,
),
);
}
beforeAll(() => {
seedGov();
sql(`
insert into auth.users (id, email)
values ('${AK_USER_ORG_B}', 'gov-agent-org-b@invariant.test')
on conflict do nothing;
insert into public.organizations (id, slug, legal_name, display_name)
values ('${AK_ORG_B}', 'gov-inv-b', 'Gov Invariant Org B', 'Gov Inv B')
on conflict do nothing;
insert into public.user_organizations (user_id, organization_id, role, accepted_at)
values ('${AK_USER_ORG_B}', '${AK_ORG_B}', 'agent', now())
on conflict do nothing;
insert into public.contacts (id, organization_id, display_name)
values ('${AK_CONTACT}', '${GOV_ORG}', 'Gov Invariant Contact AK')
on conflict do nothing;
insert into public.conversations (id, organization_id, contact_id, channel_session_id, status, assignee_kind)
values ('${AK_CONV}', '${GOV_ORG}', '${AK_CONTACT}', '${GOV_SESSION}', 'ai_handling', 'ai')
on conflict do nothing;
`);
});
describe("eixo 6 — G3-02: assignee_kind + guard INB-06a", () => {
it("CHECK de coerência: kind='user' sem dono e kind='ai' com dono são rejeitados", () => {
let err = "";
try {
sql(
`update public.conversations set assignee_kind = 'user', assigned_to_user_id = null
where id = '${AK_CONV}';`,
);
} catch (e) {
err = (e as { stderr?: string }).stderr ?? "";
}
expect(err).toContain("conversations_assignee_kind_coherence");
err = "";
try {
sql(
`update public.conversations
set assignee_kind = 'ai', assigned_to_user_id = '${GOV_AGENT_A}'
where id = '${AK_CONV}';`,
);
} catch (e) {
err = (e as { stderr?: string }).stderr ?? "";
}
expect(err).toContain("conversations_assignee_kind_coherence");
});
it("handoff via fn: kind ai→'user' + evento reason='handoff' na mesma transação", () => {
// Garante o estado 'com a IA' (independe do teste anterior).
sql(
`update public.conversations
set assignee_kind = 'ai', assigned_to_user_id = null, status = 'ai_handling'
where id = '${AK_CONV}';`,
);
const rows = assignAs(GOV_AGENT_A, `'${GOV_AGENT_A}'::uuid, 'handoff', null::uuid, false`);
expect(rows).toBe(1);
expect(convState()).toBe(`user|${GOV_AGENT_A}`);
const events = countAs(
GOV_AGENT_A,
`select count(*) from public.conversation_assignment_events
where conversation_id = '${AK_CONV}' and reason = 'handoff'
and to_user_id = '${GOV_AGENT_A}'`,
);
expect(events).toBe(1);
});
it("release via fn: kind volta a null junto com o dono", () => {
const rows = assignAs(GOV_AGENT_A, `null::uuid, 'release', '${GOV_AGENT_A}'::uuid, true`);
expect(rows).toBe(1);
expect(convState()).toBe("null|null");
});
it("INB-06a: rpc direto NÃO atribui a viewer (raise dentro da função)", () => {
const stderr = assignRejected(GOV_AGENT_A, `'${GOV_VIEWER}'::uuid, 'transfer', null::uuid, false`);
expect(stderr).toContain("assignee_not_eligible_member");
expect(convState()).toBe("null|null"); // nada mudou
});
it("INB-06a: rpc direto NÃO atribui a usuário de OUTRA org", () => {
const stderr = assignRejected(
GOV_AGENT_A,
`'${AK_USER_ORG_B}'::uuid, 'transfer', null::uuid, false`,
);
expect(stderr).toContain("assignee_not_eligible_member");
expect(convState()).toBe("null|null");
});
it("fn_member_role_in_org NEGA anon (permission denied) e segue servindo o service_role", () => {
// Mesma simulação do PostgREST anônimo: role anon + JWT sem `sub`
// (auth.uid() null — igual ao path do service_role, por isso o EXECUTE
// precisa ser negado no grant, não no corpo da função).
let stderr = "";
try {
sql(`
set role anon;
select set_config('request.jwt.claims', '{}', false);
select public.fn_member_role_in_org('${GOV_AGENT_A}'::uuid, '${GOV_ORG}'::uuid);
`);
} catch (e) {
stderr = (e as { stderr?: string }).stderr ?? "";
}
expect(stderr).toContain("permission denied");
// O path legítimo do sistema (worker via service_role, uid null) continua
// respondendo — é dele que o guard INB-06a depende no handoff.
const roleAsSystem = lastLine(
sql(`
set role service_role;
select set_config('request.jwt.claims', '{}', false);
select public.fn_member_role_in_org('${GOV_AGENT_A}'::uuid, '${GOV_ORG}'::uuid);
`),
);
expect(roleAsSystem).toBe("agent");
});
it("claim via fn segue válido para membro agent+ e marca kind='user'", () => {
const rows = assignAs(GOV_AGENT_A, `'${GOV_AGENT_A}'::uuid, 'claim', null::uuid, true`);
expect(rows).toBe(1);
expect(convState()).toBe(`user|${GOV_AGENT_A}`);
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* G3-02 acceptance 3 — regra determinística: conversa com assignee_kind='user'
* (humano atendendo) VETA o pipeline de resposta do bot, na mesma família de
* guard de force_human/bot_silenced_until (workers/ai-response-worker.ts).
*
* Prova, contra o worker REAL (admin client e gateway mockados):
* - kind='user' → skip 'assigned_to_human' ANTES de qualquer leitura de
* mensagem (nenhuma query além de conversations);
* - kind='ai' → o guard NÃO veta: o pipeline avança até o próximo passo
* (aqui, agente ausente → 'agent_inactive_or_missing').
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { processMessageReceived } from "@/workers/ai-response-worker";
import { createAdminClient } from "@/lib/supabase/admin";
import type { EventRow } from "@/lib/event-log/dispatcher";
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
vi.mock("@/lib/logger", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
vi.mock("@/lib/ai/gateway", () => ({
DEFAULT_BOT_MODEL: "anthropic/claude-sonnet-4-6",
gatewayConfig: {},
gatewayHeaders: () => ({}),
isAiGatewayConfigured: () => true,
isEmbeddingProviderConfigured: () => false,
}));
const ORG_ID = "22222222-2222-4222-8222-222222222222";
const CONV_ID = "44444444-4444-4444-8444-444444444444";
const MSG_ID = "55555555-5555-4555-8555-555555555555";
interface StubTables {
conversations: Record<string, unknown> | null;
messages: Record<string, unknown> | null;
}
function makeAdminStub(tables: StubTables, queried: string[]) {
const from = (table: string) => {
const result =
table === "conversations"
? tables.conversations
: table === "messages"
? tables.messages
: null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chain: any = {
select: () => chain,
eq: () => chain,
order: () => chain,
limit: () => chain,
maybeSingle: () => Promise.resolve({ data: result, error: null }),
then: (resolve: (v: unknown) => unknown) =>
Promise.resolve({ data: result ? [result] : [], error: null }).then(resolve),
};
queried.push(table);
return chain;
};
return { from };
}
function convRow(assigneeKind: string | null) {
return {
id: CONV_ID,
organization_id: ORG_ID,
contact_id: "66666666-6666-4666-8666-666666666666",
channel_session_id: "77777777-7777-4777-8777-777777777777",
last_inbound_at: new Date().toISOString(),
bot_silenced_until: null,
last_handoff_at: null,
assignee_kind: assigneeKind,
contacts: {
id: "66666666-6666-4666-8666-666666666666",
display_name: null, // sem PII em teste (LGPD)
locale: "pt-BR",
is_blocked: false,
force_human: false,
},
};
}
const eventRow = {
organization_id: ORG_ID,
entity_id: MSG_ID,
payload: { message_id: MSG_ID, conversation_id: CONV_ID },
} as unknown as EventRow;
beforeEach(() => {
vi.clearAllMocks();
});
describe("guard determinístico do bot — assignee_kind (G3-02)", () => {
it("kind='user' (humano atendendo) → bot NÃO dispara: skip 'assigned_to_human'", async () => {
const queried: string[] = [];
vi.mocked(createAdminClient).mockReturnValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
makeAdminStub({ conversations: convRow("user"), messages: null }, queried) as any,
);
const result = await processMessageReceived(eventRow);
expect(result).toEqual({ status: "skipped", reason: "assigned_to_human" });
// Veto é determinístico e imediato: só a conversa foi lida, nada do resto
// do pipeline (mensagem, agente, budget) foi consultado.
expect(queried).toEqual(["conversations"]);
});
it("kind='ai' → o guard não veta e o pipeline avança além dele", async () => {
const queried: string[] = [];
vi.mocked(createAdminClient).mockReturnValue(
makeAdminStub(
{
conversations: convRow("ai"),
messages: { id: MSG_ID, body: "oi", direction: "inbound", organization_id: ORG_ID },
},
queried,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any,
);
const result = await processMessageReceived(eventRow);
// Avançou até o passo seguinte do pipeline (sem ai_agents no stub):
// prova que o guard de assignment deixou passar quando a IA é a assignee.
expect(result.reason).toBe("agent_inactive_or_missing");
expect(queried).toContain("messages");
expect(queried).toContain("ai_agents");
});
it("kind=null (fila) → guard também não veta", async () => {
const queried: string[] = [];
vi.mocked(createAdminClient).mockReturnValue(
makeAdminStub(
{
conversations: convRow(null),
messages: { id: MSG_ID, body: "oi", direction: "inbound", organization_id: ORG_ID },
},
queried,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any,
);
const result = await processMessageReceived(eventRow);
expect(result.reason).toBe("agent_inactive_or_missing");
});
});
+159
View File
@@ -0,0 +1,159 @@
/**
* G3-02 acceptance 2 — crm_request_human_handoff grava o assignment event
* (reason='handoff') e move kind ai→user/fila conforme o roteamento vigente
* (round-robin agent+ que o handoff já tinha).
*
* Prova, contra a tool REAL (ctx.supabase mockado, triggerHandoff mockado):
* - com elegível: rpc fn_conversation_assign com p_reason='handoff' (evento +
* kind='user' saem da função, na mesma transação);
* - sem elegível: fila — assignee_kind limpo (null) + INSERT direto do evento
* reason='handoff' com from/to/changed_by null (sistema).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { triggerHandoff } from "@/lib/ai/handoff/orchestrator";
import { crmRequestHumanHandoff } from "@/lib/mcp/tools/handoff";
import type { McpContext } from "@/lib/mcp/types";
vi.mock("@/lib/ai/handoff/orchestrator", () => ({ triggerHandoff: vi.fn() }));
vi.mock("@/lib/logger", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
const ORG_ID = "22222222-2222-4222-8222-222222222222";
const CONV_ID = "44444444-4444-4444-8444-444444444444";
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
interface StubState {
members: Array<{ user_id: string; role: string }>;
rpcCalls: Array<{ fn: string; args: Record<string, unknown> }>;
updates: Array<{ table: string; values: Record<string, unknown> }>;
inserts: Array<{ table: string; values: Record<string, unknown> }>;
}
function makeSupabaseStub(state: StubState) {
const from = (table: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chain: any = {
select: () => chain,
eq: () => chain,
is: () => chain,
in: () =>
// user_organizations round-robin query termina em .in(...)
Promise.resolve({ data: state.members, error: null }),
order: () => chain,
limit: () => chain,
maybeSingle: () =>
Promise.resolve({
data:
table === "conversations"
? { id: CONV_ID, organization_id: ORG_ID, contact_id: null }
: null,
error: null,
}),
update: (values: Record<string, unknown>) => {
state.updates.push({ table, values });
return chain;
},
insert: (values: Record<string, unknown>) => {
state.inserts.push({ table, values });
return Promise.resolve({ data: null, error: null });
},
then: (resolve: (v: unknown) => unknown) =>
Promise.resolve({ data: null, error: null }).then(resolve),
};
return chain;
};
return {
from,
rpc: (fn: string, args: Record<string, unknown>) => {
state.rpcCalls.push({ fn, args });
return Promise.resolve({ data: [{ id: CONV_ID }], error: null });
},
};
}
function makeCtx(state: StubState): McpContext {
return {
organizationId: ORG_ID,
role: "agent",
actor: { type: "user", id: AGENT_ID },
apiTokenId: "tok",
requestId: "req",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: makeSupabaseStub(state) as any,
} as McpContext;
}
function stubState(overrides: Partial<StubState> = {}): StubState {
return {
members: [{ user_id: AGENT_ID, role: "agent" }],
rpcCalls: [],
updates: [],
inserts: [],
...overrides,
};
}
const input = {
conversation_id: CONV_ID,
reason: "cliente pediu humano",
urgency: "normal" as const,
suggested_assignee_role: "agent" as const,
metadata: undefined,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(triggerHandoff).mockResolvedValue({ triggered: true, reason: "requested_human" });
});
describe("crm_request_human_handoff — reassignment auditado (G3-02)", () => {
it("com elegível: fn_conversation_assign com reason='handoff' (kind ai→user + evento na fn)", async () => {
const state = stubState();
const result = (await crmRequestHumanHandoff.handler(input, makeCtx(state))) as {
assigned_to_user_id: string | null;
};
expect(state.rpcCalls).toEqual([
{
fn: "fn_conversation_assign",
args: {
p_organization_id: ORG_ID,
p_conversation_id: CONV_ID,
p_to_user_id: AGENT_ID,
p_reason: "handoff",
p_enforce_expected: false,
},
},
]);
expect(result.assigned_to_user_id).toBe(AGENT_ID);
// Caminho da fila NÃO roda quando a atribuição venceu.
expect(state.inserts).toEqual([]);
});
it("sem elegível: fila — kind limpo + evento reason='handoff' from/to null (sistema)", async () => {
const state = stubState({ members: [] });
const result = (await crmRequestHumanHandoff.handler(input, makeCtx(state))) as {
assigned_to_user_id: string | null;
};
expect(result.assigned_to_user_id).toBeNull();
expect(state.rpcCalls).toEqual([]);
expect(state.updates).toContainEqual({
table: "conversations",
values: { assignee_kind: null },
});
expect(state.inserts).toContainEqual({
table: "conversation_assignment_events",
values: {
organization_id: ORG_ID,
conversation_id: CONV_ID,
from_user_id: null,
to_user_id: null,
changed_by: null,
reason: "handoff",
},
});
});
});
+5 -1
View File
@@ -252,7 +252,7 @@ async function buildContext(input: BuildContextInput): Promise<GuardDecision> {
const { data: conv, error: convErr } = await admin
.from("conversations")
.select(
"id, organization_id, contact_id, channel_session_id, last_inbound_at, bot_silenced_until, last_handoff_at, contacts:contact_id(id, display_name, locale, is_blocked, force_human)",
"id, organization_id, contact_id, channel_session_id, last_inbound_at, bot_silenced_until, last_handoff_at, assignee_kind, contacts:contact_id(id, display_name, locale, is_blocked, force_human)",
)
.eq("id", input.conversationId)
.eq("organization_id", input.organizationId)
@@ -269,6 +269,7 @@ async function buildContext(input: BuildContextInput): Promise<GuardDecision> {
last_inbound_at: string | null;
bot_silenced_until: string | null;
last_handoff_at: string | null;
assignee_kind: string | null;
contacts: {
id: string;
display_name: string | null;
@@ -281,6 +282,9 @@ async function buildContext(input: BuildContextInput): Promise<GuardDecision> {
if (!c.contacts) return skip("conversation_not_found", "contact join missing");
if (c.contacts.is_blocked) return skip("contact_blocked");
if (c.contacts.force_human) return skip("force_human");
// G3-02 — assignee de 1ª classe: humano atendendo (kind='user') veta o bot
// deterministicamente, mesma família de guard de force_human/bot_silenced_until.
if (c.assignee_kind === "user") return skip("assigned_to_human");
// 24h window (IA-01). Use last_inbound_at — webhook updates it on receive.
if (c.last_inbound_at) {