Skip to content

Commit 40021db

Browse files
melgarafaelclaude
andcommitted
fix(crm): a origem do atendimento é reservada ao servidor — então ele passa a escrevê-la
EXCEÇÃO DECLARADA ao freeze-invariants.sh: o arquivo tocado, `fronteira-progressao-nao-e-conflito.test.ts`, foi criado por mim neste PR e ganha DOIS casos novos. Nada foi relaxado. CAUSA RAIZ do último vermelho, e ela é de produto, não de teste. O `pulados` que eu acabara de acrescentar ao dreno entregou o dado na primeira rodada: lead.stage_changed/followup-gatilho-etapa.v1: armados=1 enrolled=0 origem_obsoleta=1 ja_vivo=0 gate=0 sem_contato=0 `emit_event` RECUSA `service_origin` vindo de chamador autenticado (42501, e com razão: é o campo que autoriza efeito operacional, não payload público) — e ninguém o escrevia no lugar dele. O resultado: · quem move o negócio PELA IA carimba a origem no servidor (`agent-stage-sync`, `appointment-stage-move`, `handoff-stage-move`) e o follow-up nasce; · quem move PELO QUADRO — o operador, pela rota HTTP autenticada — emitia um evento SEM origem. `fn_service_event_origin` caía no `service_stale` final (40001), `serviceForEvent` engolia como `stale_origin`, e o follow-up nunca nascia. Sem erro em lugar nenhum. Ou seja: o gatilho de etapa era inalcançável pelo único caminho que o produto oferece na tela. A spec não estava vermelha por acaso — ela mede exatamente "o negócio movido NO QUADRO arma o follow-up sozinho". O conserto lê "campo reservado" pelo que ele significa: reservado AO SERVIDOR, e o servidor tem de escrevê-lo. `emit_event` passa a carimbar a origem quando ela está ausente, tirando o retrato no instante da emissão — que é a semântica de procedência que a 0223 quer. A resolução do contato repete a regra que `fn_service_event_origin` já usa; tipo que ela não sabe resolver segue sem origem, como antes. Origem já presente NÃO é sobrescrita: quem move pela IA pode estar passando uma CONTINUAÇÃO, que é o que amarra o efeito ao atendimento de onde ele nasceu. Entrou na 0224 (a última da cadeia a definir `emit_event`) e na definição correspondente do baseline — o gate de espelho confirma que os dois artefatos seguem iguais. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 5ec7b15 commit 40021db

3 files changed

Lines changed: 141 additions & 2 deletions

File tree

supabase/baseline.sql

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20434,6 +20434,8 @@ AS $function$
2043420434
declare
2043520435
v_org_id uuid;
2043620436
v_event_id uuid;
20437+
v_contact uuid;
20438+
v_origin jsonb;
2043720439
begin
2043820440
-- message.received nasce somente do INSERT inbound interno. Um chamador
2043920441
-- público não pode reapresentar uma mensagem existente como evento novo.
@@ -20464,11 +20466,46 @@ begin
2046420466

2046520467
if not public.fn_support_write_allowed(v_org_id) then raise exception 'support_readonly' using errcode='42501'; end if;
2046620468

20469+
-- A ORIGEM E RESERVADA AO SERVIDOR — ENTAO O SERVIDOR TEM DE ESCREVE-LA.
20470+
--
20471+
-- O bloco acima recusa `service_origin` vindo de chamador autenticado (42501,
20472+
-- e com razao: e o campo que AUTORIZA efeito operacional, nao payload
20473+
-- publico). So que ninguem o escrevia no lugar dele. Efeito medido: quem move
20474+
-- o negocio pela IA carimba a origem no servidor (`agent-stage-sync`,
20475+
-- `appointment-stage-move`, `handoff-stage-move`) e o follow-up nasce; quem
20476+
-- move PELO QUADRO — o operador, pela rota HTTP autenticada — emitia um
20477+
-- evento SEM origem, `fn_service_event_origin` caia no `service_stale` final
20478+
-- (40001), `serviceForEvent` engolia como `stale_origin` e o follow-up nunca
20479+
-- nascia. Sem erro em lugar nenhum: o gatilho de etapa era inalcancavel pelo
20480+
-- caminho que o produto oferece na tela.
20481+
--
20482+
-- O retrato e tirado AQUI, no instante da emissao, que e exatamente a
20483+
-- semantica de procedencia que a 0223 quer: "quando este evento nasceu, o
20484+
-- atendimento estava assim". A resolucao do contato repete a mesma regra de
20485+
-- `fn_service_event_origin` — se ela nao souber resolver o tipo, nao ha o que
20486+
-- carimbar e o evento segue sem origem, como antes.
20487+
if not (coalesce(p_payload,'{}'::jsonb) ? 'service_origin')
20488+
and not (coalesce(p_metadata,'{}'::jsonb) ? 'service_origin') then
20489+
if p_event_type in ('lead.created','lead.stage_changed','lead.tag_added') and p_entity_kind='crm_lead' then
20490+
select contact_id into v_contact from public.crm_leads where organization_id=v_org_id and id=p_entity_id;
20491+
elsif p_event_type='contact.tag_added' and p_entity_kind='contact' then
20492+
select id into v_contact from public.contacts where organization_id=v_org_id and id=p_entity_id;
20493+
end if;
20494+
if v_contact is not null
20495+
and exists(select 1 from public.contacts
20496+
where organization_id=v_org_id and id=v_contact
20497+
and not is_anonymized and is_merged_into is null) then
20498+
v_origin := jsonb_build_object('kind','command',
20499+
'observed', public.fn_service_observe_command(v_org_id, v_contact));
20500+
end if;
20501+
end if;
20502+
2046720503
insert into public.event_log
2046820504
(organization_id, event_type, entity_kind, entity_id, payload, metadata)
2046920505
values
2047020506
(v_org_id, p_event_type, p_entity_kind, p_entity_id,
20471-
coalesce(p_payload, '{}'::jsonb),
20507+
coalesce(p_payload, '{}'::jsonb)
20508+
|| case when v_origin is null then '{}'::jsonb else jsonb_build_object('service_origin', v_origin) end,
2047220509
coalesce(p_metadata, '{}'::jsonb)
2047320510
|| jsonb_build_object('emitted_at', extract(epoch from now())))
2047420511
returning id into v_event_id;

supabase/migrations/20260906120000_0224_presenca_e_recuperacao.sql

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,8 @@ AS $function$
429429
declare
430430
v_org_id uuid;
431431
v_event_id uuid;
432+
v_contact uuid;
433+
v_origin jsonb;
432434
begin
433435
-- message.received nasce somente do INSERT inbound interno. Um chamador
434436
-- público não pode reapresentar uma mensagem existente como evento novo.
@@ -459,11 +461,46 @@ begin
459461

460462
if not public.fn_support_write_allowed(v_org_id) then raise exception 'support_readonly' using errcode='42501'; end if;
461463

464+
-- A ORIGEM E RESERVADA AO SERVIDOR — ENTAO O SERVIDOR TEM DE ESCREVE-LA.
465+
--
466+
-- O bloco acima recusa `service_origin` vindo de chamador autenticado (42501,
467+
-- e com razao: e o campo que AUTORIZA efeito operacional, nao payload
468+
-- publico). So que ninguem o escrevia no lugar dele. Efeito medido: quem move
469+
-- o negocio pela IA carimba a origem no servidor (`agent-stage-sync`,
470+
-- `appointment-stage-move`, `handoff-stage-move`) e o follow-up nasce; quem
471+
-- move PELO QUADRO — o operador, pela rota HTTP autenticada — emitia um
472+
-- evento SEM origem, `fn_service_event_origin` caia no `service_stale` final
473+
-- (40001), `serviceForEvent` engolia como `stale_origin` e o follow-up nunca
474+
-- nascia. Sem erro em lugar nenhum: o gatilho de etapa era inalcancavel pelo
475+
-- caminho que o produto oferece na tela.
476+
--
477+
-- O retrato e tirado AQUI, no instante da emissao, que e exatamente a
478+
-- semantica de procedencia que a 0223 quer: "quando este evento nasceu, o
479+
-- atendimento estava assim". A resolucao do contato repete a mesma regra de
480+
-- `fn_service_event_origin` — se ela nao souber resolver o tipo, nao ha o que
481+
-- carimbar e o evento segue sem origem, como antes.
482+
if not (coalesce(p_payload,'{}'::jsonb) ? 'service_origin')
483+
and not (coalesce(p_metadata,'{}'::jsonb) ? 'service_origin') then
484+
if p_event_type in ('lead.created','lead.stage_changed','lead.tag_added') and p_entity_kind='crm_lead' then
485+
select contact_id into v_contact from public.crm_leads where organization_id=v_org_id and id=p_entity_id;
486+
elsif p_event_type='contact.tag_added' and p_entity_kind='contact' then
487+
select id into v_contact from public.contacts where organization_id=v_org_id and id=p_entity_id;
488+
end if;
489+
if v_contact is not null
490+
and exists(select 1 from public.contacts
491+
where organization_id=v_org_id and id=v_contact
492+
and not is_anonymized and is_merged_into is null) then
493+
v_origin := jsonb_build_object('kind','command',
494+
'observed', public.fn_service_observe_command(v_org_id, v_contact));
495+
end if;
496+
end if;
497+
462498
insert into public.event_log
463499
(organization_id, event_type, entity_kind, entity_id, payload, metadata)
464500
values
465501
(v_org_id, p_event_type, p_entity_kind, p_entity_id,
466-
coalesce(p_payload, '{}'::jsonb),
502+
coalesce(p_payload, '{}'::jsonb)
503+
|| case when v_origin is null then '{}'::jsonb else jsonb_build_object('service_origin', v_origin) end,
467504
coalesce(p_metadata, '{}'::jsonb)
468505
|| jsonb_build_object('emitted_at', extract(epoch from now())))
469506
returning id into v_event_id;

tests/invariants/fronteira-progressao-nao-e-conflito.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,71 @@ describe("fronteira: progressão de evento não é conflito", () => {
8080
).toBe(a.conversation_id);
8181
});
8282

83+
it("evento emitido SEM origem (a rota do quadro) ganha o carimbo do servidor e resolve", async () => {
84+
// A rota `/api/v1/leads/[id]/move` é autenticada, e `emit_event` RECUSA
85+
// `service_origin` vindo de chamador autenticado (42501) — é o campo que
86+
// autoriza efeito operacional. Só que ninguém o escrevia no lugar dela: o
87+
// evento nascia sem origem, `fn_service_event_origin` caía no `service_stale`
88+
// final e `serviceForEvent` engolia como `stale_origin`. Quem move o negócio
89+
// pela IA carimba a origem no servidor e o follow-up nasce; quem move PELO
90+
// QUADRO não, e o gatilho de etapa era inalcançável pela tela.
91+
const contact = randomUUID(),
92+
lead = randomUUID();
93+
await pool.query("insert into contacts(id,organization_id,name) values($1,$2,'Sem origem')", [
94+
contact,
95+
GOV_ORG,
96+
]);
97+
await pool.query(
98+
"insert into crm_leads(id,organization_id,contact_id,pipeline_id,stage_id,title) values($1,$2,$3,$4,$5,'Sem origem')",
99+
[lead, GOV_ORG, contact, GOV_PIPELINE, GOV_STAGE],
100+
);
101+
// Exatamente o que a rota manda: payload de negócio, zero origem.
102+
const evento = (
103+
await pool.query(
104+
"select emit_event('lead.stage_changed','crm_lead',$1,$2::jsonb,'{}',$3) id",
105+
[lead, JSON.stringify({ from_stage_id: null, to_stage_id: GOV_STAGE }), GOV_ORG],
106+
)
107+
).rows[0].id;
108+
const linha = (await pool.query("select payload from event_log where id=$1", [evento])).rows[0]
109+
.payload;
110+
expect(
111+
linha.service_origin?.kind,
112+
"o servidor tem de carimbar a origem que ele proíbe o chamador de mandar",
113+
).toBe("command");
114+
expect(
115+
(await pool.query("select fn_service_event_origin($1,$2,$3) b", [GOV_ORG, evento, contact]))
116+
.rows[0].b?.conversation_id,
117+
"e com o carimbo o evento resolve, em vez de morrer em stale_origin",
118+
).toBeTruthy();
119+
});
120+
121+
it("origem já presente não é sobrescrita pelo carimbo automático", async () => {
122+
// Quem move pela IA já resolve a origem no servidor e a passa. O carimbo
123+
// automático não pode atropelá-la: ela pode ser uma CONTINUAÇÃO, que é o que
124+
// amarra o efeito ao atendimento de onde ele nasceu.
125+
const contact = randomUUID(),
126+
lead = randomUUID();
127+
await pool.query("insert into contacts(id,organization_id,name) values($1,$2,'Com origem')", [
128+
contact,
129+
GOV_ORG,
130+
]);
131+
await pool.query(
132+
"insert into crm_leads(id,organization_id,contact_id,pipeline_id,stage_id,title) values($1,$2,$3,$4,$5,'Com origem')",
133+
[lead, GOV_ORG, contact, GOV_PIPELINE, GOV_STAGE],
134+
);
135+
const proprio = { kind: "command", observed: { marca: "veio-de-quem-emitiu" } };
136+
const evento = (
137+
await pool.query(
138+
"select emit_event('lead.stage_changed','crm_lead',$1,$2::jsonb,'{}',$3) id",
139+
[lead, JSON.stringify({ service_origin: proprio }), GOV_ORG],
140+
)
141+
).rows[0].id;
142+
expect(
143+
(await pool.query("select payload from event_log where id=$1", [evento])).rows[0].payload
144+
.service_origin.observed.marca,
145+
).toBe("veio-de-quem-emitiu");
146+
});
147+
83148
it("o CAS do chamador direto continua recusando observação que o mundo superou", async () => {
84149
// A metade que NÃO pode afrouxar junto: quem chama `fn_service_begin` com
85150
// um retrato `absent` já superado é um ator em corrida, e perde.

0 commit comments

Comments
 (0)