1066 lines
41 KiB
PL/PgSQL
1066 lines
41 KiB
PL/PgSQL
-- 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;
|