Files
Jyotisha/frontend/supabase/migrations/20261001020000_rectification_segment_checks.sql
T
Jesse_ChenandClaude Opus 5.5 73605c3b8e fix(rectification): product-domain open wrapper runs as invoker; V10 turn append in history test and replay harness; rename segment migrations (BUG-1131)
- 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
2026-10-01 08:04:05 +08:00

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;