- open_agentic_rectification_case_v2 (12 args) becomes SECURITY INVOKER: the immutable-skill ACL reconciliation leaves EXECUTE on the 11-arg open only to service_role, so the definer wrapper hit 42501 on every homepage/new open. - The pending-opening read moves to owner function agentic_rectification_opening_pending_v1, granted to service_role only. - History test and persisted replay harness append turns through the V10 request-idempotent overload; the V9 overload is revoked from service_role. - Opening test fixture adds the required birth_time_source. - Segment migrations renamed to 20261001* so they sort after staging's 20260930* migrations on both fresh and existing databases. - Stale Windows replay replaced with the production-path replay (M1/M2 equal to accepted research, implementation_identity included). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
194 lines
14 KiB
PL/PgSQL
194 lines
14 KiB
PL/PgSQL
begin;
|
|
|
|
-- Nullable for historical Cases: only new Cases opt into segment opening checks.
|
|
alter table public.agentic_rectification_cases
|
|
add column if not exists rectification_domain text,
|
|
add column if not exists checks jsonb;
|
|
|
|
create or replace function public.initialize_agentic_rectification_domain_v1(p_user_id uuid, p_case_id uuid, p_domain text)
|
|
returns jsonb language plpgsql security definer set search_path = '' as $$
|
|
declare v_case public.agentic_rectification_cases%rowtype;
|
|
begin
|
|
if p_domain is null or p_domain not in ('general','report','education','career','relationship','marriage','relocation','finance','health','family','other') then
|
|
raise exception 'agentic_rectification_invalid_domain';
|
|
end if;
|
|
select * into v_case from public.agentic_rectification_cases where id=p_case_id and user_id=p_user_id for update;
|
|
if not found then raise exception 'agentic_rectification_case_not_found'; end if;
|
|
if v_case.rectification_domain is not null then
|
|
return jsonb_build_object('domain',v_case.rectification_domain);
|
|
end if;
|
|
if v_case.status <> 'draft' or exists(select 1 from public.agentic_rectification_turns where case_id=p_case_id) then
|
|
raise exception 'agentic_rectification_domain_locked';
|
|
end if;
|
|
update public.agentic_rectification_cases set rectification_domain=p_domain where id=p_case_id;
|
|
return jsonb_build_object('domain',p_domain);
|
|
end $$;
|
|
|
|
-- Owner-side read for the invoker wrapper below: runtime roles have no direct table access.
|
|
create or replace function public.agentic_rectification_opening_pending_v1(p_user_id uuid, p_case_id uuid)
|
|
returns boolean language sql stable security definer set search_path = '' as $$
|
|
select exists(select 1 from public.agentic_rectification_cases c where c.id=p_case_id
|
|
and c.user_id=p_user_id and c.rectification_domain is not null and c.status='draft'
|
|
and not exists(select 1 from public.agentic_rectification_turns t where t.case_id=c.id));
|
|
$$;
|
|
|
|
-- The extra server-owned argument selects this overload; old 11-argument callers
|
|
-- and exact-session opens keep their original immutable Skill/domain contract.
|
|
-- SECURITY INVOKER: the immutable-skill ACL reconciliation leaves EXECUTE on the
|
|
-- 11-argument open only to service_role (not the owner), so a definer wrapper
|
|
-- would hit 42501. The caller is service_role, which may run both inner functions.
|
|
create or replace function public.open_agentic_rectification_case_v2(
|
|
p_user_id uuid, p_request_id uuid, p_intent text, p_session_id uuid,
|
|
p_skill_name text, p_skill_version text, p_skill_sha256 text, p_skill_source_commit text,
|
|
p_baseline_profile_fingerprint text, p_baseline_birth_snapshot jsonb, p_candidate_range jsonb,
|
|
p_product_domain text
|
|
) returns jsonb language plpgsql security invoker set search_path = '' as $$
|
|
declare v_result jsonb;
|
|
begin
|
|
if p_product_domain is null or p_product_domain not in ('general','report','education','career','relationship','marriage','relocation','finance','health','family','other') then
|
|
raise exception 'agentic_rectification_invalid_domain';
|
|
end if;
|
|
v_result := public.open_agentic_rectification_case_v2(p_user_id,p_request_id,p_intent,p_session_id,
|
|
p_skill_name,p_skill_version,p_skill_sha256,p_skill_source_commit,p_baseline_profile_fingerprint,
|
|
p_baseline_birth_snapshot,p_candidate_range);
|
|
if v_result->>'disposition' = 'created' then
|
|
perform public.initialize_agentic_rectification_domain_v1(p_user_id,(v_result->>'case_id')::uuid,p_product_domain);
|
|
end if;
|
|
if public.agentic_rectification_opening_pending_v1(p_user_id,(v_result->>'case_id')::uuid) then
|
|
v_result := v_result || jsonb_build_object('should_start_opening',true);
|
|
end if;
|
|
return v_result;
|
|
end $$;
|
|
|
|
create or replace function public.write_agentic_rectification_segment_checks_v1(p_user_id uuid, p_case_id uuid, p_candidate_range jsonb, p_checks jsonb)
|
|
returns jsonb language plpgsql security definer set search_path = '' as $$
|
|
declare v_case public.agentic_rectification_cases%rowtype; v_targets jsonb; v_summary jsonb; v_chart jsonb; v_segment jsonb; v_width integer; v_next integer; v_key integer; v_unique boolean := true; v_chart_unique boolean;
|
|
v_original_width integer; v_origin timestamp; v_end timestamp; v_start integer; v_finish integer;
|
|
begin
|
|
select * into v_case from public.agentic_rectification_cases where id=p_case_id and user_id=p_user_id for update;
|
|
if not found then raise exception 'agentic_rectification_case_not_found'; end if;
|
|
if v_case.rectification_domain is null or v_case.status not in ('draft','collecting_evidence','candidate_ready','paused')
|
|
or v_case.candidate_range is distinct from p_candidate_range then raise exception 'agentic_rectification_segment_checks_stale'; end if;
|
|
if v_case.checks->>'range_fingerprint' = p_checks->>'range_fingerprint'
|
|
and v_case.checks->'candidate_range' = v_case.candidate_range then return v_case.checks; end if;
|
|
v_targets := case v_case.rectification_domain when 'relationship' then '["D1","D9"]'::jsonb when 'marriage' then '["D1","D9"]'::jsonb when 'career' then '["D1","D10"]'::jsonb else '["D1","D9","D10"]'::jsonb end;
|
|
if p_checks->>'contract' is distinct from 'segment-opening-v1' or p_checks->>'domain' is distinct from v_case.rectification_domain
|
|
or coalesce(p_checks->>'range_fingerprint','') !~ '^[0-9a-f]{64}$'
|
|
or coalesce(p_checks->>'status','') not in ('complete','unavailable') then raise exception 'agentic_rectification_invalid_segment_checks'; end if;
|
|
v_summary := p_checks->'summary';
|
|
if p_checks->>'status' = 'unavailable' then
|
|
if v_summary is distinct from 'null'::jsonb then raise exception 'agentic_rectification_invalid_segment_checks'; end if;
|
|
else
|
|
if v_summary->'targets' is distinct from v_targets or v_summary->'scan_complete' is distinct from 'true'::jsonb
|
|
or jsonb_typeof(v_summary->'charts') is distinct from 'array'
|
|
or jsonb_array_length(v_summary->'charts') <> jsonb_array_length(v_targets)
|
|
or coalesce(v_summary->>'window_minutes','') !~ '^[0-9]+$' then raise exception 'agentic_rectification_invalid_segment_checks'; end if;
|
|
v_width := (v_summary->>'window_minutes')::integer;
|
|
if v_width < 1 or v_width > 1440 then raise exception 'agentic_rectification_invalid_segment_checks'; end if;
|
|
-- Verify the original persisted window, never a credible/candidate subwindow.
|
|
if v_case.candidate_range ? 'candidate_intervals' then
|
|
if jsonb_typeof(v_case.candidate_range->'candidate_intervals') is distinct from 'array'
|
|
or jsonb_array_length(v_case.candidate_range->'candidate_intervals') <> 1 then
|
|
raise exception 'agentic_rectification_invalid_segment_checks';
|
|
end if;
|
|
v_origin := (v_case.candidate_range->'candidate_intervals'->0->>'start_at')::timestamp;
|
|
v_end := (v_case.candidate_range->'candidate_intervals'->0->>'end_at')::timestamp;
|
|
if to_char(v_origin,'YYYY-MM-DD"T"HH24:MI') is distinct from v_case.candidate_range->'candidate_intervals'->0->>'start_at'
|
|
or to_char(v_end,'YYYY-MM-DD"T"HH24:MI') is distinct from v_case.candidate_range->'candidate_intervals'->0->>'end_at' then
|
|
raise exception 'agentic_rectification_invalid_segment_checks';
|
|
end if;
|
|
v_original_width := extract(epoch from (v_end-v_origin))/60+1;
|
|
else
|
|
if coalesce(v_case.candidate_range->>'start_time','') !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
|
|
or coalesce(v_case.candidate_range->>'end_time','') !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
|
|
or v_case.candidate_range->'midnight_side_pending' = 'true'::jsonb then
|
|
raise exception 'agentic_rectification_invalid_segment_checks';
|
|
end if;
|
|
v_start := extract(hour from (v_case.candidate_range->>'start_time')::time)::integer*60
|
|
+ extract(minute from (v_case.candidate_range->>'start_time')::time)::integer;
|
|
v_finish := extract(hour from (v_case.candidate_range->>'end_time')::time)::integer*60
|
|
+ extract(minute from (v_case.candidate_range->>'end_time')::time)::integer;
|
|
v_original_width := (v_finish-v_start+1440)%1440+1;
|
|
end if;
|
|
if v_original_width is distinct from v_width then raise exception 'agentic_rectification_invalid_segment_checks'; end if;
|
|
for v_next in 0..jsonb_array_length(v_targets)-1 loop
|
|
v_chart := v_summary->'charts'->v_next;
|
|
if v_chart->'chart' is distinct from v_targets->v_next or jsonb_typeof(v_chart->'segments') is distinct from 'array'
|
|
or jsonb_array_length(v_chart->'segments') < 1 then raise exception 'agentic_rectification_invalid_segment_checks'; end if;
|
|
v_chart_unique := jsonb_array_length(v_chart->'segments')=1;
|
|
v_key := 0;
|
|
for v_segment in select value from jsonb_array_elements(v_chart->'segments') loop
|
|
if coalesce(v_segment->>'start','') !~ '^[0-9]+$' or coalesce(v_segment->>'end','') !~ '^[0-9]+$'
|
|
or (v_segment->>'start')::integer <> v_key or (v_segment->>'end')::integer < v_key
|
|
or (v_segment->>'end')::integer >= v_width or jsonb_typeof(v_segment->'key') is distinct from 'array'
|
|
or jsonb_array_length(v_segment->'key')<>1 or coalesce(v_segment->'key'->>0,'') !~ '^[0-9]+$'
|
|
or (v_segment->'key'->>0)::integer not between 0 and 11 then raise exception 'agentic_rectification_invalid_segment_checks'; end if;
|
|
v_key := (v_segment->>'end')::integer + 1;
|
|
end loop;
|
|
if v_key <> v_width or v_chart->'full_window_unique' is distinct from to_jsonb(v_chart_unique) then raise exception 'agentic_rectification_invalid_segment_checks'; end if;
|
|
v_unique := v_unique and v_chart_unique;
|
|
end loop;
|
|
if v_summary->'no_rectification_needed' is distinct from to_jsonb(v_unique) then raise exception 'agentic_rectification_invalid_segment_checks'; end if;
|
|
end if;
|
|
p_checks := (p_checks-'delivered_turn_id') || jsonb_build_object('candidate_range',v_case.candidate_range);
|
|
update public.agentic_rectification_cases set checks=p_checks where id=p_case_id;
|
|
return p_checks;
|
|
end $$;
|
|
|
|
-- Called only by the shared successful-turn exit. Turn + checks + close are atomic.
|
|
create or replace function public.finalize_agentic_rectification_segment_consistency_v1(p_user_id uuid, p_case_id uuid, p_range_fingerprint text, p_narration text)
|
|
returns jsonb language plpgsql security definer set search_path = '' as $$
|
|
declare v_case public.agentic_rectification_cases%rowtype; v_turn jsonb;
|
|
begin
|
|
select * into v_case from public.agentic_rectification_cases where id=p_case_id and user_id=p_user_id for update;
|
|
if not found then raise exception 'agentic_rectification_case_not_found'; end if;
|
|
if v_case.checks->>'range_fingerprint' is distinct from p_range_fingerprint
|
|
or v_case.checks->'summary'->'no_rectification_needed' is distinct from 'true'::jsonb
|
|
or v_case.checks->>'status' is distinct from 'complete'
|
|
or v_case.checks->'candidate_range' is distinct from v_case.candidate_range
|
|
or length(btrim(coalesce(p_narration,'')))=0 then raise exception 'agentic_rectification_segment_checks_stale'; end if;
|
|
if v_case.checks->>'delivered_turn_id' is not null then
|
|
return jsonb_build_object('turn_id',v_case.checks->>'delivered_turn_id','idempotent',true);
|
|
end if;
|
|
if v_case.status <> 'draft'
|
|
or exists(select 1 from public.agentic_rectification_turns where case_id=p_case_id)
|
|
or exists(select 1 from public.agentic_rectification_conversation_focuses where case_id=p_case_id and status='active') then
|
|
raise exception 'agentic_rectification_segment_opening_started';
|
|
end if;
|
|
v_turn := public.append_agentic_rectification_turn(p_user_id,p_case_id,null,p_narration,'deterministic:segment-consistency',null,'completed',p_case_id);
|
|
update public.agentic_rectification_cases set checks=checks||jsonb_build_object('delivered_turn_id',v_turn->>'turn_id'),
|
|
status='closed',closed_reason='other',completed_at=pg_catalog.now() where id=p_case_id;
|
|
return v_turn;
|
|
end $$;
|
|
|
|
-- Preserve the previous implementation intact; add only two server-owned fields.
|
|
do $$ begin
|
|
if pg_catalog.to_regprocedure('public.get_agentic_rectification_case_dossier_before_segments(uuid,uuid)') is null then
|
|
execute pg_catalog.replace(pg_catalog.pg_get_functiondef('public.get_agentic_rectification_case_dossier(uuid,uuid)'::regprocedure),
|
|
'public.get_agentic_rectification_case_dossier(', 'public.get_agentic_rectification_case_dossier_before_segments(');
|
|
end if;
|
|
end $$;
|
|
create or replace function public.get_agentic_rectification_case_dossier(p_user_id uuid,p_case_id uuid)
|
|
returns jsonb language plpgsql security definer set search_path = '' as $$
|
|
declare v_dossier jsonb; v_case public.agentic_rectification_cases%rowtype;
|
|
begin
|
|
v_dossier := public.get_agentic_rectification_case_dossier_before_segments(p_user_id,p_case_id);
|
|
select * into v_case from public.agentic_rectification_cases where id=p_case_id and user_id=p_user_id;
|
|
return jsonb_set(v_dossier,'{case}',(v_dossier->'case')||jsonb_build_object('rectification_domain',v_case.rectification_domain,'checks',v_case.checks));
|
|
end $$;
|
|
|
|
revoke all on function public.get_agentic_rectification_case_dossier_before_segments(uuid,uuid) from public,anon,authenticated,service_role;
|
|
revoke all on function public.open_agentic_rectification_case_v2(uuid,uuid,text,uuid,text,text,text,text,text,jsonb,jsonb,text),
|
|
public.agentic_rectification_opening_pending_v1(uuid,uuid),
|
|
public.initialize_agentic_rectification_domain_v1(uuid,uuid,text),
|
|
public.write_agentic_rectification_segment_checks_v1(uuid,uuid,jsonb,jsonb),
|
|
public.finalize_agentic_rectification_segment_consistency_v1(uuid,uuid,text,text),
|
|
public.get_agentic_rectification_case_dossier(uuid,uuid) from public,anon,authenticated;
|
|
grant execute on function public.open_agentic_rectification_case_v2(uuid,uuid,text,uuid,text,text,text,text,text,jsonb,jsonb,text),
|
|
public.agentic_rectification_opening_pending_v1(uuid,uuid),
|
|
public.initialize_agentic_rectification_domain_v1(uuid,uuid,text),
|
|
public.write_agentic_rectification_segment_checks_v1(uuid,uuid,jsonb,jsonb),
|
|
public.finalize_agentic_rectification_segment_consistency_v1(uuid,uuid,text,text),
|
|
public.get_agentic_rectification_case_dossier(uuid,uuid) to service_role;
|
|
commit;
|