-- PR-4: server-owned event/decision contract persistence and candidate UUID boundary. -- Forward-only: historical candidate migrations and RPC definitions remain unchanged, -- while service_role is moved to explicit V2 entry points that never accept HH:MM -- for candidate acceptance or confirmation. begin; -- --------------------------------------------------------------------------- -- 1. Durable decision contract fields on candidate results -- --------------------------------------------------------------------------- alter table public.agentic_rectification_results add column if not exists event_contract_version text; alter table public.agentic_rectification_results add column if not exists decision_policy_version text; alter table public.agentic_rectification_results add column if not exists decision_receipt jsonb; alter table public.agentic_rectification_results add column if not exists execution_ledger jsonb; alter table public.agentic_rectification_results add column if not exists display_allowed boolean not null default false; alter table public.agentic_rectification_results add column if not exists selected_candidate_id uuid; alter table public.agentic_rectification_results drop constraint if exists agentic_rectification_results_event_contract_version_check; alter table public.agentic_rectification_results add constraint agentic_rectification_results_event_contract_version_check check ( event_contract_version is null or length(btrim(event_contract_version)) between 1 and 120 ); alter table public.agentic_rectification_results drop constraint if exists agentic_rectification_results_decision_policy_version_check; alter table public.agentic_rectification_results add constraint agentic_rectification_results_decision_policy_version_check check ( decision_policy_version is null or length(btrim(decision_policy_version)) between 1 and 120 ); alter table public.agentic_rectification_results drop constraint if exists agentic_rectification_results_decision_receipt_check; alter table public.agentic_rectification_results add constraint agentic_rectification_results_decision_receipt_check check ( decision_receipt is null or case when jsonb_typeof(decision_receipt) = 'object' and jsonb_typeof(decision_receipt -> 'display_allowed') = 'boolean' and jsonb_typeof(decision_receipt -> 'accept_allowed') = 'boolean' and jsonb_typeof(decision_receipt -> 'confirm_allowed') = 'boolean' then display_allowed = (decision_receipt ->> 'display_allowed')::boolean and selection_allowed = (decision_receipt ->> 'accept_allowed')::boolean and confirmation_allowed = (decision_receipt ->> 'confirm_allowed')::boolean else false end ); alter table public.agentic_rectification_results drop constraint if exists agentic_rectification_results_execution_ledger_check; alter table public.agentic_rectification_results add constraint agentic_rectification_results_execution_ledger_check check ( execution_ledger is null or jsonb_typeof(execution_ledger) = 'array' ); create unique index if not exists agentic_rectification_results_candidate_owner_unique on public.agentic_rectification_results (id, user_id, case_id); create index if not exists agentic_rectification_results_v2_cache_idx on public.agentic_rectification_results ( case_id, evidence_ledger_fingerprint, candidate_range_fingerprint, skill_version, algorithm_version, event_contract_version, decision_policy_version, created_at desc ) where invalidated_at is null and event_contract_version is not null; -- --------------------------------------------------------------------------- -- 2. Server-issued candidate UUIDs and request-id decision ledger -- --------------------------------------------------------------------------- create table if not exists public.agentic_rectification_candidates ( id uuid primary key default gen_random_uuid(), result_id uuid not null, user_id uuid not null, case_id uuid not null, ordinal integer not null check (ordinal > 0), candidate_time time without time zone not null, candidate_payload jsonb not null check (jsonb_typeof(candidate_payload) = 'object'), is_representative boolean not null default false, created_at timestamptz not null default pg_catalog.now(), foreign key (result_id, user_id, case_id) references public.agentic_rectification_results (id, user_id, case_id) on delete cascade, unique (result_id, ordinal), unique (result_id, candidate_time) ); create unique index if not exists agentic_rectification_candidates_owner_unique on public.agentic_rectification_candidates (id, result_id, user_id, case_id); create index if not exists agentic_rectification_candidates_case_result_idx on public.agentic_rectification_candidates (case_id, result_id, ordinal); create unique index if not exists agentic_rectification_candidates_one_representative_idx on public.agentic_rectification_candidates (result_id) where is_representative; alter table public.agentic_rectification_results drop constraint if exists agentic_rectification_results_selected_candidate_fk; alter table public.agentic_rectification_results add constraint agentic_rectification_results_selected_candidate_fk foreign key (selected_candidate_id) references public.agentic_rectification_candidates(id) on delete set null; create table if not exists public.agentic_rectification_candidate_decisions ( id uuid primary key default gen_random_uuid(), user_id uuid not null, case_id uuid not null, result_id uuid not null, candidate_id uuid not null, request_id uuid not null, decision_kind text not null check (decision_kind in ('accept', 'confirm')), response jsonb not null check (jsonb_typeof(response) = 'object'), created_at timestamptz not null default pg_catalog.now(), foreign key (candidate_id, result_id, user_id, case_id) references public.agentic_rectification_candidates (id, result_id, user_id, case_id), unique (user_id, request_id) ); create index if not exists agentic_rectification_candidate_decisions_case_idx on public.agentic_rectification_candidate_decisions (case_id, created_at desc); alter table public.agentic_rectification_candidates enable row level security; alter table public.agentic_rectification_candidate_decisions enable row level security; revoke all on table public.agentic_rectification_candidates from public, anon, authenticated, service_role; revoke all on table public.agentic_rectification_candidate_decisions from public, anon, authenticated, service_role; -- --------------------------------------------------------------------------- -- 3. V2 candidate persistence -- -- Booleans and presentation/decision fields are derived from decision_receipt. -- The caller cannot provide them as independent function arguments. Candidate -- UUIDs are generated here and caller-provided candidate_id fields are removed. -- --------------------------------------------------------------------------- create or replace function public.persist_agentic_rectification_candidate_v2( p_user_id uuid, p_case_id uuid, p_engine_result_id text, p_evidence_ledger_fingerprint text, p_candidate_range_fingerprint text, p_skill_version text, p_algorithm_version text, p_event_contract_version text, p_decision_policy_version text, p_candidate_range jsonb, p_candidates jsonb, p_decision_receipt jsonb, p_execution_ledger jsonb ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_case public.agentic_rectification_cases%rowtype; v_cached public.agentic_rectification_results%rowtype; v_snapshot jsonb; v_result_id uuid; v_candidate jsonb; v_candidate_id uuid; v_candidate_time time without time zone; v_candidate_time_text text; v_candidate_ordinal integer; v_saved_candidate jsonb; v_saved_candidates jsonb := '[]'::jsonb; v_seen_times text[] := array[]::text[]; v_display_allowed boolean; v_selection_allowed boolean; v_confirmation_allowed boolean; v_overall_confidence text; v_margin_percent numeric; v_representative_time_text text; v_representative_time time without time zone; v_representative_candidate_id uuid; v_representative_count integer := 0; v_saved_decision_receipt jsonb; begin if p_user_id is null or p_case_id is null or length(btrim(coalesce(p_engine_result_id, ''))) = 0 or length(btrim(coalesce(p_evidence_ledger_fingerprint, ''))) = 0 or length(btrim(coalesce(p_candidate_range_fingerprint, ''))) = 0 or length(btrim(coalesce(p_skill_version, ''))) = 0 or length(btrim(coalesce(p_algorithm_version, ''))) = 0 or length(btrim(coalesce(p_event_contract_version, ''))) = 0 or length(btrim(coalesce(p_decision_policy_version, ''))) = 0 then raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; end if; if p_candidate_range is null or jsonb_typeof(p_candidate_range) <> 'object' then raise exception 'agentic_rectification_invalid_candidate_range' using errcode = 'P0001'; end if; if p_candidates is null or jsonb_typeof(p_candidates) <> 'array' or jsonb_array_length(p_candidates) = 0 then raise exception 'agentic_rectification_invalid_candidates' using errcode = 'P0001'; end if; if p_decision_receipt is null or jsonb_typeof(p_decision_receipt) <> 'object' or jsonb_typeof(p_decision_receipt -> 'display_allowed') is distinct from 'boolean' or jsonb_typeof(p_decision_receipt -> 'accept_allowed') is distinct from 'boolean' or jsonb_typeof(p_decision_receipt -> 'confirm_allowed') is distinct from 'boolean' or jsonb_typeof(p_decision_receipt -> 'overall_confidence') is distinct from 'string' then raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001'; end if; if p_execution_ledger is null or jsonb_typeof(p_execution_ledger) <> 'array' then raise exception 'agentic_rectification_invalid_execution_ledger' using errcode = 'P0001'; end if; v_display_allowed := (p_decision_receipt ->> 'display_allowed')::boolean; v_selection_allowed := (p_decision_receipt ->> 'accept_allowed')::boolean; v_confirmation_allowed := (p_decision_receipt ->> 'confirm_allowed')::boolean; v_overall_confidence := p_decision_receipt ->> 'overall_confidence'; v_saved_decision_receipt := p_decision_receipt - 'representative_candidate_id'; if v_overall_confidence not in ('low', 'medium', 'high') then raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001'; end if; if (p_decision_receipt ? 'margin_percent') and jsonb_typeof(p_decision_receipt -> 'margin_percent') not in ('number', 'null') then raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001'; end if; v_margin_percent := case when jsonb_typeof(p_decision_receipt -> 'margin_percent') = 'number' then (p_decision_receipt ->> 'margin_percent')::numeric else null end; if v_selection_allowed and not v_display_allowed then raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001'; end if; if v_confirmation_allowed and not v_selection_allowed then raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001'; end if; v_representative_time_text := nullif(btrim(coalesce(p_decision_receipt ->> 'representative_time', '')), ''); if v_representative_time_text is not null then if v_representative_time_text !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' then raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001'; end if; v_representative_time := v_representative_time_text::time without time zone; end if; if v_confirmation_allowed and v_representative_time is null then raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001'; 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' using errcode = 'P0001'; end if; if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; end if; if v_case.skill_version is distinct from p_skill_version then raise exception 'agentic_rectification_skill_version_mismatch' using errcode = 'P0001'; end if; select * into v_cached from public.agentic_rectification_results where case_id = p_case_id and invalidated_at is null and evidence_ledger_fingerprint = p_evidence_ledger_fingerprint and candidate_range_fingerprint = p_candidate_range_fingerprint and skill_version = p_skill_version and algorithm_version = p_algorithm_version and event_contract_version = p_event_contract_version and decision_policy_version = p_decision_policy_version order by created_at desc limit 1; if found then return jsonb_build_object( 'result_id', v_cached.id, 'cached', true, 'candidates', v_cached.candidates, 'overall_confidence', v_cached.overall_confidence, 'margin_percent', v_cached.margin_percent, 'display_allowed', v_cached.display_allowed, 'selection_allowed', v_cached.selection_allowed, 'confirmation_allowed', v_cached.confirmation_allowed, 'representative_time', v_cached.representative_time, 'algorithm_version', v_cached.algorithm_version, 'event_contract_version', v_cached.event_contract_version, 'decision_policy_version', v_cached.decision_policy_version, 'decision_receipt', v_cached.decision_receipt, 'execution_ledger', v_cached.execution_ledger ); end if; v_snapshot := v_case.baseline_birth_snapshot; insert into public.agentic_rectification_results ( user_id, session_id, case_id, engine_result_id, canonical_input_hash, algorithm_version, evidence_ledger_fingerprint, candidate_range_fingerprint, skill_version, event_contract_version, decision_policy_version, candidate_range, candidates, overall_confidence, margin_percent, display_allowed, selection_allowed, confirmation_allowed, representative_time, decision_receipt, execution_ledger, baseline_birth_date, baseline_reported_birth_time, baseline_active_birth_time, baseline_birth_time_source, baseline_birth_time_period, baseline_uncertainty_before_minutes, baseline_uncertainty_after_minutes, baseline_latitude, baseline_longitude, baseline_timezone_offset ) values ( v_case.user_id, v_case.session_id, v_case.id, p_engine_result_id, p_evidence_ledger_fingerprint, p_algorithm_version, p_evidence_ledger_fingerprint, p_candidate_range_fingerprint, p_skill_version, p_event_contract_version, p_decision_policy_version, p_candidate_range, '[]'::jsonb, v_overall_confidence, v_margin_percent, v_display_allowed, v_selection_allowed, v_confirmation_allowed, v_representative_time, v_saved_decision_receipt, p_execution_ledger, (v_snapshot ->> 'birth_date')::date, (v_snapshot ->> 'reported_birth_time')::time without time zone, (v_snapshot ->> 'active_birth_time')::time without time zone, v_snapshot ->> 'birth_time_source', v_snapshot ->> 'birth_time_period', (v_snapshot ->> 'uncertainty_before_minutes')::integer, (v_snapshot ->> 'uncertainty_after_minutes')::integer, (v_snapshot ->> 'latitude')::double precision, (v_snapshot ->> 'longitude')::double precision, (v_snapshot ->> 'timezone_offset')::double precision ) returning id into v_result_id; for v_candidate, v_candidate_ordinal in select item.value, item.ordinality::integer from pg_catalog.jsonb_array_elements(p_candidates) with ordinality as item(value, ordinality) loop if jsonb_typeof(v_candidate) <> 'object' then raise exception 'agentic_rectification_invalid_candidates' using errcode = 'P0001'; end if; v_candidate_time_text := nullif(btrim(coalesce(v_candidate ->> 'time', '')), ''); if v_candidate_time_text is null or v_candidate_time_text !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' or v_candidate_time_text = any(v_seen_times) then raise exception 'agentic_rectification_invalid_candidates' using errcode = 'P0001'; end if; v_seen_times := pg_catalog.array_append(v_seen_times, v_candidate_time_text); v_candidate_time := v_candidate_time_text::time without time zone; v_candidate_id := gen_random_uuid(); if v_representative_time is not null and v_candidate_time is not distinct from v_representative_time then v_representative_count := v_representative_count + 1; v_representative_candidate_id := v_candidate_id; end if; v_saved_candidate := jsonb_set( v_candidate - 'candidate_id', '{candidate_id}', to_jsonb(v_candidate_id::text), true ); insert into public.agentic_rectification_candidates ( id, result_id, user_id, case_id, ordinal, candidate_time, candidate_payload, is_representative ) values ( v_candidate_id, v_result_id, p_user_id, p_case_id, v_candidate_ordinal, v_candidate_time, v_saved_candidate, v_representative_time is not null and v_candidate_time is not distinct from v_representative_time ); v_saved_candidates := v_saved_candidates || jsonb_build_array(v_saved_candidate); end loop; if v_representative_time is not null and v_representative_count <> 1 then raise exception 'agentic_rectification_invalid_decision_receipt' using errcode = 'P0001'; end if; if v_representative_candidate_id is not null then v_saved_decision_receipt := jsonb_set( v_saved_decision_receipt, '{representative_candidate_id}', to_jsonb(v_representative_candidate_id::text), true ); end if; update public.agentic_rectification_results set candidates = v_saved_candidates, decision_receipt = v_saved_decision_receipt, updated_at = pg_catalog.now() where id = v_result_id; return jsonb_build_object( 'result_id', v_result_id, 'cached', false, 'candidates', v_saved_candidates, 'overall_confidence', v_overall_confidence, 'margin_percent', v_margin_percent, 'display_allowed', v_display_allowed, 'selection_allowed', v_selection_allowed, 'confirmation_allowed', v_confirmation_allowed, 'representative_time', v_representative_time, 'algorithm_version', p_algorithm_version, 'event_contract_version', p_event_contract_version, 'decision_policy_version', p_decision_policy_version, 'decision_receipt', v_saved_decision_receipt, 'execution_ledger', p_execution_ledger ); end; $$; revoke all on function public.persist_agentic_rectification_candidate_v2(uuid, uuid, text, text, text, text, text, text, text, jsonb, jsonb, jsonb, jsonb) from public, anon, authenticated; grant execute on function public.persist_agentic_rectification_candidate_v2(uuid, uuid, text, text, text, text, text, text, text, jsonb, jsonb, jsonb, jsonb) to service_role; -- --------------------------------------------------------------------------- -- 4. V2 recovery projections: preserve existing shape and expose decision data -- --------------------------------------------------------------------------- create or replace function public.get_agentic_rectification_case( p_user_id uuid, p_case_id uuid ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_case public.agentic_rectification_cases%rowtype; v_result public.agentic_rectification_results%rowtype; v_evidence_count bigint; v_turn_count bigint; begin if p_user_id is null or p_case_id is null then raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; end if; select * into v_case from public.agentic_rectification_cases where id = p_case_id and user_id = p_user_id; if not found then raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; end if; select count(*) into v_evidence_count from public.agentic_rectification_evidence where case_id = v_case.id; select count(*) into v_turn_count from public.agentic_rectification_turns where case_id = v_case.id; select * into v_result from public.agentic_rectification_results where case_id = v_case.id and invalidated_at is null order by created_at desc limit 1; return jsonb_build_object( 'case_id', v_case.id, 'session_id', v_case.session_id, 'status', v_case.status, 'skill_name', v_case.skill_name, 'skill_version', v_case.skill_version, 'candidate_range', v_case.candidate_range, 'accepted_time', v_case.accepted_time, 'confirmed_time', v_case.confirmed_time, 'created_at', v_case.created_at, 'last_activity_at', v_case.last_activity_at, 'completed_at', v_case.completed_at, 'closed_reason', v_case.closed_reason, 'evidence_count', v_evidence_count, 'turn_count', v_turn_count, 'latest_result', case when v_result.id is null then null else jsonb_build_object( 'result_id', v_result.id, 'candidates', v_result.candidates, 'overall_confidence', v_result.overall_confidence, 'display_allowed', v_result.display_allowed, 'selection_allowed', v_result.selection_allowed, 'confirmation_allowed', v_result.confirmation_allowed, 'representative_time', v_result.representative_time, 'selected_candidate_id', v_result.selected_candidate_id, 'selected_time', v_result.selected_time, 'selection_kind', v_result.selection_kind, 'evidence_ledger_fingerprint', v_result.evidence_ledger_fingerprint, 'candidate_range_fingerprint', v_result.candidate_range_fingerprint, 'skill_version', v_result.skill_version, 'algorithm_version', v_result.algorithm_version, 'event_contract_version', v_result.event_contract_version, 'decision_policy_version', v_result.decision_policy_version, 'decision_receipt', v_result.decision_receipt, 'execution_ledger', v_result.execution_ledger, 'created_at', v_result.created_at, 'invalidated_at', v_result.invalidated_at ) end ); end; $$; revoke all on function public.get_agentic_rectification_case(uuid, uuid) from public, anon, authenticated; grant execute on function public.get_agentic_rectification_case(uuid, uuid) to service_role; 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_case public.agentic_rectification_cases%rowtype; v_turns jsonb; v_evidence jsonb; v_summary public.agentic_rectification_case_conversation_summaries%rowtype; v_result public.agentic_rectification_results%rowtype; v_evidence_count bigint; v_turn_count bigint; begin if p_user_id is null or p_case_id is null then raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; end if; select * into v_case from public.agentic_rectification_cases where id = p_case_id and user_id = p_user_id; if not found then raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; end if; select coalesce(jsonb_agg(jsonb_build_object( 'id', recent.id, 'role', message.role, 'text', message.text, 'status', recent.status, 'created_at', recent.created_at, 'completed_at', recent.completed_at ) order by recent.created_at, recent.id, message.ordinal), '[]'::jsonb) into v_turns from ( select t.* from public.agentic_rectification_turns t where t.case_id = v_case.id order by t.created_at desc, t.id desc limit 50 ) recent cross join lateral ( values (1, 'user'::text, recent.user_message), (2, 'assistant'::text, recent.assistant_message) ) as message(ordinal, role, text) where message.text is not null; select coalesce(jsonb_agg(jsonb_build_object( 'id', e.id, 'source_turn_id', e.source_turn_id, 'subject', e.subject, 'event_kind', e.event_kind, 'domain', e.domain, 'occurred_from', e.occurred_from, 'occurred_to', e.occurred_to, 'date_precision', e.date_precision, 'summary', e.summary, 'status', e.status, 'supersedes_evidence_id', e.supersedes_evidence_id, 'created_at', e.created_at ) order by e.created_at, e.id), '[]'::jsonb) into v_evidence from public.agentic_rectification_evidence e where e.case_id = v_case.id; select count(*) into v_evidence_count from public.agentic_rectification_evidence where case_id = v_case.id; select count(*) into v_turn_count from public.agentic_rectification_turns where case_id = v_case.id; select * into v_summary from public.agentic_rectification_case_conversation_summaries where case_id = v_case.id; if not found then perform public.refresh_agentic_rectification_case_conversation_summary(v_case.id); select * into v_summary from public.agentic_rectification_case_conversation_summaries where case_id = v_case.id; end if; select * into v_result from public.agentic_rectification_results where case_id = v_case.id and invalidated_at is null order by created_at desc, id desc limit 1; return jsonb_build_object( 'case', jsonb_build_object( 'case_id', v_case.id, 'session_id', v_case.session_id, 'status', v_case.status, 'skill_name', v_case.skill_name, 'skill_version', v_case.skill_version, 'candidate_range', v_case.candidate_range, 'accepted_time', v_case.accepted_time, 'confirmed_time', v_case.confirmed_time, 'completed_at', v_case.completed_at, 'closed_reason', v_case.closed_reason, 'last_activity_at', v_case.last_activity_at, 'evidence_count', v_evidence_count, 'turn_count', v_turn_count ), 'turns', v_turns, 'evidence', v_evidence, 'conversation_summary', jsonb_build_object( 'confirmed_evidence_summary', v_summary.confirmed_evidence_summary, 'pending_revisions', v_summary.pending_revisions, 'active_focus', v_summary.active_focus, 'declined_skipped_topics', v_summary.declined_skipped_topics, 'candidate_divergence_summary', v_summary.candidate_divergence_summary, 'missing_evidence_categories', v_summary.missing_evidence_categories, 'last_result_policy', v_summary.last_result_policy, 'summary_version', v_summary.summary_version, 'updated_at', v_summary.updated_at ), 'latest_result', case when v_result.id is null then null else jsonb_build_object( 'result_id', v_result.id, 'candidates', v_result.candidates, 'overall_confidence', v_result.overall_confidence, 'display_allowed', v_result.display_allowed, 'selection_allowed', v_result.selection_allowed, 'confirmation_allowed', v_result.confirmation_allowed, 'representative_time', v_result.representative_time, 'selected_candidate_id', v_result.selected_candidate_id, 'selected_time', v_result.selected_time, 'selection_kind', v_result.selection_kind, 'evidence_ledger_fingerprint', v_result.evidence_ledger_fingerprint, 'candidate_range_fingerprint', v_result.candidate_range_fingerprint, 'skill_version', v_result.skill_version, 'algorithm_version', v_result.algorithm_version, 'event_contract_version', v_result.event_contract_version, 'decision_policy_version', v_result.decision_policy_version, 'decision_receipt', v_result.decision_receipt, 'execution_ledger', v_result.execution_ledger, 'created_at', v_result.created_at, 'invalidated_at', v_result.invalidated_at ) end ); end; $$; revoke all on function public.get_agentic_rectification_case_dossier(uuid, uuid) from public, anon, authenticated; grant execute on function public.get_agentic_rectification_case_dossier(uuid, uuid) to service_role; -- --------------------------------------------------------------------------- -- 5. V2 acceptance: UUID lookup, request idempotency, accepted-only state -- --------------------------------------------------------------------------- create or replace function public.accept_agentic_rectification_candidate_for_case_v2( p_user_id uuid, p_case_id uuid, p_result_id uuid, p_candidate_id uuid, p_request_id uuid ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_case public.agentic_rectification_cases%rowtype; v_result public.agentic_rectification_results%rowtype; v_candidate public.agentic_rectification_candidates%rowtype; v_profile public.profiles%rowtype; v_snapshot jsonb; v_existing_decision public.agentic_rectification_candidate_decisions%rowtype; v_response jsonb; begin if p_user_id is null or p_case_id is null or p_result_id is null or p_candidate_id is null or p_request_id is null then raise exception 'agentic_rectification_candidate_invalid_input' using errcode = 'P0001'; end if; perform pg_catalog.pg_advisory_xact_lock( pg_catalog.hashtextextended(p_user_id::text || ':' || p_request_id::text, 0) ); 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' using errcode = 'P0001'; end if; select * into v_result from public.agentic_rectification_results where id = p_result_id and user_id = p_user_id and case_id = p_case_id for update; if not found then raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; end if; select * into v_candidate from public.agentic_rectification_candidates where id = p_candidate_id and result_id = p_result_id and user_id = p_user_id and case_id = p_case_id; if not found then raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; end if; select * into v_existing_decision from public.agentic_rectification_candidate_decisions where user_id = p_user_id and request_id = p_request_id for update; if found then if v_existing_decision.decision_kind <> 'accept' or v_existing_decision.case_id <> p_case_id or v_existing_decision.result_id <> p_result_id or v_existing_decision.candidate_id <> p_candidate_id then raise exception 'agentic_rectification_candidate_request_conflict' using errcode = 'P0001'; end if; return jsonb_set(v_existing_decision.response, '{idempotent}', 'true'::jsonb, true); end if; if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; end if; if v_result.invalidated_at is not null or v_result.expires_at <= pg_catalog.now() then raise exception 'agentic_rectification_candidate_expired' using errcode = 'P0001'; end if; if not v_result.display_allowed or not v_result.selection_allowed then raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; end if; if exists ( select 1 from public.agentic_rectification_results newer where newer.user_id = p_user_id and newer.case_id = p_case_id and newer.invalidated_at is null and newer.created_at > v_result.created_at ) then raise exception 'agentic_rectification_candidate_superseded' using errcode = 'P0001'; end if; select * into v_profile from public.profiles where id = p_user_id for update; if not found then raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; end if; if v_result.selected_candidate_id is not null then if v_result.selected_candidate_id = p_candidate_id and v_result.selected_time is not distinct from v_candidate.candidate_time and v_case.accepted_time is not distinct from v_candidate.candidate_time and v_result.selection_kind is not distinct from 'user_accepted' and v_profile.active_birth_time is not distinct from v_candidate.candidate_time and v_profile.birth_time_status is not distinct from 'accepted' then v_response := jsonb_build_object( 'success', true, 'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'), 'status', 'accepted', 'result_id', v_result.id, 'candidate_id', v_candidate.id, 'case_status', 'candidate_accepted', 'idempotent', true ); insert into public.agentic_rectification_candidate_decisions ( user_id, case_id, result_id, candidate_id, request_id, decision_kind, response ) values ( p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'accept', v_response ); return v_response; end if; if v_case.status is distinct from 'candidate_accepted' or v_result.selection_kind is distinct from 'user_accepted' or v_profile.active_birth_time is distinct from v_result.selected_time or v_profile.birth_time_status is distinct from 'accepted' then raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; end if; else if v_result.selected_time is not null then raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; end if; v_snapshot := v_case.baseline_birth_snapshot; if v_profile.birth_date is distinct from (v_snapshot ->> 'birth_date')::date or v_profile.reported_birth_time is distinct from (v_snapshot ->> 'reported_birth_time')::time without time zone or v_profile.active_birth_time is distinct from (v_snapshot ->> 'active_birth_time')::time without time zone or v_profile.birth_time_source is distinct from v_snapshot ->> 'birth_time_source' or v_profile.birth_time_period is distinct from v_snapshot ->> 'birth_time_period' or v_profile.uncertainty_before_minutes is distinct from (v_snapshot ->> 'uncertainty_before_minutes')::integer or v_profile.uncertainty_after_minutes is distinct from (v_snapshot ->> 'uncertainty_after_minutes')::integer or v_profile.latitude is distinct from (v_snapshot ->> 'latitude')::double precision or v_profile.longitude is distinct from (v_snapshot ->> 'longitude')::double precision or v_profile.timezone_offset is distinct from (v_snapshot ->> 'timezone_offset')::double precision then raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; end if; end if; update public.profiles set active_birth_time = v_candidate.candidate_time, birth_time = v_candidate.candidate_time, birth_time_status = 'accepted', rectification_confidence = case when v_result.overall_confidence = 'high' then 100 when v_result.overall_confidence = 'medium' then 70 else 40 end, updated_at = pg_catalog.now() where id = p_user_id; update public.agentic_rectification_results set selected_candidate_id = p_candidate_id, selected_time = v_candidate.candidate_time, selection_kind = 'user_accepted', selected_at = pg_catalog.now(), updated_at = pg_catalog.now() where id = v_result.id; update public.agentic_rectification_results set invalidated_at = pg_catalog.now(), updated_at = pg_catalog.now() where user_id = p_user_id and case_id = p_case_id and id <> v_result.id and invalidated_at is null and selected_time is null; update public.agentic_rectification_cases set status = 'candidate_accepted', accepted_time = v_candidate.candidate_time, confirmed_time = null, completed_at = null, last_activity_at = pg_catalog.now(), updated_at = pg_catalog.now() where id = v_case.id; v_response := jsonb_build_object( 'success', true, 'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'), 'status', 'accepted', 'result_id', v_result.id, 'candidate_id', v_candidate.id, 'case_status', 'candidate_accepted', 'idempotent', false ); insert into public.agentic_rectification_candidate_decisions ( user_id, case_id, result_id, candidate_id, request_id, decision_kind, response ) values ( p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'accept', v_response ); return v_response; end; $$; revoke all on function public.accept_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid) from public, anon, authenticated; grant execute on function public.accept_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid) to service_role; -- --------------------------------------------------------------------------- -- 5. V2 confirmation: exact representative gate plus grounded user consent -- --------------------------------------------------------------------------- create or replace function public.confirm_agentic_rectification_candidate_for_case_v2( p_user_id uuid, p_case_id uuid, p_result_id uuid, p_candidate_id uuid, p_request_id uuid, p_consent_quote text, p_source_turn_id uuid ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_case public.agentic_rectification_cases%rowtype; v_result public.agentic_rectification_results%rowtype; v_candidate public.agentic_rectification_candidates%rowtype; v_profile public.profiles%rowtype; v_turn public.agentic_rectification_turns%rowtype; v_snapshot jsonb; v_existing_decision public.agentic_rectification_candidate_decisions%rowtype; v_response jsonb; begin if p_user_id is null or p_case_id is null or p_result_id is null or p_candidate_id is null or p_request_id is null or p_source_turn_id is null or length(btrim(coalesce(p_consent_quote, ''))) = 0 then raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; end if; perform pg_catalog.pg_advisory_xact_lock( pg_catalog.hashtextextended(p_user_id::text || ':' || p_request_id::text, 0) ); 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' using errcode = 'P0001'; end if; select * into v_result from public.agentic_rectification_results where id = p_result_id and user_id = p_user_id and case_id = p_case_id for update; if not found then raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; end if; select * into v_candidate from public.agentic_rectification_candidates where id = p_candidate_id and result_id = p_result_id and user_id = p_user_id and case_id = p_case_id; if not found then raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; end if; select * into v_existing_decision from public.agentic_rectification_candidate_decisions where user_id = p_user_id and request_id = p_request_id for update; if found then if v_existing_decision.decision_kind <> 'confirm' or v_existing_decision.case_id <> p_case_id or v_existing_decision.result_id <> p_result_id or v_existing_decision.candidate_id <> p_candidate_id then raise exception 'agentic_rectification_candidate_request_conflict' using errcode = 'P0001'; end if; return jsonb_set(v_existing_decision.response, '{idempotent}', 'true'::jsonb, true); end if; if v_case.status = 'confirmed' then if v_result.selected_candidate_id is distinct from p_candidate_id or v_case.confirmed_time is distinct from v_candidate.candidate_time then raise exception 'agentic_rectification_case_already_confirmed' using errcode = 'P0001'; end if; v_response := jsonb_build_object( 'success', true, 'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'), 'status', 'confirmed', 'result_id', v_result.id, 'candidate_id', v_candidate.id, 'case_status', 'confirmed', 'idempotent', true ); insert into public.agentic_rectification_candidate_decisions ( user_id, case_id, result_id, candidate_id, request_id, decision_kind, response ) values ( p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'confirm', v_response ); return v_response; end if; if v_case.status in ('closed', 'abandoned', 'superseded') then raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; end if; select * into v_turn from public.agentic_rectification_turns where id = p_source_turn_id and case_id = p_case_id; if not found then raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; end if; if v_turn.user_message is null or position( public.agentic_rectification_normalize_quote(p_consent_quote) in public.agentic_rectification_normalize_quote(v_turn.user_message) ) = 0 then raise exception 'agentic_rectification_consent_not_grounded' using errcode = 'P0001'; end if; if v_result.invalidated_at is not null or v_result.expires_at <= pg_catalog.now() then raise exception 'agentic_rectification_candidate_expired' using errcode = 'P0001'; end if; if not v_result.confirmation_allowed then raise exception 'agentic_rectification_confirmation_blocked' using errcode = 'P0001'; end if; if not v_candidate.is_representative or v_result.representative_time is distinct from v_candidate.candidate_time then raise exception 'agentic_rectification_confirmation_exact_gate_blocked' using errcode = 'P0001'; end if; if exists ( select 1 from public.agentic_rectification_results newer where newer.user_id = p_user_id and newer.case_id = p_case_id and newer.invalidated_at is null and newer.created_at > v_result.created_at ) then raise exception 'agentic_rectification_candidate_superseded' using errcode = 'P0001'; end if; select * into v_profile from public.profiles where id = p_user_id for update; if not found then raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; end if; if v_result.selected_candidate_id is null then if v_result.selected_time is not null then raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; end if; v_snapshot := v_case.baseline_birth_snapshot; if v_profile.birth_date is distinct from (v_snapshot ->> 'birth_date')::date or v_profile.reported_birth_time is distinct from (v_snapshot ->> 'reported_birth_time')::time without time zone or v_profile.active_birth_time is distinct from (v_snapshot ->> 'active_birth_time')::time without time zone or v_profile.birth_time_source is distinct from v_snapshot ->> 'birth_time_source' or v_profile.birth_time_period is distinct from v_snapshot ->> 'birth_time_period' or v_profile.uncertainty_before_minutes is distinct from (v_snapshot ->> 'uncertainty_before_minutes')::integer or v_profile.uncertainty_after_minutes is distinct from (v_snapshot ->> 'uncertainty_after_minutes')::integer or v_profile.latitude is distinct from (v_snapshot ->> 'latitude')::double precision or v_profile.longitude is distinct from (v_snapshot ->> 'longitude')::double precision or v_profile.timezone_offset is distinct from (v_snapshot ->> 'timezone_offset')::double precision then raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; end if; elsif v_result.selected_candidate_id <> p_candidate_id or v_result.selected_time is distinct from v_candidate.candidate_time or v_result.selection_kind is distinct from 'user_accepted' or v_case.status is distinct from 'candidate_accepted' or v_case.accepted_time is distinct from v_candidate.candidate_time or v_profile.active_birth_time is distinct from v_candidate.candidate_time or v_profile.birth_time_status is distinct from 'accepted' then raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; end if; update public.profiles set active_birth_time = v_candidate.candidate_time, birth_time = v_candidate.candidate_time, birth_time_status = 'confirmed', rectification_confidence = case when v_result.overall_confidence = 'high' then 100 when v_result.overall_confidence = 'medium' then 70 else 40 end, updated_at = pg_catalog.now() where id = p_user_id; update public.agentic_rectification_results set selected_candidate_id = p_candidate_id, selected_time = v_candidate.candidate_time, selection_kind = 'engine_confirmed', selected_at = pg_catalog.now(), updated_at = pg_catalog.now() where id = v_result.id; update public.agentic_rectification_results set invalidated_at = pg_catalog.now(), updated_at = pg_catalog.now() where user_id = p_user_id and case_id = p_case_id and id <> v_result.id and invalidated_at is null and selected_time is null; update public.agentic_rectification_cases set status = 'confirmed', accepted_time = v_candidate.candidate_time, confirmed_time = v_candidate.candidate_time, completed_at = pg_catalog.now(), last_activity_at = pg_catalog.now(), updated_at = pg_catalog.now() where id = v_case.id; v_response := jsonb_build_object( 'success', true, 'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'), 'status', 'confirmed', 'result_id', v_result.id, 'candidate_id', v_candidate.id, 'case_status', 'confirmed', 'idempotent', false ); insert into public.agentic_rectification_candidate_decisions ( user_id, case_id, result_id, candidate_id, request_id, decision_kind, response ) values ( p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'confirm', v_response ); return v_response; end; $$; revoke all on function public.confirm_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid, text, uuid) from public, anon, authenticated; grant execute on function public.confirm_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid, text, uuid) to service_role; -- --------------------------------------------------------------------------- -- 7. Retire every legacy service-role entry that accepts caller-provided time -- --------------------------------------------------------------------------- revoke execute on function public.persist_agentic_rectification_candidate(uuid, uuid, text, text, text, text, text, jsonb, jsonb, text, numeric, boolean, boolean, time without time zone) from service_role; revoke execute on function public.accept_agentic_rectification_candidate(uuid, uuid, uuid, time without time zone) from service_role; revoke execute on function public.accept_agentic_rectification_candidate_for_case(uuid, uuid, uuid, time without time zone) from service_role; revoke execute on function public.confirm_agentic_rectification_birth_time(uuid, uuid, uuid, time without time zone, text, uuid) from service_role; commit;