-- V9 Agentic Rectification agent API: case dossier (safe projection), turn -- finalize, case-scoped candidate persist/accept/confirm, guarded status -- transitions, needs_rebaseline guard on profile change, and the DB-driven -- rectification runtime feature flag. -- -- This is a forward migration layered on top of -- 20260812010000_agentic_rectification_v9_runtime.sql. It never modifies -- history; it only adds new functions/triggers/rows. Business schema only: -- this file belongs in frontend/supabase/migrations and MUST NOT be copied -- into frontend/db/migrations (BUG-127 / BUG-144). -- -- Security model: every RPC is SECURITY DEFINER with search_path = '', and -- every new function is granted to service_role only. The browser never -- passes userId, birth snapshot, candidate range or permission decisions; -- the server derives them from the authenticated session and the Case row. begin; -- --------------------------------------------------------------------------- -- 1. Case dossier (safe projection; never the baseline birth snapshot) -- -- Served to the agent on every turn. Includes turns, evidence ledger and the -- latest candidate snapshot so the agent can decide the next action without -- the client ever reconstructing history. -- --------------------------------------------------------------------------- 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_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', t.id, 'role', case when t.user_message is not null then 'user' else 'assistant' end, 'text', coalesce(t.user_message, t.assistant_message), 'status', t.status, 'created_at', t.created_at, 'completed_at', t.completed_at ) order by t.created_at ), '[]'::jsonb) into v_turns from public.agentic_rectification_turns t where t.case_id = v_case.id; 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 ), '[]'::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_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', 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, '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; -- --------------------------------------------------------------------------- -- 2. Compute-only projection (baseline + range) for deterministic engine calls -- -- service_role only; the tool layer reads this to build Python payloads. The -- projection is never returned to the model or the browser. -- --------------------------------------------------------------------------- create or replace function public.get_agentic_rectification_case_compute( 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; 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; return jsonb_build_object( 'case_id', v_case.id, 'skill_version', v_case.skill_version, 'baseline_profile_fingerprint', v_case.baseline_profile_fingerprint, 'baseline_birth_snapshot', v_case.baseline_birth_snapshot, 'candidate_range', v_case.candidate_range ); end; $$; revoke all on function public.get_agentic_rectification_case_compute(uuid, uuid) from public, anon, authenticated; grant execute on function public.get_agentic_rectification_case_compute(uuid, uuid) to service_role; -- --------------------------------------------------------------------------- -- 3. Turn finalize (pending -> completed / failed / retryable) -- -- A turn is first inserted as pending by the API; only after the agent run -- succeeds may it be completed with assistant text. Half-finished text never -- becomes settled history. -- --------------------------------------------------------------------------- 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 ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_turn public.agentic_rectification_turns%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 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; 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; if not found then raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; end if; if v_turn.status in ('completed', 'failed', 'retryable') then -- Idempotent finalize: a completed turn may not be rewritten. if v_turn.status = 'completed' then return jsonb_build_object('turn_id', v_turn.id, 'status', v_turn.status, '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 assistant_message end, completed_at = case when p_status = 'completed' then pg_catalog.now() else completed_at end, updated_at = pg_catalog.now() where id = p_turn_id; 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', p_turn_id, 'status', p_status, 'idempotent', false); end; $$; revoke all on function public.finalize_agentic_rectification_turn(uuid, uuid, uuid, text, text) from public, anon, authenticated; grant execute on function public.finalize_agentic_rectification_turn(uuid, uuid, uuid, text, text) to service_role; -- --------------------------------------------------------------------------- -- 4. Case-scoped candidate persist with fingerprint cache reuse -- -- Same case + same evidence ledger fingerprint + same candidate range -- fingerprint + same skill/engine version => return the cached snapshot -- without re-running the engine. Only genuinely changed evidence re-scores. -- --------------------------------------------------------------------------- create or replace function public.persist_agentic_rectification_candidate( p_user_id uuid, p_case_id uuid, p_engine_result_id text, p_algorithm_version text, p_evidence_ledger_fingerprint text, p_candidate_range_fingerprint text, p_skill_version text, p_candidate_range jsonb, p_candidates jsonb, p_overall_confidence text, p_margin_percent numeric, p_selection_allowed boolean, p_confirmation_allowed boolean, p_representative_time time without time zone ) 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; 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_algorithm_version, ''))) = 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 then raise exception 'agentic_rectification_invalid_input' 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_overall_confidence not in ('low', 'medium', 'high') 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; if v_case.skill_version is distinct from p_skill_version then raise exception 'agentic_rectification_skill_version_mismatch' using errcode = 'P0001'; end if; -- Cache reuse: identical fingerprint + engine version returns the stored -- snapshot. The engine is only invoked when evidence effectively changed. 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 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, 'selection_allowed', v_cached.selection_allowed, 'confirmation_allowed', v_cached.confirmation_allowed, 'representative_time', v_cached.representative_time, 'algorithm_version', v_cached.algorithm_version ); 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, candidate_range, candidates, overall_confidence, margin_percent, selection_allowed, confirmation_allowed, representative_time, 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_candidate_range, p_candidates, p_overall_confidence, p_margin_percent, p_selection_allowed, p_confirmation_allowed, p_representative_time, (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; return jsonb_build_object( 'result_id', v_result_id, 'cached', false, 'candidates', p_candidates, 'overall_confidence', p_overall_confidence, 'margin_percent', p_margin_percent, 'selection_allowed', p_selection_allowed, 'confirmation_allowed', p_confirmation_allowed, 'representative_time', p_representative_time, 'algorithm_version', p_algorithm_version ); end; $$; revoke all 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 public, anon, authenticated; grant 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) to service_role; -- --------------------------------------------------------------------------- -- 5. Case-scoped candidate acceptance (accepted != confirmed) -- -- Only a candidate that belongs to the current user/case/result snapshot may -- be accepted, and only while the profile still matches the case baseline. -- The case moves to candidate_accepted with accepted_time. -- --------------------------------------------------------------------------- create or replace function public.accept_agentic_rectification_candidate_for_case( p_user_id uuid, p_case_id uuid, p_result_id uuid, p_time time without time zone ) 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_profile public.profiles%rowtype; v_snapshot jsonb; v_selection_kind text; v_status text; begin if p_user_id is null or p_case_id is null or p_result_id is null or p_time is null or extract(second from p_time) is distinct from 0 then raise exception 'agentic_rectification_candidate_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_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; 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.selection_allowed then raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; end if; if not exists ( select 1 from pg_catalog.jsonb_array_elements(v_result.candidates) candidate where candidate ->> 'time' = pg_catalog.to_char(p_time, 'HH24:MI') ) then raise exception 'agentic_rectification_candidate_time_not_allowed' using errcode = 'P0001'; end if; -- Idempotent replay: this candidate/time was already accepted for the -- case. The profile legitimately carries the accepted time now (the -- baseline snapshot is intentionally stale after acceptance), so the replay -- validates the profile against the accepted selection instead of the -- baseline. This mirrors the pre-v9 accept_agentic_rectification_candidate -- semantics and keeps retries/double-clicks idempotent. if v_result.selected_time is not null then if v_result.selected_time is distinct from p_time or v_case.accepted_time is distinct from p_time then raise exception 'agentic_rectification_candidate_already_selected' using errcode = 'P0001'; end if; select * into v_profile from public.profiles where id = p_user_id for update; if not found or v_profile.active_birth_time is distinct from v_result.selected_time or v_profile.birth_time is distinct from v_result.selected_time or v_profile.birth_time_status is distinct from ( case when v_result.selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end ) then raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; end if; return jsonb_build_object( 'success', true, 'saved_time', pg_catalog.to_char(p_time, 'HH24:MI'), 'status', case when v_result.selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end, 'result_id', v_result.id, 'case_status', v_case.status, 'idempotent', true ); end if; -- Fresh acceptance: the profile must still match the case baseline before -- any write (an engine-confirmed replay already returned above). v_snapshot := v_case.baseline_birth_snapshot; select * into v_profile from public.profiles where id = p_user_id for update; if not found or 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; 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; v_selection_kind := case when v_result.confirmation_allowed and v_result.representative_time is not distinct from p_time then 'engine_confirmed' else 'user_accepted' end; v_status := case when v_selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end; update public.profiles set active_birth_time = p_time, birth_time = p_time, birth_time_status = v_status, 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_time = p_time, selection_kind = v_selection_kind, 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; -- The profile update above may have flipped this case to needs_rebaseline -- via the guard trigger; the accept outcome must win. update public.agentic_rectification_cases set status = 'candidate_accepted', accepted_time = p_time, last_activity_at = pg_catalog.now(), updated_at = pg_catalog.now() where id = v_case.id; return jsonb_build_object( 'success', true, 'saved_time', pg_catalog.to_char(p_time, 'HH24:MI'), 'status', v_status, 'result_id', v_result.id, 'case_status', 'candidate_accepted', 'idempotent', false ); end; $$; revoke all on function public.accept_agentic_rectification_candidate_for_case(uuid, uuid, uuid, time without time zone) from public, anon, authenticated; grant execute on function public.accept_agentic_rectification_candidate_for_case(uuid, uuid, uuid, time without time zone) to service_role; -- --------------------------------------------------------------------------- -- 6. Confirm birth time (confirmation gate + durable explicit consent) -- -- Only reaches 'confirmed' when the engine confirmation gate already allowed -- it on the stored result AND the user's explicit consent quote is grounded -- in the source turn's own message. This is the only path that writes -- birth_time_status = 'confirmed'. -- --------------------------------------------------------------------------- create or replace function public.confirm_agentic_rectification_birth_time( p_user_id uuid, p_case_id uuid, p_result_id uuid, p_time time without time zone, 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_profile public.profiles%rowtype; v_turn public.agentic_rectification_turns%rowtype; v_snapshot jsonb; begin if p_user_id is null or p_case_id is null or p_result_id is null or p_time is null or p_source_turn_id is null or length(btrim(coalesce(p_consent_quote, ''))) = 0 or extract(second from p_time) is distinct from 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; if v_case.status = 'confirmed' then if v_case.confirmed_time is distinct from p_time then raise exception 'agentic_rectification_case_already_confirmed' using errcode = 'P0001'; end if; select id into v_result.id from public.agentic_rectification_results where id = p_result_id and user_id = p_user_id and case_id = p_case_id; return jsonb_build_object( 'success', true, 'saved_time', pg_catalog.to_char(v_case.confirmed_time, 'HH24:MI'), 'status', 'confirmed', 'result_id', v_result.id, 'case_status', 'confirmed', 'idempotent', true ); 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; 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; 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 v_result.representative_time is distinct from p_time then raise exception 'agentic_rectification_confirm_time_mismatch' using errcode = 'P0001'; end if; if not exists ( select 1 from pg_catalog.jsonb_array_elements(v_result.candidates) candidate where candidate ->> 'time' = pg_catalog.to_char(p_time, 'HH24:MI') ) then raise exception 'agentic_rectification_candidate_time_not_allowed' using errcode = 'P0001'; end if; v_snapshot := v_case.baseline_birth_snapshot; select * into v_profile from public.profiles where id = p_user_id for update; if not found or 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; update public.profiles set active_birth_time = p_time, birth_time = p_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_time = p_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; -- The profile update above may have flipped this case to needs_rebaseline -- via the guard trigger; the confirm outcome must win. update public.agentic_rectification_cases set status = 'confirmed', confirmed_time = p_time, accepted_time = p_time, completed_at = pg_catalog.now(), last_activity_at = pg_catalog.now(), updated_at = pg_catalog.now() where id = v_case.id; return jsonb_build_object( 'success', true, 'saved_time', pg_catalog.to_char(p_time, 'HH24:MI'), 'status', 'confirmed', 'result_id', v_result.id, 'case_status', 'confirmed', 'idempotent', false ); end; $$; revoke all on function public.confirm_agentic_rectification_birth_time(uuid, uuid, uuid, time without time zone, text, uuid) from public, anon, authenticated; grant execute on function public.confirm_agentic_rectification_birth_time(uuid, uuid, uuid, time without time zone, text, uuid) to service_role; -- --------------------------------------------------------------------------- -- 7. Guarded status transition (resumable -> resumable only) -- -- Terminal transitions are exclusively owned by confirm/close RPCs. -- --------------------------------------------------------------------------- create or replace function public.transition_agentic_rectification_case_status( p_user_id uuid, p_case_id uuid, p_status text ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare v_case public.agentic_rectification_cases%rowtype; begin if p_user_id is null or p_case_id is null or p_status not in ('draft', 'collecting_evidence', 'candidate_ready', 'candidate_accepted', 'needs_rebaseline', 'paused') 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; if v_case.status = p_status then return jsonb_build_object('case_id', v_case.id, 'status', v_case.status, 'idempotent', true); end if; update public.agentic_rectification_cases set status = p_status, updated_at = pg_catalog.now(), last_activity_at = pg_catalog.now() where id = v_case.id; return jsonb_build_object('case_id', v_case.id, 'status', p_status, 'idempotent', false); end; $$; revoke all on function public.transition_agentic_rectification_case_status(uuid, uuid, text) from public, anon, authenticated; grant execute on function public.transition_agentic_rectification_case_status(uuid, uuid, text) to service_role; -- --------------------------------------------------------------------------- -- 8. needs_rebaseline guard on profile change -- -- When any calculation-relevant profile field changes, every resumable case -- of that user is flipped to needs_rebaseline and its candidate snapshot is -- invalidated by the existing results trigger. The accept/confirm RPCs -- deliberately run their case update after the profile write so their own -- outcome wins inside the same transaction. -- --------------------------------------------------------------------------- create or replace function public.agentic_rectification_profiles_rebaseline_guard() returns trigger language plpgsql security definer set search_path = '' as $$ declare v_case record; begin if old is null or new is null then return new; end if; if old.birth_date is distinct from new.birth_date or old.reported_birth_time is distinct from new.reported_birth_time or old.active_birth_time is distinct from new.active_birth_time or old.birth_time_source is distinct from new.birth_time_source or old.birth_time_period is distinct from new.birth_time_period or old.uncertainty_before_minutes is distinct from new.uncertainty_before_minutes or old.uncertainty_after_minutes is distinct from new.uncertainty_after_minutes or old.latitude is distinct from new.latitude or old.longitude is distinct from new.longitude or old.timezone_offset is distinct from new.timezone_offset then for v_case in select c.id from public.agentic_rectification_cases c where c.user_id = new.id and c.status = any (public.agentic_rectification_resumable_statuses()) loop update public.agentic_rectification_cases set status = 'needs_rebaseline', updated_at = pg_catalog.now(), last_activity_at = pg_catalog.now() where id = v_case.id; end loop; end if; return new; end; $$; revoke all on function public.agentic_rectification_profiles_rebaseline_guard() from public, anon, authenticated; drop trigger if exists agentic_rectification_profiles_rebaseline_guard_trigger on public.profiles; create trigger agentic_rectification_profiles_rebaseline_guard_trigger after update of birth_date, reported_birth_time, active_birth_time, birth_time_source, birth_time_period, uncertainty_before_minutes, uncertainty_after_minutes, latitude, longitude, timezone_offset on public.profiles for each row execute function public.agentic_rectification_profiles_rebaseline_guard(); -- --------------------------------------------------------------------------- -- 9. DB-driven runtime selector for the V9 rectification runtime -- -- New cases default to V9; legacy sessions are read-only. The route layer -- reads this flag through loadRuntimeFeatureFlags; when the flag is retired -- or disabled, no new V9 runs are served and legacy stays read-only, so the -- two runtimes can never both write profile / charge / confirm. -- --------------------------------------------------------------------------- insert into public.feature_flags (flag_key, version, enabled, rollout_percentage, config, status, created_at, published_at) values ( 'rectification_runtime_version', 1, true, 100, '{"version":"v9","legacy_mode":"readonly"}'::jsonb, 'published', pg_catalog.now(), pg_catalog.now() ) on conflict (flag_key, version) do nothing; -- --------------------------------------------------------------------------- -- 10. Durable run phases (execution receipt evidence) -- -- The web client's activity panel and the persisted execution receipt are -- rebuilt from these rows. The skill tool evidence (skill.started / -- skill.loaded) lives here because the tool receipts table's tool_name check -- only allows the ten rectification tools. Reasoning, raw payloads, provider -- metadata, birth data and internal scores are never written. -- --------------------------------------------------------------------------- create table if not exists public.agentic_rectification_run_phases ( 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, phase text not null check ( phase in ( 'run.started', 'skill.started', 'skill.loaded', 'case.loaded', 'evidence.proposed', 'evidence.confirmed', 'candidates.comparing', 'candidates.updated', 'diagnostics.completed', 'candidate.accepted', 'birth_time.confirmed', 'answer.delta', 'run.completed', 'run.failed' ) ), tool_name text, sequence integer not null default 0, created_at timestamptz not null default pg_catalog.now() ); create index if not exists agentic_rectification_run_phases_turn_idx on public.agentic_rectification_run_phases (case_id, turn_id, sequence); alter table public.agentic_rectification_run_phases enable row level security; revoke all on table public.agentic_rectification_run_phases from public, anon, authenticated, service_role; grant all on table public.agentic_rectification_run_phases to service_role; 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 ) returns jsonb language plpgsql security definer set search_path = '' as $$ declare 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_phase not in ( 'run.started', 'skill.started', 'skill.loaded', 'case.loaded', 'evidence.proposed', 'evidence.confirmed', 'candidates.comparing', 'candidates.updated', 'diagnostics.completed', 'candidate.accepted', 'birth_time.confirmed', '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; insert into public.agentic_rectification_run_phases ( case_id, turn_id, phase, tool_name, sequence ) values ( p_case_id, p_turn_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); end; $$; revoke all on function public.insert_agentic_rectification_run_phase(uuid, uuid, uuid, text, text, integer) from public, anon, authenticated; grant execute on function public.insert_agentic_rectification_run_phase(uuid, uuid, uuid, text, text, integer) to service_role; -- --------------------------------------------------------------------------- -- 11. Turn execution receipt (safe phases + tools + status for refresh) -- --------------------------------------------------------------------------- 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_phases jsonb; v_tools 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; select coalesce(jsonb_agg( jsonb_build_object('phase', rp.phase, 'tool', rp.tool_name) order by rp.sequence ), '[]'::jsonb) into v_phases from public.agentic_rectification_run_phases rp where rp.turn_id = p_turn_id; select coalesce(jsonb_agg(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' ) tools; 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; return jsonb_build_object( 'turn_id', v_turn.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, '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; commit;