-- Rectification V10 durable conversation/runtime contract. -- Additive only: V9 RPC signatures remain available for older runners. begin; -- --------------------------------------------------------------------------- -- 1. Durable run attempts and receipt ownership -- --------------------------------------------------------------------------- create table if not exists public.agentic_rectification_run_attempts ( id uuid primary key default gen_random_uuid(), case_id uuid not null references public.agentic_rectification_cases(id) on delete cascade, turn_id uuid not null references public.agentic_rectification_turns(id) on delete cascade, attempt_number integer not null check (attempt_number > 0), status text not null default 'started' check ( status in ('started', 'completed', 'failed', 'retryable', 'aborted') ), error_code text, usage jsonb not null default '{}'::jsonb check (jsonb_typeof(usage) = 'object'), started_at timestamptz not null default pg_catalog.now(), completed_at timestamptz, updated_at timestamptz not null default pg_catalog.now(), unique (turn_id, attempt_number), unique (id, case_id, turn_id), check (status = 'started' or completed_at is not null) ); create index if not exists agentic_rectification_run_attempts_turn_idx on public.agentic_rectification_run_attempts (case_id, turn_id, attempt_number desc); create index if not exists agentic_rectification_run_attempts_success_idx on public.agentic_rectification_run_attempts (turn_id, completed_at desc) where status = 'completed'; alter table public.agentic_rectification_run_phases add column if not exists attempt_id uuid references public.agentic_rectification_run_attempts(id) on delete cascade; alter table public.agentic_rectification_tool_receipts add column if not exists attempt_id uuid references public.agentic_rectification_run_attempts(id) on delete cascade; alter table public.agentic_rectification_turns add column if not exists successful_attempt_id uuid references public.agentic_rectification_run_attempts(id) on delete set null; alter table public.agentic_rectification_turns add column if not exists request_id uuid; create unique index if not exists agentic_rectification_turns_case_request_idx on public.agentic_rectification_turns (case_id, request_id) where request_id is not null; -- Keep the storage-layer allowlists aligned with the V10 RPC allowlists. The -- original V9 CHECK constraints would otherwise reject valid V10 receipts. alter table public.agentic_rectification_run_phases drop constraint if exists agentic_rectification_run_phases_phase_check, add constraint agentic_rectification_run_phases_phase_check check ( phase in ( 'run.started', 'skill.started', 'skill.loaded', 'skill.bound', 'case.loaded', 'intent.classified', 'evidence.proposed', 'evidence.confirmed', 'candidates.comparing', 'candidates.updated', 'diagnostics.completed', 'candidate.accepted', 'birth_time.confirmed', 'answer.composed', 'billing.settled', 'answer.delta', 'run.completed', 'run.failed' ) ); alter table public.agentic_rectification_tool_receipts drop constraint if exists agentic_rectification_tool_receipts_tool_name_check, add constraint agentic_rectification_tool_receipts_tool_name_check check ( tool_name in ( 'rectification-read-case', 'rectification-set-focus', 'rectification-resolve-focus', 'rectification-record-evidence-batch', 'rectification-propose-evidence', 'rectification-confirm-evidence', 'rectification-revise-evidence', 'rectification-compare-candidates', 'rectification-read-diagnostics', 'rectification-offer-candidates', 'rectification-accept-candidate', 'rectification-confirm-birth-time', 'rectification-close-case' ) ), drop constraint if exists agentic_rectification_tool_receipts_public_phase_check, add constraint agentic_rectification_tool_receipts_public_phase_check check ( public_phase in ( 'run.started', 'skill.started', 'skill.loaded', 'skill.bound', 'case.loaded', 'intent.classified', 'evidence.proposed', 'evidence.confirmed', 'candidates.comparing', 'candidates.updated', 'diagnostics.completed', 'candidate.accepted', 'birth_time.confirmed', 'answer.composed', 'billing.settled', 'answer.delta', 'run.completed', 'run.failed' ) ); -- Enforce Case/Turn/Attempt scope below the SECURITY DEFINER RPC layer. alter table public.agentic_rectification_turns drop constraint if exists agentic_rectification_turns_id_case_id_key, add constraint agentic_rectification_turns_id_case_id_key unique (id, case_id); alter table public.agentic_rectification_run_attempts drop constraint if exists agentic_rectification_run_attempts_turn_case_fkey, add constraint agentic_rectification_run_attempts_turn_case_fkey foreign key (turn_id, case_id) references public.agentic_rectification_turns(id, case_id) on delete cascade; alter table public.agentic_rectification_run_phases drop constraint if exists agentic_rectification_run_phases_attempt_scope_fkey, add constraint agentic_rectification_run_phases_attempt_scope_fkey foreign key (attempt_id, case_id, turn_id) references public.agentic_rectification_run_attempts(id, case_id, turn_id) on delete cascade; alter table public.agentic_rectification_tool_receipts drop constraint if exists agentic_rectification_tool_receipts_attempt_scope_fkey, add constraint agentic_rectification_tool_receipts_attempt_scope_fkey foreign key (attempt_id, case_id, turn_id) references public.agentic_rectification_run_attempts(id, case_id, turn_id) on delete cascade; create index if not exists agentic_rectification_run_phases_attempt_idx on public.agentic_rectification_run_phases (attempt_id, sequence); create index if not exists agentic_rectification_tool_receipts_attempt_idx on public.agentic_rectification_tool_receipts (attempt_id, started_at); -- --------------------------------------------------------------------------- -- 2. ConversationFocus and server-owned CaseConversationSummary -- --------------------------------------------------------------------------- create table if not exists public.agentic_rectification_conversation_focuses ( id uuid primary key default gen_random_uuid(), case_id uuid not null references public.agentic_rectification_cases(id) on delete cascade, question_id text not null check (length(btrim(question_id)) > 0), intent text not null check (length(btrim(intent)) > 0), target_evidence_id uuid references public.agentic_rectification_evidence(id) on delete set null, target_domain text check ( target_domain is null or target_domain in ( 'education', 'career', 'relationship', 'relocation', 'finance', 'health', 'family', 'other' ) ), target_kind text check ( target_kind is null or target_kind in ( 'education_start', 'education_completion', 'education_interruption', 'career_entry', 'career_change', 'promotion', 'career_pressure', 'career_exit', 'relationship_start', 'relationship_commitment', 'relationship_separation', 'relocation', 'finance_gain', 'finance_loss', 'self_health_event', 'family_event', 'other' ) ), expected_answer_schema jsonb not null default '{}'::jsonb check (jsonb_typeof(expected_answer_schema) = 'object'), status text not null default 'active' check ( status in ('active', 'resolved', 'declined', 'skipped', 'superseded') ), asked_at timestamptz not null default pg_catalog.now(), resolved_at timestamptz, created_at timestamptz not null default pg_catalog.now(), updated_at timestamptz not null default pg_catalog.now(), unique (case_id, question_id), check ( (status = 'active' and resolved_at is null) or (status <> 'active' and resolved_at is not null) ) ); create unique index if not exists agentic_rectification_one_active_focus_per_case on public.agentic_rectification_conversation_focuses (case_id) where status = 'active'; create index if not exists agentic_rectification_focus_case_history_idx on public.agentic_rectification_conversation_focuses (case_id, asked_at desc); create index if not exists agentic_rectification_focus_evidence_idx on public.agentic_rectification_conversation_focuses (target_evidence_id) where target_evidence_id is not null; create table if not exists public.agentic_rectification_case_conversation_summaries ( case_id uuid primary key references public.agentic_rectification_cases(id) on delete cascade, confirmed_evidence_summary jsonb not null default '[]'::jsonb check (jsonb_typeof(confirmed_evidence_summary) = 'array'), pending_revisions jsonb not null default '[]'::jsonb check (jsonb_typeof(pending_revisions) = 'array'), active_focus jsonb, declined_skipped_topics jsonb not null default '[]'::jsonb check (jsonb_typeof(declined_skipped_topics) = 'array'), candidate_divergence_summary jsonb, missing_evidence_categories jsonb not null default '[]'::jsonb check (jsonb_typeof(missing_evidence_categories) = 'array'), last_result_policy jsonb, summary_version integer not null default 1 check (summary_version > 0), updated_at timestamptz not null default pg_catalog.now(), check (active_focus is null or jsonb_typeof(active_focus) = 'object'), check (candidate_divergence_summary is null or jsonb_typeof(candidate_divergence_summary) = 'object'), check (last_result_policy is null or jsonb_typeof(last_result_policy) = 'object') ); -- Batch idempotency is per Case and per independently parsed event. alter table public.agentic_rectification_evidence add column if not exists idempotency_key text; create unique index if not exists agentic_rectification_evidence_idempotency_idx on public.agentic_rectification_evidence (case_id, idempotency_key) where idempotency_key is not null; -- --------------------------------------------------------------------------- -- 3. RLS and grants: service role owns writes; browser receives projections. -- --------------------------------------------------------------------------- alter table public.agentic_rectification_run_attempts enable row level security; alter table public.agentic_rectification_conversation_focuses enable row level security; alter table public.agentic_rectification_case_conversation_summaries enable row level security; revoke all on table public.agentic_rectification_run_attempts from public, anon, authenticated, service_role; revoke all on table public.agentic_rectification_conversation_focuses from public, anon, authenticated, service_role; revoke all on table public.agentic_rectification_case_conversation_summaries from public, anon, authenticated, service_role; -- No direct table DML is granted. Runtime access is only through the scoped -- SECURITY DEFINER RPCs below. Revoke inherited V9 DML on receipt tables too; -- their legacy and V10 write paths are SECURITY DEFINER functions. revoke insert, update, delete, truncate on table public.agentic_rectification_run_phases, public.agentic_rectification_tool_receipts from service_role; -- --------------------------------------------------------------------------- -- 4. Server summary rebuild. No raw quote, birth snapshot, score or reasoning. -- --------------------------------------------------------------------------- create or replace function public.refresh_agentic_rectification_case_conversation_summary( p_case_id uuid ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_confirmed jsonb; v_pending jsonb; v_active_focus jsonb; v_declined_skipped jsonb; v_candidate_divergence jsonb; v_missing jsonb; v_last_policy jsonb; v_result public.agentic_rectification_results%rowtype; v_summary jsonb; begin if p_case_id is null or not exists ( select 1 from public.agentic_rectification_cases where id = p_case_id ) then raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; end if; select coalesce(jsonb_agg(jsonb_build_object( 'evidence_id', e.id, 'summary', e.summary, 'event_kind', e.event_kind, 'domain', e.domain, 'date_precision', e.date_precision, 'occurred_from', e.occurred_from, 'occurred_to', e.occurred_to ) order by e.created_at, e.id), '[]'::jsonb) into v_confirmed from public.agentic_rectification_evidence e where e.case_id = p_case_id and e.status = 'confirmed'; select coalesce(jsonb_agg(jsonb_build_object( 'evidence_id', e.id, 'supersedes_evidence_id', e.supersedes_evidence_id, 'summary', e.summary, 'event_kind', e.event_kind, 'domain', e.domain, 'date_precision', e.date_precision, 'occurred_from', e.occurred_from, 'occurred_to', e.occurred_to ) order by e.created_at, e.id), '[]'::jsonb) into v_pending from public.agentic_rectification_evidence e where e.case_id = p_case_id and e.status in ('draft', 'pending_confirmation'); select jsonb_build_object( 'id', f.id, 'case_id', f.case_id, 'question_id', f.question_id, 'intent', f.intent, 'target_evidence_id', f.target_evidence_id, 'target_domain', f.target_domain, 'target_kind', f.target_kind, 'expected_answer_schema', f.expected_answer_schema, 'status', f.status, 'asked_at', f.asked_at, 'resolved_at', f.resolved_at ) into v_active_focus from public.agentic_rectification_conversation_focuses f where f.case_id = p_case_id and f.status = 'active' order by f.asked_at desc, f.id limit 1; select coalesce(jsonb_agg(jsonb_build_object( 'focus_id', f.id, 'intent', f.intent, 'target_evidence_id', f.target_evidence_id, 'target_domain', f.target_domain, 'target_kind', f.target_kind, 'status', f.status, 'asked_at', f.asked_at, 'resolved_at', f.resolved_at ) order by f.resolved_at, f.id), '[]'::jsonb) into v_declined_skipped from public.agentic_rectification_conversation_focuses f where f.case_id = p_case_id and f.status in ('declined', 'skipped'); select * into v_result from public.agentic_rectification_results where case_id = p_case_id and invalidated_at is null order by created_at desc, id desc limit 1; if v_result.id is null then v_candidate_divergence := null; v_last_policy := null; else v_candidate_divergence := jsonb_build_object( 'result_id', v_result.id, 'overall_confidence', v_result.overall_confidence, 'margin_percent', v_result.margin_percent, 'candidate_count', jsonb_array_length(coalesce(v_result.candidates, '[]'::jsonb)), 'tied_candidate_count', ( select count(*) from jsonb_array_elements(coalesce(v_result.candidates, '[]'::jsonb)) candidate where coalesce((candidate->>'tied_minute_count')::integer, 0) > 1 ) ); v_last_policy := jsonb_build_object( 'result_id', v_result.id, 'selection_allowed', v_result.selection_allowed, 'confirmation_allowed', v_result.confirmation_allowed, 'representative_time', v_result.representative_time, 'selected_time', v_result.selected_time, 'selection_kind', v_result.selection_kind, 'algorithm_version', v_result.algorithm_version ); end if; select coalesce(jsonb_agg(domain order by ordinal), '[]'::jsonb) into v_missing from unnest(array[ 'education', 'career', 'relationship', 'relocation', 'finance', 'health', 'family' ]::text[]) with ordinality as required(domain, ordinal) where not exists ( select 1 from public.agentic_rectification_evidence e where e.case_id = p_case_id and e.status = 'confirmed' and e.domain = required.domain ); insert into public.agentic_rectification_case_conversation_summaries ( case_id, confirmed_evidence_summary, pending_revisions, active_focus, declined_skipped_topics, candidate_divergence_summary, missing_evidence_categories, last_result_policy, summary_version, updated_at ) values ( p_case_id, v_confirmed, v_pending, v_active_focus, v_declined_skipped, v_candidate_divergence, v_missing, v_last_policy, 1, pg_catalog.now() ) on conflict (case_id) do update set confirmed_evidence_summary = excluded.confirmed_evidence_summary, pending_revisions = excluded.pending_revisions, active_focus = excluded.active_focus, declined_skipped_topics = excluded.declined_skipped_topics, candidate_divergence_summary = excluded.candidate_divergence_summary, missing_evidence_categories = excluded.missing_evidence_categories, last_result_policy = excluded.last_result_policy, summary_version = excluded.summary_version, updated_at = excluded.updated_at; v_summary := jsonb_build_object( 'confirmed_evidence_summary', v_confirmed, 'pending_revisions', v_pending, 'active_focus', v_active_focus, 'declined_skipped_topics', v_declined_skipped, 'candidate_divergence_summary', v_candidate_divergence, 'missing_evidence_categories', v_missing, 'last_result_policy', v_last_policy, 'summary_version', 1 ); return v_summary; end; $$; revoke all on function public.refresh_agentic_rectification_case_conversation_summary(uuid) from public, anon, authenticated; grant execute on function public.refresh_agentic_rectification_case_conversation_summary(uuid) to service_role; create or replace function public.agentic_rectification_refresh_conversation_summary_trigger() returns trigger language plpgsql security definer set search_path = '' as $$ declare v_old_case_id uuid := case when tg_op in ('UPDATE', 'DELETE') then old.case_id else null end; v_new_case_id uuid := case when tg_op in ('INSERT', 'UPDATE') then new.case_id else null end; begin if v_old_case_id is not null and exists ( select 1 from public.agentic_rectification_cases where id = v_old_case_id ) then perform public.refresh_agentic_rectification_case_conversation_summary(v_old_case_id); end if; if v_new_case_id is not null and v_new_case_id is distinct from v_old_case_id and exists ( select 1 from public.agentic_rectification_cases where id = v_new_case_id ) then perform public.refresh_agentic_rectification_case_conversation_summary(v_new_case_id); end if; return case when tg_op = 'DELETE' then old else new end; end; $$; revoke all on function public.agentic_rectification_refresh_conversation_summary_trigger() from public, anon, authenticated; drop trigger if exists agentic_rectification_evidence_refresh_summary on public.agentic_rectification_evidence; create trigger agentic_rectification_evidence_refresh_summary after insert or update or delete on public.agentic_rectification_evidence for each row execute function public.agentic_rectification_refresh_conversation_summary_trigger(); drop trigger if exists agentic_rectification_focus_refresh_summary on public.agentic_rectification_conversation_focuses; create trigger agentic_rectification_focus_refresh_summary after insert or update or delete on public.agentic_rectification_conversation_focuses for each row execute function public.agentic_rectification_refresh_conversation_summary_trigger(); drop trigger if exists agentic_rectification_result_refresh_summary on public.agentic_rectification_results; create trigger agentic_rectification_result_refresh_summary after insert or update or delete on public.agentic_rectification_results for each row execute function public.agentic_rectification_refresh_conversation_summary_trigger(); -- Terminal cases cannot retain an active question. Evidence/focus RPCs below -- also reject all new writes once the Case is terminal. create or replace function public.agentic_rectification_terminal_focus_guard() returns trigger language plpgsql security definer set search_path = '' as $$ begin if new.status in ('confirmed', 'closed', 'abandoned', 'superseded') and old.status is distinct from new.status then update public.agentic_rectification_conversation_focuses set status = 'superseded', resolved_at = coalesce(resolved_at, pg_catalog.now()), updated_at = pg_catalog.now() where case_id = new.id and status = 'active'; end if; return new; end; $$; revoke all on function public.agentic_rectification_terminal_focus_guard() from public, anon, authenticated; drop trigger if exists agentic_rectification_cases_terminal_focus_guard on public.agentic_rectification_cases; create trigger agentic_rectification_cases_terminal_focus_guard after update of status on public.agentic_rectification_cases for each row execute function public.agentic_rectification_terminal_focus_guard(); -- Existing cases receive a durable summary before the new dossier is used. select public.refresh_agentic_rectification_case_conversation_summary(id) from public.agentic_rectification_cases; -- --------------------------------------------------------------------------- -- 5. Focus RPCs. The server, not the model, validates the active target. -- --------------------------------------------------------------------------- create or replace function public.set_agentic_rectification_conversation_focus( p_user_id uuid, p_case_id uuid, p_question_id text, p_intent text, p_target_evidence_id uuid, p_target_domain text, p_target_kind text, p_expected_answer_schema jsonb ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_case public.agentic_rectification_cases%rowtype; v_focus public.agentic_rectification_conversation_focuses%rowtype; begin if p_user_id is null or p_case_id is null or length(btrim(coalesce(p_question_id, ''))) = 0 or length(btrim(coalesce(p_intent, ''))) = 0 or p_expected_answer_schema is null or jsonb_typeof(p_expected_answer_schema) <> 'object' 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 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 p_target_evidence_id is not null and not exists ( select 1 from public.agentic_rectification_evidence where id = p_target_evidence_id and case_id = p_case_id ) then raise exception 'agentic_rectification_evidence_not_found' using errcode = 'P0001'; end if; select * into v_focus from public.agentic_rectification_conversation_focuses where case_id = p_case_id and question_id = p_question_id; if found then if v_focus.status = 'active' and v_focus.intent = p_intent and v_focus.target_evidence_id is not distinct from p_target_evidence_id and v_focus.target_domain is not distinct from p_target_domain and v_focus.target_kind is not distinct from p_target_kind and v_focus.expected_answer_schema = p_expected_answer_schema then return jsonb_build_object( 'focus_id', v_focus.id, 'status', v_focus.status, 'idempotent', true ); end if; raise exception 'agentic_rectification_focus_idempotency_conflict' using errcode = 'P0001'; end if; update public.agentic_rectification_conversation_focuses set status = 'superseded', resolved_at = pg_catalog.now(), updated_at = pg_catalog.now() where case_id = p_case_id and status = 'active'; insert into public.agentic_rectification_conversation_focuses ( case_id, question_id, intent, target_evidence_id, target_domain, target_kind, expected_answer_schema, status, asked_at ) values ( p_case_id, p_question_id, p_intent, p_target_evidence_id, p_target_domain, p_target_kind, p_expected_answer_schema, 'active', pg_catalog.now() ) returning * into v_focus; return jsonb_build_object( 'focus_id', v_focus.id, 'status', v_focus.status, 'idempotent', false ); end; $$; revoke all on function public.set_agentic_rectification_conversation_focus( uuid, uuid, text, text, uuid, text, text, jsonb ) from public, anon, authenticated; grant execute on function public.set_agentic_rectification_conversation_focus( uuid, uuid, text, text, uuid, text, text, jsonb ) to service_role; create or replace function public.resolve_agentic_rectification_conversation_focus( p_user_id uuid, p_case_id uuid, p_focus_id uuid, p_status text, p_evidence_id uuid ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_case public.agentic_rectification_cases%rowtype; v_focus public.agentic_rectification_conversation_focuses%rowtype; begin if p_user_id is null or p_case_id is null or p_focus_id is null or p_status not in ('resolved', 'declined', 'skipped', 'superseded') 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; if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; end if; select * into v_focus from public.agentic_rectification_conversation_focuses where id = p_focus_id and case_id = p_case_id for update; if not found then raise exception 'agentic_rectification_focus_not_found' using errcode = 'P0001'; end if; if p_evidence_id is not null and not exists ( select 1 from public.agentic_rectification_evidence where id = p_evidence_id and case_id = p_case_id ) then raise exception 'agentic_rectification_evidence_not_found' using errcode = 'P0001'; end if; if v_focus.target_evidence_id is not null and p_evidence_id is distinct from v_focus.target_evidence_id then raise exception 'agentic_rectification_focus_target_mismatch' using errcode = 'P0001'; end if; if v_focus.status <> 'active' then if v_focus.status = p_status and (v_focus.target_evidence_id is null or v_focus.target_evidence_id is not distinct from p_evidence_id) then return jsonb_build_object( 'focus_id', v_focus.id, 'status', v_focus.status, 'evidence_id', v_focus.target_evidence_id, 'idempotent', true ); end if; raise exception 'agentic_rectification_focus_not_active' using errcode = 'P0001'; end if; update public.agentic_rectification_conversation_focuses set status = p_status, target_evidence_id = coalesce(target_evidence_id, p_evidence_id), resolved_at = pg_catalog.now(), updated_at = pg_catalog.now() where id = v_focus.id; return jsonb_build_object( 'focus_id', v_focus.id, 'status', p_status, 'evidence_id', coalesce(v_focus.target_evidence_id, p_evidence_id), 'idempotent', false ); end; $$; revoke all on function public.resolve_agentic_rectification_conversation_focus( uuid, uuid, uuid, text, uuid ) from public, anon, authenticated; grant execute on function public.resolve_agentic_rectification_conversation_focus( uuid, uuid, uuid, text, uuid ) to service_role; create or replace function public.confirm_agentic_rectification_evidence_v10( p_user_id uuid, p_case_id uuid, p_focus_id uuid, p_evidence_id uuid ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_case public.agentic_rectification_cases%rowtype; v_focus public.agentic_rectification_conversation_focuses%rowtype; v_result jsonb; begin if p_user_id is null or p_case_id is null or p_focus_id is null or p_evidence_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 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 not exists ( select 1 from public.agentic_rectification_evidence where id = p_evidence_id and case_id = p_case_id ) then raise exception 'agentic_rectification_evidence_not_found' using errcode = 'P0001'; end if; select * into v_focus from public.agentic_rectification_conversation_focuses where id = p_focus_id and case_id = p_case_id for update; if not found then raise exception 'agentic_rectification_focus_not_found' using errcode = 'P0001'; end if; if v_focus.target_evidence_id is not null and v_focus.target_evidence_id <> p_evidence_id then raise exception 'agentic_rectification_focus_target_mismatch' using errcode = 'P0001'; end if; if v_focus.status not in ('active', 'resolved') then raise exception 'agentic_rectification_focus_not_active' using errcode = 'P0001'; end if; if v_focus.status = 'resolved' and v_focus.target_evidence_id is distinct from p_evidence_id then raise exception 'agentic_rectification_focus_target_mismatch' using errcode = 'P0001'; end if; v_result := public.confirm_agentic_rectification_evidence( p_user_id, p_case_id, p_evidence_id ); if v_focus.status = 'active' then perform public.resolve_agentic_rectification_conversation_focus( p_user_id, p_case_id, p_focus_id, 'resolved', p_evidence_id ); end if; return v_result || jsonb_build_object('focus_id', p_focus_id); end; $$; revoke all on function public.confirm_agentic_rectification_evidence_v10(uuid, uuid, uuid, uuid) from public, anon, authenticated; grant execute on function public.confirm_agentic_rectification_evidence_v10(uuid, uuid, uuid, uuid) to service_role; create or replace function public.revise_agentic_rectification_evidence_v10( p_user_id uuid, p_case_id uuid, p_focus_id uuid, p_evidence_id uuid, p_user_quote text, p_occurred_from date, p_occurred_to date, p_date_precision text, p_summary text ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_case public.agentic_rectification_cases%rowtype; v_focus public.agentic_rectification_conversation_focuses%rowtype; v_existing public.agentic_rectification_evidence%rowtype; v_result jsonb; begin if p_user_id is null or p_case_id is null or p_focus_id is null or p_evidence_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 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; select * into v_focus from public.agentic_rectification_conversation_focuses where id = p_focus_id and case_id = p_case_id for update; if not found then raise exception 'agentic_rectification_focus_not_found' using errcode = 'P0001'; end if; if v_focus.target_evidence_id is not null and v_focus.target_evidence_id <> p_evidence_id then raise exception 'agentic_rectification_focus_target_mismatch' using errcode = 'P0001'; end if; if v_focus.status not in ('active', 'resolved') then raise exception 'agentic_rectification_focus_not_active' using errcode = 'P0001'; end if; if v_focus.status = 'resolved' then if v_focus.target_evidence_id is distinct from p_evidence_id then raise exception 'agentic_rectification_focus_target_mismatch' using errcode = 'P0001'; end if; select * into v_existing from public.agentic_rectification_evidence where case_id = p_case_id and supersedes_evidence_id = p_evidence_id and user_quote = p_user_quote and occurred_from is not distinct from p_occurred_from and occurred_to is not distinct from p_occurred_to and date_precision = p_date_precision and summary = p_summary order by created_at desc, id desc limit 1; if not found then raise exception 'agentic_rectification_focus_idempotency_conflict' using errcode = 'P0001'; end if; return jsonb_build_object( 'evidence_id', v_existing.id, 'supersedes_evidence_id', p_evidence_id, 'focus_id', p_focus_id, 'idempotent', true ); end if; v_result := public.revise_agentic_rectification_evidence( p_user_id, p_case_id, p_evidence_id, p_user_quote, p_occurred_from, p_occurred_to, p_date_precision, p_summary ); perform public.resolve_agentic_rectification_conversation_focus( p_user_id, p_case_id, p_focus_id, 'resolved', p_evidence_id ); return v_result || jsonb_build_object('focus_id', p_focus_id); end; $$; revoke all on function public.revise_agentic_rectification_evidence_v10( uuid, uuid, uuid, uuid, text, date, date, text, text ) from public, anon, authenticated; grant execute on function public.revise_agentic_rectification_evidence_v10( uuid, uuid, uuid, uuid, text, date, date, text, text ) to service_role; -- --------------------------------------------------------------------------- -- 6. Batch evidence: per-item validation/result and transactional idempotency. -- --------------------------------------------------------------------------- create or replace function public.record_agentic_rectification_evidence_batch( p_user_id uuid, p_case_id uuid, p_source_turn_id uuid, p_focus_id uuid, p_items jsonb ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_case public.agentic_rectification_cases%rowtype; v_turn public.agentic_rectification_turns%rowtype; v_focus public.agentic_rectification_conversation_focuses%rowtype; v_focus_replay boolean := false; v_focus_match_id uuid; v_focus_match_count integer := 0; v_focus_resolution text := 'not_requested'; v_item jsonb; v_index bigint; v_item_key text; v_quote text; v_subject text; v_kind text; v_domain text; v_precision text; v_summary text; v_from date; v_to date; v_status text; v_outcome text; v_error text; v_clarification jsonb; v_existing public.agentic_rectification_evidence%rowtype; v_evidence_id uuid; v_idempotent boolean; v_results jsonb := '[]'::jsonb; v_accepted integer := 0; v_needs integer := 0; v_rejected integer := 0; begin if p_user_id is null or p_case_id is null or p_source_turn_id is null or p_items is null or jsonb_typeof(p_items) <> 'array' or jsonb_array_length(p_items) = 0 or jsonb_array_length(p_items) > 12 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 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; 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 then raise exception 'agentic_rectification_quote_not_grounded' using errcode = 'P0001'; end if; if p_focus_id is not null then select * into v_focus from public.agentic_rectification_conversation_focuses where id = p_focus_id and case_id = p_case_id for update; if not found or v_focus.status not in ('active', 'resolved') then raise exception 'agentic_rectification_focus_not_active' using errcode = 'P0001'; end if; v_focus_replay := v_focus.status = 'resolved'; if not v_focus_replay and v_focus.target_evidence_id is not null then raise exception 'agentic_rectification_focus_target_mismatch' using errcode = 'P0001'; end if; v_focus_resolution := case when v_focus_replay then 'idempotent' else 'active' end; if v_focus_replay and exists ( select 1 from jsonb_array_elements(p_items) item where length(btrim(coalesce(item->>'idempotency_key', ''))) = 0 or not exists ( select 1 from public.agentic_rectification_evidence e where e.case_id = p_case_id and e.idempotency_key = btrim(item->>'idempotency_key') ) ) then raise exception 'agentic_rectification_focus_not_active' using errcode = 'P0001'; end if; end if; for v_item, v_index in select value, ordinality from jsonb_array_elements(p_items) with ordinality loop v_item_key := btrim(coalesce(v_item->>'idempotency_key', '')); v_quote := btrim(coalesce(v_item->>'quote', '')); v_subject := coalesce(v_item->>'subject', ''); v_kind := coalesce(v_item->>'event_kind', ''); v_domain := coalesce(v_item->>'domain', ''); v_precision := coalesce(v_item->>'date_precision', ''); v_summary := btrim(coalesce(v_item->>'summary', '')); v_from := null; v_to := null; v_error := null; v_clarification := '[]'::jsonb; v_evidence_id := null; v_idempotent := false; begin if nullif(v_item->>'occurred_from', '') is not null then v_from := (v_item->>'occurred_from')::date; end if; if nullif(v_item->>'occurred_to', '') is not null then v_to := (v_item->>'occurred_to')::date; end if; exception when others then v_error := 'invalid_date'; end; if v_error is null and ( length(v_item_key) = 0 or length(v_item_key) > 160 or length(v_quote) = 0 or length(v_summary) = 0 or v_subject not in ('self', 'family', 'other') or v_kind not in ( 'education_start', 'education_completion', 'education_interruption', 'career_entry', 'career_change', 'promotion', 'career_pressure', 'career_exit', 'relationship_start', 'relationship_commitment', 'relationship_separation', 'relocation', 'finance_gain', 'finance_loss', 'self_health_event', 'family_event', 'other' ) or v_domain not in ('education', 'career', 'relationship', 'relocation', 'finance', 'health', 'family', 'other') or v_precision not in ('year', 'month', 'day', 'range', 'unknown') ) then v_error := 'invalid_item'; end if; if v_error is null and position( public.agentic_rectification_normalize_quote(v_quote) in public.agentic_rectification_normalize_quote(v_turn.user_message) ) = 0 then v_error := 'quote_not_grounded'; end if; if v_error is null and v_precision = 'range' and (v_from is null or v_to is null or v_from > v_to) then v_error := 'invalid_range'; end if; if v_error is not null then v_outcome := 'rejected'; v_status := 'rejected'; v_rejected := v_rejected + 1; else select * into v_existing from public.agentic_rectification_evidence where case_id = p_case_id and idempotency_key = v_item_key; if found then if v_existing.source_turn_id is distinct from p_source_turn_id or v_existing.user_quote is distinct from v_quote or v_existing.subject is distinct from v_subject or v_existing.event_kind is distinct from v_kind or v_existing.domain is distinct from v_domain or v_existing.occurred_from is distinct from v_from or v_existing.occurred_to is distinct from v_to or v_existing.date_precision is distinct from v_precision or v_existing.summary is distinct from v_summary then v_outcome := 'rejected'; v_status := 'rejected'; v_error := 'idempotency_conflict'; v_rejected := v_rejected + 1; else v_evidence_id := v_existing.id; v_idempotent := true; if v_existing.status = 'confirmed' then v_outcome := 'accepted'; v_status := 'confirmed'; v_accepted := v_accepted + 1; else v_outcome := 'needs_clarification'; v_status := v_existing.status; v_clarification := case when v_existing.date_precision = 'unknown' then '["date"]'::jsonb else '[]'::jsonb end; v_needs := v_needs + 1; end if; end if; else if v_precision = 'unknown' or v_from is null then v_outcome := 'needs_clarification'; v_status := 'draft'; v_clarification := '["date"]'::jsonb; v_needs := v_needs + 1; else v_outcome := 'accepted'; v_status := 'confirmed'; v_accepted := v_accepted + 1; end if; insert into public.agentic_rectification_evidence ( case_id, source_turn_id, user_quote, subject, event_kind, domain, occurred_from, occurred_to, date_precision, summary, status, confirmed_at, idempotency_key ) values ( p_case_id, p_source_turn_id, v_quote, v_subject, v_kind, v_domain, v_from, v_to, v_precision, v_summary, v_status, case when v_status = 'confirmed' then pg_catalog.now() else null end, v_item_key ) returning id into v_evidence_id; end if; end if; if p_focus_id is not null and v_outcome = 'accepted' and v_evidence_id is not null and (v_focus.target_domain is null or v_domain = v_focus.target_domain) and (v_focus.target_kind is null or v_kind = v_focus.target_kind) then v_focus_match_count := v_focus_match_count + 1; if v_focus_match_id is null then v_focus_match_id := v_evidence_id; end if; end if; v_results := v_results || jsonb_build_array(jsonb_build_object( 'index', v_index - 1, 'idempotency_key', nullif(v_item_key, ''), 'outcome', v_outcome, 'evidence_id', v_evidence_id, 'status', v_status, 'idempotent', v_idempotent, 'clarification_fields', v_clarification, 'error_code', v_error )); end loop; update public.agentic_rectification_cases set status = case when status = 'draft' and v_accepted > 0 then 'collecting_evidence' else status end, last_activity_at = pg_catalog.now(), updated_at = pg_catalog.now() where id = p_case_id; if p_focus_id is not null then if v_focus_replay then if v_focus_match_count <> 1 or v_focus_match_id is distinct from v_focus.target_evidence_id then raise exception 'agentic_rectification_focus_idempotency_conflict' using errcode = 'P0001'; end if; v_focus_resolution := 'idempotent'; elsif v_focus_match_count = 1 then perform public.resolve_agentic_rectification_conversation_focus( p_user_id, p_case_id, p_focus_id, 'resolved', v_focus_match_id ); v_focus_resolution := 'resolved'; elsif v_focus_match_count > 1 then -- Do not guess which evidence answered the question. Keep the focus active -- and make the ambiguity explicit to the caller. v_focus_resolution := 'ambiguous'; else v_focus_resolution := 'unmatched'; end if; end if; return jsonb_build_object( 'items', v_results, 'accepted_count', v_accepted, 'needs_clarification_count', v_needs, 'rejected_count', v_rejected, 'focus_id', p_focus_id, 'focus_evidence_id', v_focus_match_id, 'focus_resolution', v_focus_resolution ); end; $$; revoke all on function public.record_agentic_rectification_evidence_batch( uuid, uuid, uuid, uuid, jsonb ) from public, anon, authenticated; grant execute on function public.record_agentic_rectification_evidence_batch( uuid, uuid, uuid, uuid, jsonb ) to service_role; -- --------------------------------------------------------------------------- -- 7. Attempt lifecycle and attempt-aware receipt insertions. -- --------------------------------------------------------------------------- -- Request-aware turn append. The seven-argument V9 function remains available. create or replace function public.append_agentic_rectification_turn( p_user_id uuid, p_case_id uuid, p_user_message text, p_assistant_message text, p_model_name text, p_model_version text, p_status text, p_request_id uuid ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_case public.agentic_rectification_cases%rowtype; v_turn public.agentic_rectification_turns%rowtype; begin if p_user_id is null or p_case_id is null or p_request_id is null or length(btrim(coalesce(p_model_name, ''))) = 0 or p_status not in ('pending', 'completed', 'failed', 'retryable') then raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; end if; if p_status = 'completed' and (p_assistant_message is null or length(btrim(p_assistant_message)) = 0) then raise exception 'agentic_rectification_turn_incomplete' 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; select * into v_turn from public.agentic_rectification_turns where case_id = p_case_id and request_id = p_request_id for update; if found then if v_turn.user_message is distinct from p_user_message or v_turn.model_name is distinct from p_model_name or v_turn.model_version is distinct from p_model_version then raise exception 'agentic_rectification_request_mismatch' using errcode = 'P0001'; end if; return jsonb_build_object( 'turn_id', v_turn.id, 'status', v_turn.status, 'assistant_message', v_turn.assistant_message, 'successful_attempt_id', v_turn.successful_attempt_id, 'should_execute', false, 'already_in_progress', v_turn.status = 'pending', 'idempotent', true ); end if; if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; end if; insert into public.agentic_rectification_turns ( case_id, request_id, user_message, assistant_message, status, model_name, model_version, completed_at ) values ( p_case_id, p_request_id, p_user_message, p_assistant_message, p_status, p_model_name, p_model_version, case when p_status = 'completed' then pg_catalog.now() else null end ) returning * into v_turn; update public.agentic_rectification_cases set last_activity_at = pg_catalog.now(), updated_at = pg_catalog.now() where id = p_case_id; return jsonb_build_object( 'turn_id', v_turn.id, 'status', v_turn.status, 'assistant_message', v_turn.assistant_message, 'successful_attempt_id', v_turn.successful_attempt_id, 'should_execute', true, 'already_in_progress', false, 'idempotent', false ); end; $$; revoke all on function public.append_agentic_rectification_turn( uuid, uuid, text, text, text, text, text, uuid ) from public, anon, authenticated; grant execute on function public.append_agentic_rectification_turn( uuid, uuid, text, text, text, text, text, uuid ) to service_role; -- V10 request idempotency is mandatory for every runtime Turn write. Keep the -- V9 overload for migration compatibility, but remove it from service runtime. revoke execute on function public.append_agentic_rectification_turn( uuid, uuid, text, text, text, text, text ) from service_role; create or replace function public.create_agentic_rectification_run_attempt( p_user_id uuid, p_case_id uuid, p_turn_id uuid, p_attempt_number integer ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_case public.agentic_rectification_cases%rowtype; v_turn public.agentic_rectification_turns%rowtype; v_attempt public.agentic_rectification_run_attempts%rowtype; v_inserted boolean := false; begin if p_user_id is null or p_case_id is null or p_turn_id is null or coalesce(p_attempt_number, 0) <= 0 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 for update; if not found then raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; end if; select * into v_turn from public.agentic_rectification_turns where id = p_turn_id and case_id = p_case_id for update; if not found then raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; end if; -- A terminal Case must not create a new attempt, but a worker recovering -- after a confirm/close action still needs the identity of the attempt that -- already owned execution so it can finish the durable Turn projection. select * into v_attempt from public.agentic_rectification_run_attempts where turn_id = p_turn_id and attempt_number = p_attempt_number for update; if found then return jsonb_build_object( 'attempt_id', v_attempt.id, 'attempt_number', v_attempt.attempt_number, 'status', v_attempt.status, 'should_execute', false, 'already_in_progress', v_attempt.status = 'started', 'idempotent', true ); end if; if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; end if; insert into public.agentic_rectification_run_attempts ( case_id, turn_id, attempt_number, status ) values ( p_case_id, p_turn_id, p_attempt_number, 'started' ) on conflict (turn_id, attempt_number) do nothing returning * into v_attempt; v_inserted := found; if not v_inserted then select * into v_attempt from public.agentic_rectification_run_attempts where turn_id = p_turn_id and attempt_number = p_attempt_number for update; end if; return jsonb_build_object( 'attempt_id', v_attempt.id, 'attempt_number', v_attempt.attempt_number, 'status', v_attempt.status, 'should_execute', v_inserted, 'already_in_progress', not v_inserted and v_attempt.status = 'started', 'idempotent', not v_inserted ); end; $$; revoke all on function public.create_agentic_rectification_run_attempt(uuid, uuid, uuid, integer) from public, anon, authenticated; grant execute on function public.create_agentic_rectification_run_attempt(uuid, uuid, uuid, integer) to service_role; create or replace function public.finalize_agentic_rectification_run_attempt( p_user_id uuid, p_case_id uuid, p_turn_id uuid, p_attempt_id uuid, p_status text, p_error_code text, p_usage jsonb ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_case public.agentic_rectification_cases%rowtype; v_attempt public.agentic_rectification_run_attempts%rowtype; v_error_code text := nullif(btrim(coalesce(p_error_code, '')), ''); begin if p_user_id is null or p_case_id is null or p_turn_id is null or p_attempt_id is null or p_status not in ('completed', 'failed', 'retryable', 'aborted') or p_usage is null or jsonb_typeof(p_usage) <> 'object' 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 for update; if not found then raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; end if; select * into v_attempt from public.agentic_rectification_run_attempts where id = p_attempt_id and case_id = p_case_id and turn_id = p_turn_id for update; if not found then raise exception 'agentic_rectification_attempt_not_found' using errcode = 'P0001'; end if; if v_attempt.status <> 'started' then if v_attempt.status = p_status and v_attempt.error_code is not distinct from v_error_code and v_attempt.usage = p_usage then return jsonb_build_object( 'attempt_id', v_attempt.id, 'status', v_attempt.status, 'idempotent', true ); end if; raise exception 'agentic_rectification_attempt_already_finalized' using errcode = 'P0001'; end if; if p_status = 'completed' then if not exists ( select 1 from public.agentic_rectification_run_phases where attempt_id = p_attempt_id and case_id = p_case_id and turn_id = p_turn_id and phase = 'billing.settled' ) or not exists ( select 1 from public.agentic_rectification_run_phases where attempt_id = p_attempt_id and case_id = p_case_id and turn_id = p_turn_id and phase = 'run.completed' ) then raise exception 'agentic_rectification_attempt_completion_receipt_missing' using errcode = 'P0001'; end if; if not exists ( select 1 from public.agentic_rectification_skill_run_receipts receipt where receipt.case_id = p_case_id and receipt.turn_id = p_turn_id and receipt.request_id = p_attempt_id and receipt.run_kind = 'turn' and receipt.user_id = p_user_id and receipt.skill_name = v_case.skill_name and receipt.skill_version = v_case.skill_version and receipt.skill_sha256 = v_case.skill_sha256 and receipt.source_commit is not distinct from v_case.skill_source_commit ) then raise exception 'agentic_rectification_skill_receipt_missing' using errcode = 'P0001'; end if; end if; update public.agentic_rectification_run_attempts set status = p_status, error_code = v_error_code, usage = p_usage, completed_at = pg_catalog.now(), updated_at = pg_catalog.now() where id = p_attempt_id; return jsonb_build_object( 'attempt_id', p_attempt_id, 'status', p_status, 'idempotent', false ); end; $$; revoke all on function public.finalize_agentic_rectification_run_attempt( uuid, uuid, uuid, uuid, text, text, jsonb ) from public, anon, authenticated; grant execute on function public.finalize_agentic_rectification_run_attempt( uuid, uuid, uuid, uuid, text, text, jsonb ) to service_role; -- New overload. The six-argument V9 function remains untouched. create or replace function public.insert_agentic_rectification_run_phase( p_user_id uuid, p_case_id uuid, p_turn_id uuid, p_phase text, p_tool_name text, p_sequence integer, p_attempt_id uuid ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_attempt public.agentic_rectification_run_attempts%rowtype; v_phase_id uuid; begin if p_user_id is null or p_case_id is null or p_turn_id is null or p_attempt_id is null or p_phase not in ( 'run.started', 'skill.started', 'skill.loaded', 'skill.bound', 'case.loaded', 'intent.classified', 'evidence.proposed', 'evidence.confirmed', 'candidates.comparing', 'candidates.updated', 'diagnostics.completed', 'candidate.accepted', 'birth_time.confirmed', 'answer.composed', 'billing.settled', 'answer.delta', 'run.completed', 'run.failed' ) then raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; end if; if not exists ( select 1 from public.agentic_rectification_cases where id = p_case_id and user_id = p_user_id ) then raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; end if; if not exists ( select 1 from public.agentic_rectification_turns where id = p_turn_id and case_id = p_case_id ) then raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; end if; select * into v_attempt from public.agentic_rectification_run_attempts where id = p_attempt_id and case_id = p_case_id and turn_id = p_turn_id for update; if not found then raise exception 'agentic_rectification_attempt_not_found' using errcode = 'P0001'; end if; if v_attempt.status <> 'started' then raise exception 'agentic_rectification_attempt_not_started' using errcode = 'P0001'; end if; insert into public.agentic_rectification_run_phases ( case_id, turn_id, attempt_id, phase, tool_name, sequence ) values ( p_case_id, p_turn_id, p_attempt_id, p_phase, p_tool_name, coalesce(p_sequence, 0) ) returning id into v_phase_id; return jsonb_build_object('phase_id', v_phase_id, 'attempt_id', p_attempt_id); end; $$; revoke all on function public.insert_agentic_rectification_run_phase( uuid, uuid, uuid, text, text, integer, uuid ) from public, anon, authenticated; grant execute on function public.insert_agentic_rectification_run_phase( uuid, uuid, uuid, text, text, integer, uuid ) to service_role; -- New overload. The eleven-argument V9 function remains untouched. create or replace function public.insert_agentic_rectification_tool_receipt( p_user_id uuid, p_case_id uuid, p_turn_id uuid, p_tool_name text, p_public_phase text, p_status text, p_input_fingerprint text, p_result_fingerprint text, p_engine_version text, p_safe_error_code text, p_executed_methods jsonb, p_attempt_id uuid ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_attempt public.agentic_rectification_run_attempts%rowtype; v_receipt_id uuid; begin if p_user_id is null or p_case_id is null or p_turn_id is null or p_attempt_id is null or p_tool_name not in ( 'rectification-read-case', 'rectification-set-focus', 'rectification-resolve-focus', 'rectification-record-evidence-batch', 'rectification-propose-evidence', 'rectification-confirm-evidence', 'rectification-revise-evidence', 'rectification-compare-candidates', 'rectification-read-diagnostics', 'rectification-offer-candidates', 'rectification-accept-candidate', 'rectification-confirm-birth-time', 'rectification-close-case' ) or p_public_phase not in ( 'run.started', 'skill.started', 'skill.loaded', 'skill.bound', 'case.loaded', 'intent.classified', 'evidence.proposed', 'evidence.confirmed', 'candidates.comparing', 'candidates.updated', 'diagnostics.completed', 'candidate.accepted', 'birth_time.confirmed', 'answer.composed', 'billing.settled', 'answer.delta', 'run.completed', 'run.failed' ) or p_status not in ('started', 'completed', 'failed', 'skipped') or p_executed_methods is null or jsonb_typeof(p_executed_methods) <> 'array' or exists ( select 1 from jsonb_array_elements_text(p_executed_methods) method(value) where method.value not in ( 'd1-rashi', 'd2-hora', 'd4-chaturthamsha', 'd9-navamsa', 'd10-dashamsa', 'd11-labhamsha', 'd24-chaturvimshamsha', 'd30-trimshamsha', 'vimshottari-dasha', 'narayana-dasha', 'gochara', 'ashtakavarga', 'shadbala', 'arudha-pada', 'functional-benefic-malefic' ) ) then raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; end if; if not exists ( select 1 from public.agentic_rectification_cases where id = p_case_id and user_id = p_user_id ) then raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; end if; if not exists ( select 1 from public.agentic_rectification_turns where id = p_turn_id and case_id = p_case_id ) then raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; end if; select * into v_attempt from public.agentic_rectification_run_attempts where id = p_attempt_id and case_id = p_case_id and turn_id = p_turn_id for update; if not found then raise exception 'agentic_rectification_attempt_not_found' using errcode = 'P0001'; end if; if v_attempt.status <> 'started' then raise exception 'agentic_rectification_attempt_not_started' using errcode = 'P0001'; end if; insert into public.agentic_rectification_tool_receipts ( case_id, turn_id, attempt_id, tool_name, public_phase, status, input_fingerprint, result_fingerprint, engine_version, safe_error_code, executed_methods, completed_at ) values ( p_case_id, p_turn_id, p_attempt_id, p_tool_name, p_public_phase, p_status, p_input_fingerprint, p_result_fingerprint, p_engine_version, p_safe_error_code, p_executed_methods, case when p_status = 'completed' then pg_catalog.now() else null end ) returning id into v_receipt_id; return jsonb_build_object('receipt_id', v_receipt_id, 'attempt_id', p_attempt_id); end; $$; revoke all on function public.insert_agentic_rectification_tool_receipt( uuid, uuid, uuid, text, text, text, text, text, text, text, jsonb, uuid ) from public, anon, authenticated; grant execute on function public.insert_agentic_rectification_tool_receipt( uuid, uuid, uuid, text, text, text, text, text, text, text, jsonb, uuid ) to service_role; -- Attempt-aware finalization. The five-argument V9 function remains available. create or replace function public.finalize_agentic_rectification_turn( p_user_id uuid, p_case_id uuid, p_turn_id uuid, p_status text, p_assistant_message text, p_successful_attempt_id uuid ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_turn public.agentic_rectification_turns%rowtype; v_attempt public.agentic_rectification_run_attempts%rowtype; begin if p_user_id is null or p_case_id is null or p_turn_id is null or p_status not in ('completed', 'failed', 'retryable') then raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; end if; if not exists ( select 1 from public.agentic_rectification_cases where id = p_case_id and user_id = p_user_id ) then raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; end if; select * into v_turn from public.agentic_rectification_turns where id = p_turn_id and case_id = p_case_id for update; if not found then raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; end if; if v_turn.status = 'completed' then if p_status = 'completed' and v_turn.successful_attempt_id is not distinct from p_successful_attempt_id and v_turn.assistant_message is not distinct from p_assistant_message then return jsonb_build_object( 'turn_id', p_turn_id, 'status', v_turn.status, 'successful_attempt_id', v_turn.successful_attempt_id, 'idempotent', true ); end if; raise exception 'agentic_rectification_turn_already_completed' using errcode = 'P0001'; end if; if p_status = 'completed' then if p_successful_attempt_id is null or p_assistant_message is null or length(btrim(p_assistant_message)) = 0 then raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; end if; select * into v_attempt from public.agentic_rectification_run_attempts where id = p_successful_attempt_id and case_id = p_case_id and turn_id = p_turn_id and status = 'completed'; if not found then raise exception 'agentic_rectification_attempt_not_successful' using errcode = 'P0001'; end if; elsif p_successful_attempt_id is not null or p_assistant_message is not null then raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; end if; update public.agentic_rectification_turns set status = p_status, assistant_message = case when p_status = 'completed' then p_assistant_message else null end, successful_attempt_id = case when p_status = 'completed' then p_successful_attempt_id else null end, completed_at = case when p_status = 'completed' then pg_catalog.now() else null end, updated_at = pg_catalog.now() where id = p_turn_id and status <> 'completed'; return jsonb_build_object( 'turn_id', p_turn_id, 'status', p_status, 'successful_attempt_id', case when p_status = 'completed' then p_successful_attempt_id else null end, 'idempotent', false ); end; $$; revoke all on function public.finalize_agentic_rectification_turn( uuid, uuid, uuid, text, text, uuid ) from public, anon, authenticated; -- Legacy finalizers cannot prove latest-attempt ownership. Retain their -- definitions for additive migration compatibility, but make the V10 runtime -- use only the attempt-aware overload below. revoke execute on function public.finalize_agentic_rectification_turn( uuid, uuid, uuid, text, text ) from service_role; revoke execute on function public.finalize_agentic_rectification_turn( uuid, uuid, uuid, text, text, uuid ) from service_role; -- Attempt-aware monotonic finalization used by the V10 runner. create or replace function public.finalize_agentic_rectification_turn( p_user_id uuid, p_case_id uuid, p_turn_id uuid, p_attempt_id uuid, p_status text, p_assistant_message text, p_successful_attempt_id uuid ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_turn public.agentic_rectification_turns%rowtype; v_attempt public.agentic_rectification_run_attempts%rowtype; v_latest_attempt_id uuid; begin if p_user_id is null or p_case_id is null or p_turn_id is null or p_attempt_id is null or p_status not in ('completed', 'failed', 'retryable') then raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; end if; if not exists ( select 1 from public.agentic_rectification_cases where id = p_case_id and user_id = p_user_id ) then raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; end if; select * into v_turn from public.agentic_rectification_turns where id = p_turn_id and case_id = p_case_id for update; if not found then raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; end if; if v_turn.status = 'completed' then if p_status = 'completed' and p_attempt_id = p_successful_attempt_id and v_turn.successful_attempt_id = p_successful_attempt_id and v_turn.assistant_message is not distinct from p_assistant_message then return jsonb_build_object( 'turn_id', p_turn_id, 'status', v_turn.status, 'successful_attempt_id', v_turn.successful_attempt_id, 'idempotent', true ); end if; raise exception 'agentic_rectification_turn_already_completed' using errcode = 'P0001'; end if; select * into v_attempt from public.agentic_rectification_run_attempts where id = p_attempt_id and case_id = p_case_id and turn_id = p_turn_id for update; if not found then raise exception 'agentic_rectification_attempt_not_found' using errcode = 'P0001'; end if; select id into v_latest_attempt_id from public.agentic_rectification_run_attempts where case_id = p_case_id and turn_id = p_turn_id order by attempt_number desc limit 1; if v_latest_attempt_id is distinct from p_attempt_id then raise exception 'agentic_rectification_attempt_superseded' using errcode = 'P0001'; end if; if p_status = 'completed' then if p_successful_attempt_id is distinct from p_attempt_id or p_assistant_message is null or length(btrim(p_assistant_message)) = 0 or v_attempt.status <> 'completed' then raise exception 'agentic_rectification_attempt_not_successful' using errcode = 'P0001'; end if; else if p_successful_attempt_id is not null or p_assistant_message is not null or v_attempt.status <> p_status then raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; end if; if v_turn.status = p_status then return jsonb_build_object( 'turn_id', p_turn_id, 'status', v_turn.status, 'successful_attempt_id', null, 'idempotent', true ); end if; end if; update public.agentic_rectification_turns set status = p_status, assistant_message = case when p_status = 'completed' then p_assistant_message else null end, successful_attempt_id = case when p_status = 'completed' then p_successful_attempt_id else null end, completed_at = case when p_status = 'completed' then pg_catalog.now() else null end, updated_at = pg_catalog.now() where id = p_turn_id and status <> 'completed'; return jsonb_build_object( 'turn_id', p_turn_id, 'status', p_status, 'successful_attempt_id', case when p_status = 'completed' then p_successful_attempt_id else null end, 'idempotent', false ); end; $$; revoke all on function public.finalize_agentic_rectification_turn( uuid, uuid, uuid, uuid, text, text, uuid ) from public, anon, authenticated; grant execute on function public.finalize_agentic_rectification_turn( uuid, uuid, uuid, uuid, text, text, uuid ) to service_role; -- --------------------------------------------------------------------------- -- 8. Attempt-filtered turn receipt and bounded dossier with durable summary. -- --------------------------------------------------------------------------- create or replace function public.get_agentic_rectification_turn_receipt( p_user_id uuid, p_case_id uuid, p_turn_id uuid ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_turn public.agentic_rectification_turns%rowtype; v_case public.agentic_rectification_cases%rowtype; v_attempt_id uuid; v_phases jsonb; v_tools jsonb; v_methods jsonb; v_engine_version text; begin if p_user_id is null or p_case_id is null or p_turn_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 * into v_turn from public.agentic_rectification_turns where id = p_turn_id and case_id = p_case_id; if not found then raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; end if; v_attempt_id := v_turn.successful_attempt_id; if v_attempt_id is null then select id into v_attempt_id from public.agentic_rectification_run_attempts where turn_id = p_turn_id and status = 'completed' order by completed_at desc, attempt_number desc limit 1; end if; select coalesce(jsonb_agg( jsonb_build_object('phase', rp.phase, 'tool', rp.tool_name) order by rp.sequence, rp.created_at, rp.id ), '[]'::jsonb) into v_phases from public.agentic_rectification_run_phases rp where rp.turn_id = p_turn_id and ( (v_attempt_id is not null and rp.attempt_id = v_attempt_id) or (v_attempt_id is null and rp.attempt_id is null) ); select coalesce(jsonb_agg(tool_name order by tool_name), '[]'::jsonb) into v_tools from ( select distinct tr.tool_name from public.agentic_rectification_tool_receipts tr where tr.turn_id = p_turn_id and tr.status = 'completed' and ( (v_attempt_id is not null and tr.attempt_id = v_attempt_id) or (v_attempt_id is null and tr.attempt_id is null) ) ) tools; select coalesce(jsonb_agg(method order by method), '[]'::jsonb) into v_methods from ( select distinct jsonb_array_elements_text(tr.executed_methods) as method from public.agentic_rectification_tool_receipts tr where tr.turn_id = p_turn_id and tr.status = 'completed' and ( (v_attempt_id is not null and tr.attempt_id = v_attempt_id) or (v_attempt_id is null and tr.attempt_id is null) ) ) methods; select max(tr.engine_version) into v_engine_version from public.agentic_rectification_tool_receipts tr where tr.turn_id = p_turn_id and tr.engine_version is not null and ( (v_attempt_id is not null and tr.attempt_id = v_attempt_id) or (v_attempt_id is null and tr.attempt_id is null) ); return jsonb_build_object( 'turn_id', v_turn.id, 'attempt_id', v_attempt_id, 'status', v_turn.status, 'skill_name', v_case.skill_name, 'skill_version', v_case.skill_version, 'engine_version', v_engine_version, 'phases', v_phases, 'tools', v_tools, 'methods', v_methods, 'started_at', v_turn.created_at, 'completed_at', v_turn.completed_at ); end; $$; revoke all on function public.get_agentic_rectification_turn_receipt(uuid, uuid, uuid) from public, anon, authenticated; grant execute on function public.get_agentic_rectification_turn_receipt(uuid, 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, 'selection_allowed', v_result.selection_allowed, 'confirmation_allowed', v_result.confirmation_allowed, 'representative_time', v_result.representative_time, '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, '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; commit;