492 lines
21 KiB
PL/PgSQL
492 lines
21 KiB
PL/PgSQL
begin;
|
|
|
|
-- Read projection and acceptance must agree on the same authoritative transition.
|
|
-- A malformed or internally contradictory ledger row cannot replace the immutable receipt.
|
|
create or replace function public.compose_agentic_rectification_decision_receipt(
|
|
p_case_id uuid,
|
|
p_result_id uuid,
|
|
p_receipt jsonb
|
|
)
|
|
returns jsonb
|
|
language plpgsql
|
|
stable
|
|
security definer
|
|
set search_path = ''
|
|
as $$
|
|
declare
|
|
v_transition public.agentic_rectification_inference_transitions%rowtype;
|
|
v_receipt jsonb;
|
|
begin
|
|
v_receipt := case
|
|
when p_receipt is not null and jsonb_typeof(p_receipt) = 'object' then p_receipt
|
|
else '{}'::jsonb
|
|
end;
|
|
if p_case_id is null or p_result_id is null then
|
|
return v_receipt;
|
|
end if;
|
|
|
|
select * into v_transition
|
|
from public.agentic_rectification_inference_transitions
|
|
where case_id = p_case_id
|
|
and result_id = p_result_id
|
|
order by revision desc
|
|
limit 1;
|
|
|
|
if not found then
|
|
return v_receipt;
|
|
end if;
|
|
|
|
if jsonb_typeof(v_transition.inference_state) <> 'object'
|
|
or jsonb_typeof(v_transition.inference_state -> 'revision') is distinct from 'number'
|
|
or coalesce((v_transition.inference_state ->> 'revision') !~ '^[0-9]+$', true)
|
|
or jsonb_typeof(v_transition.inference_state -> 'candidate_set_id') is distinct from 'string' then
|
|
return v_receipt - 'inference_state' - 'decision_state_fingerprint';
|
|
end if;
|
|
|
|
if (v_transition.inference_state ->> 'revision')::integer is distinct from v_transition.revision
|
|
or v_transition.inference_state ->> 'candidate_set_id' is distinct from v_transition.candidate_set_id then
|
|
return v_receipt - 'inference_state' - 'decision_state_fingerprint';
|
|
end if;
|
|
|
|
return jsonb_set(
|
|
jsonb_set(v_receipt, '{inference_state}', v_transition.inference_state, true),
|
|
'{decision_state_fingerprint}',
|
|
to_jsonb(v_transition.decision_state_fingerprint),
|
|
true
|
|
);
|
|
end;
|
|
$$;
|
|
|
|
revoke all on function public.compose_agentic_rectification_decision_receipt(uuid, uuid, jsonb)
|
|
from public, anon, authenticated;
|
|
grant execute on function public.compose_agentic_rectification_decision_receipt(uuid, uuid, jsonb)
|
|
to service_role;
|
|
|
|
-- A fresh rectification Case intentionally snapshots active_birth_time as null so
|
|
-- an older accepted chart minute cannot become the next Case's calculation input.
|
|
-- Compare only the inputs that shaped the live result, on both first acceptance
|
|
-- and reselection; active_birth_time is the output of acceptance, not an input.
|
|
|
|
create or replace function public.accept_agentic_rectification_candidate_for_case_v2(
|
|
p_user_id uuid,
|
|
p_case_id uuid,
|
|
p_result_id uuid,
|
|
p_candidate_id uuid,
|
|
p_request_id uuid
|
|
)
|
|
returns jsonb
|
|
language plpgsql
|
|
security definer
|
|
set search_path = ''
|
|
as $$
|
|
declare
|
|
v_case public.agentic_rectification_cases%rowtype;
|
|
v_result public.agentic_rectification_results%rowtype;
|
|
v_candidate public.agentic_rectification_candidates%rowtype;
|
|
v_profile public.profiles%rowtype;
|
|
v_snapshot jsonb;
|
|
v_inference jsonb;
|
|
v_expected_candidate_set_id text;
|
|
v_persisted_candidate_count integer;
|
|
v_top_active_time text;
|
|
v_active_range_start text;
|
|
v_active_range_end text;
|
|
v_latest_transition public.agentic_rectification_inference_transitions%rowtype;
|
|
v_existing_decision public.agentic_rectification_candidate_decisions%rowtype;
|
|
v_response jsonb;
|
|
begin
|
|
if p_user_id is null or p_case_id is null or p_result_id is null
|
|
or p_candidate_id is null or p_request_id is null then
|
|
raise exception 'agentic_rectification_candidate_invalid_input' using errcode = 'P0001';
|
|
end if;
|
|
|
|
perform pg_catalog.pg_advisory_xact_lock(
|
|
pg_catalog.hashtextextended(p_user_id::text || ':' || p_request_id::text, 0)
|
|
);
|
|
|
|
select * into v_case
|
|
from public.agentic_rectification_cases
|
|
where id = p_case_id and user_id = p_user_id
|
|
for update;
|
|
if not found then
|
|
raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001';
|
|
end if;
|
|
|
|
select * into v_result
|
|
from public.agentic_rectification_results
|
|
where id = p_result_id
|
|
and user_id = p_user_id
|
|
and case_id = p_case_id
|
|
for update;
|
|
if not found then
|
|
raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001';
|
|
end if;
|
|
|
|
select * into v_candidate
|
|
from public.agentic_rectification_candidates
|
|
where id = p_candidate_id
|
|
and result_id = p_result_id
|
|
and user_id = p_user_id
|
|
and case_id = p_case_id;
|
|
if not found then
|
|
raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001';
|
|
end if;
|
|
|
|
select * into v_existing_decision
|
|
from public.agentic_rectification_candidate_decisions
|
|
where user_id = p_user_id and request_id = p_request_id
|
|
for update;
|
|
if found then
|
|
if v_existing_decision.decision_kind <> 'accept'
|
|
or v_existing_decision.case_id <> p_case_id
|
|
or v_existing_decision.result_id <> p_result_id
|
|
or v_existing_decision.candidate_id <> p_candidate_id then
|
|
raise exception 'agentic_rectification_candidate_request_conflict' using errcode = 'P0001';
|
|
end if;
|
|
return jsonb_set(v_existing_decision.response, '{idempotent}', 'true'::jsonb, true);
|
|
end if;
|
|
|
|
if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then
|
|
raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001';
|
|
end if;
|
|
if v_result.invalidated_at is not null or v_result.expires_at <= pg_catalog.now() then
|
|
raise exception 'agentic_rectification_candidate_expired' using errcode = 'P0001';
|
|
end if;
|
|
if not v_result.display_allowed or not v_result.selection_allowed then
|
|
raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001';
|
|
end if;
|
|
if exists (
|
|
select 1
|
|
from public.agentic_rectification_results newer
|
|
where newer.user_id = p_user_id
|
|
and newer.case_id = p_case_id
|
|
and newer.invalidated_at is null
|
|
and newer.created_at > v_result.created_at
|
|
) then
|
|
raise exception 'agentic_rectification_candidate_superseded' using errcode = 'P0001';
|
|
end if;
|
|
|
|
select * into v_profile
|
|
from public.profiles
|
|
where id = p_user_id
|
|
for update;
|
|
if not found then
|
|
raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001';
|
|
end if;
|
|
|
|
v_snapshot := v_case.baseline_birth_snapshot;
|
|
if v_profile.birth_date is distinct from (v_snapshot ->> 'birth_date')::date
|
|
or v_profile.reported_birth_time is distinct from (v_snapshot ->> 'reported_birth_time')::time without time zone
|
|
or v_profile.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.declared_window_start is distinct from v_snapshot ->> 'declared_window_start'
|
|
or v_profile.declared_window_end is distinct from v_snapshot ->> 'declared_window_end'
|
|
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_id is distinct from v_snapshot ->> 'timezone_id'
|
|
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;
|
|
|
|
-- Adoption is a trust-boundary write. Structured answers append inference
|
|
-- transitions instead of mutating the engine result, so the latest transition
|
|
-- is authoritative when present; persisted display candidates remain a cache.
|
|
select * into v_latest_transition
|
|
from public.agentic_rectification_inference_transitions
|
|
where case_id = p_case_id
|
|
and result_id = v_result.id
|
|
order by revision desc
|
|
limit 1;
|
|
v_inference := case
|
|
when v_latest_transition.id is not null then v_latest_transition.inference_state
|
|
else v_result.decision_receipt -> 'inference_state'
|
|
end;
|
|
if v_inference is null
|
|
or jsonb_typeof(v_inference) <> 'object'
|
|
or jsonb_typeof(v_inference -> 'candidates') <> 'array'
|
|
or jsonb_array_length(v_inference -> 'candidates') = 0
|
|
or jsonb_typeof(v_inference -> 'revision') is distinct from 'number'
|
|
or coalesce((v_inference ->> 'revision') !~ '^[0-9]+$', true)
|
|
or length(btrim(coalesce(v_inference ->> 'candidate_set_id', ''))) = 0
|
|
or (v_inference ->> 'range_start') is distinct from v_case.candidate_range ->> 'start_time'
|
|
or (v_inference ->> 'range_end') is distinct from v_case.candidate_range ->> 'end_time' then
|
|
raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001';
|
|
end if;
|
|
|
|
if v_latest_transition.id is not null
|
|
and (
|
|
v_latest_transition.revision is distinct from (v_inference ->> 'revision')::integer
|
|
or v_latest_transition.candidate_set_id is distinct from v_inference ->> 'candidate_set_id'
|
|
) then
|
|
raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001';
|
|
end if;
|
|
|
|
select count(*),
|
|
(v_case.candidate_range ->> 'start_time') || '-' ||
|
|
(v_case.candidate_range ->> 'end_time') || ':' ||
|
|
string_agg(pg_catalog.to_char(candidate_time, 'HH24:MI'), ',' order by candidate_time)
|
|
into v_persisted_candidate_count, v_expected_candidate_set_id
|
|
from public.agentic_rectification_candidates
|
|
where result_id = v_result.id
|
|
and user_id = p_user_id
|
|
and case_id = p_case_id;
|
|
|
|
if v_persisted_candidate_count = 0
|
|
or jsonb_array_length(v_inference -> 'candidates') <> v_persisted_candidate_count
|
|
or (v_inference ->> 'candidate_set_id') is distinct from v_expected_candidate_set_id
|
|
or exists (
|
|
select 1
|
|
from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value)
|
|
where jsonb_typeof(item.value) <> 'object'
|
|
) then
|
|
raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001';
|
|
end if;
|
|
|
|
if exists (
|
|
select 1
|
|
from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value)
|
|
where coalesce((item.value ->> 'time') !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$', true)
|
|
or coalesce(item.value ->> 'status', '') not in ('active', 'equivalent', 'winner', 'eliminated')
|
|
or jsonb_typeof(item.value -> 'probability') is distinct from 'number'
|
|
or jsonb_typeof(item.value -> 'posterior_score') is distinct from 'number'
|
|
or jsonb_typeof(item.value -> 'cluster_range') is distinct from 'array'
|
|
) then
|
|
raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001';
|
|
end if;
|
|
|
|
if exists (
|
|
select 1
|
|
from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value)
|
|
where jsonb_array_length(item.value -> 'cluster_range') is distinct from 2
|
|
or coalesce((item.value -> 'cluster_range' ->> 0) !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$', true)
|
|
or coalesce((item.value -> 'cluster_range' ->> 1) !~ '^([01][0-9]|2[0-3]):[0-5][0-9]$', true)
|
|
or item.value -> 'cluster_range' ->> 0 > item.value ->> 'time'
|
|
or item.value -> 'cluster_range' ->> 1 < item.value ->> 'time'
|
|
or not exists (
|
|
select 1
|
|
from public.agentic_rectification_candidates persisted
|
|
where persisted.result_id = v_result.id
|
|
and pg_catalog.to_char(persisted.candidate_time, 'HH24:MI') = item.value ->> 'time'
|
|
)
|
|
) or (
|
|
select count(distinct item.value ->> 'time')
|
|
from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value)
|
|
) <> v_persisted_candidate_count then
|
|
raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001';
|
|
end if;
|
|
|
|
select item.value ->> 'time'
|
|
into v_top_active_time
|
|
from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value)
|
|
where item.value ->> 'status' <> 'eliminated'
|
|
order by (item.value ->> 'probability')::numeric desc,
|
|
(item.value ->> 'posterior_score')::numeric desc,
|
|
item.value ->> 'time'
|
|
limit 1;
|
|
|
|
select min(item.value -> 'cluster_range' ->> 0),
|
|
max(item.value -> 'cluster_range' ->> 1)
|
|
into v_active_range_start, v_active_range_end
|
|
from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value)
|
|
where item.value ->> 'status' <> 'eliminated';
|
|
|
|
if v_top_active_time is null
|
|
or (v_inference ->> 'representative_time') is distinct from v_top_active_time
|
|
or jsonb_typeof(v_inference -> 'credible_range') <> 'array'
|
|
or jsonb_array_length(v_inference -> 'credible_range') <> 2
|
|
or (v_inference -> 'credible_range' ->> 0) is distinct from v_active_range_start
|
|
or (v_inference -> 'credible_range' ->> 1) is distinct from v_active_range_end
|
|
or not exists (
|
|
select 1
|
|
from pg_catalog.jsonb_array_elements(v_inference -> 'candidates') as item(value)
|
|
where item.value ->> 'time' = pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI')
|
|
and item.value ->> 'status' <> 'eliminated'
|
|
) then
|
|
raise exception 'agentic_rectification_candidate_state_inconsistent' using errcode = 'P0001';
|
|
end if;
|
|
|
|
if v_result.selected_candidate_id is not null then
|
|
if v_result.selected_candidate_id = p_candidate_id
|
|
and v_result.selected_time is not distinct from v_candidate.candidate_time
|
|
and v_case.accepted_time is not distinct from v_candidate.candidate_time
|
|
and v_result.selection_kind is not distinct from 'user_accepted'
|
|
and v_profile.active_birth_time is not distinct from v_candidate.candidate_time
|
|
and v_profile.birth_time_status is not distinct from 'accepted' then
|
|
v_response := jsonb_build_object(
|
|
'success', true,
|
|
'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'),
|
|
'status', 'accepted',
|
|
'result_id', v_result.id,
|
|
'candidate_id', v_candidate.id,
|
|
'case_status', 'candidate_accepted',
|
|
'idempotent', true
|
|
);
|
|
insert into public.agentic_rectification_candidate_decisions (
|
|
user_id, case_id, result_id, candidate_id, request_id, decision_kind, response
|
|
) values (
|
|
p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'accept', v_response
|
|
);
|
|
return v_response;
|
|
end if;
|
|
|
|
if v_case.status is distinct from 'candidate_accepted'
|
|
or v_result.selection_kind is distinct from 'user_accepted'
|
|
or v_profile.active_birth_time is distinct from v_result.selected_time
|
|
or v_profile.birth_time_status is distinct from 'accepted' then
|
|
raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001';
|
|
end if;
|
|
else
|
|
if v_result.selected_time is not null then
|
|
raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001';
|
|
end if;
|
|
end if;
|
|
|
|
update public.profiles
|
|
set active_birth_time = v_candidate.candidate_time,
|
|
birth_time = v_candidate.candidate_time,
|
|
birth_time_status = 'accepted',
|
|
rectification_confidence = case
|
|
when v_result.overall_confidence = 'high' then 100
|
|
when v_result.overall_confidence = 'medium' then 70
|
|
else 40
|
|
end,
|
|
updated_at = pg_catalog.now()
|
|
where id = p_user_id;
|
|
|
|
update public.agentic_rectification_results
|
|
set selected_candidate_id = p_candidate_id,
|
|
selected_time = v_candidate.candidate_time,
|
|
selection_kind = 'user_accepted',
|
|
selected_at = pg_catalog.now(),
|
|
updated_at = pg_catalog.now()
|
|
where id = v_result.id;
|
|
|
|
update public.agentic_rectification_results
|
|
set invalidated_at = pg_catalog.now(),
|
|
updated_at = pg_catalog.now()
|
|
where user_id = p_user_id
|
|
and case_id = p_case_id
|
|
and id <> v_result.id
|
|
and invalidated_at is null
|
|
and selected_time is null;
|
|
|
|
update public.agentic_rectification_cases
|
|
set status = 'candidate_accepted',
|
|
accepted_time = v_candidate.candidate_time,
|
|
confirmed_time = null,
|
|
completed_at = null,
|
|
last_activity_at = pg_catalog.now(),
|
|
updated_at = pg_catalog.now()
|
|
where id = v_case.id;
|
|
|
|
v_response := jsonb_build_object(
|
|
'success', true,
|
|
'saved_time', pg_catalog.to_char(v_candidate.candidate_time, 'HH24:MI'),
|
|
'status', 'accepted',
|
|
'result_id', v_result.id,
|
|
'candidate_id', v_candidate.id,
|
|
'case_status', 'candidate_accepted',
|
|
'idempotent', false
|
|
);
|
|
|
|
insert into public.agentic_rectification_candidate_decisions (
|
|
user_id, case_id, result_id, candidate_id, request_id, decision_kind, response
|
|
) values (
|
|
p_user_id, p_case_id, p_result_id, p_candidate_id, p_request_id, 'accept', v_response
|
|
);
|
|
|
|
return v_response;
|
|
end;
|
|
$$;
|
|
|
|
revoke all on function public.accept_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid)
|
|
from public, anon, authenticated;
|
|
grant execute on function public.accept_agentic_rectification_candidate_for_case_v2(uuid, uuid, uuid, uuid, uuid)
|
|
to service_role;
|
|
|
|
-- Keep candidate freshness tied to calculation inputs only. Display labels and
|
|
-- accepted/active time are outputs or presentation state; declared windows are
|
|
-- calculation inputs and must invalidate live candidates immediately.
|
|
create or replace function public.agentic_rectification_profiles_rebaseline_guard()
|
|
returns trigger
|
|
language plpgsql
|
|
security definer
|
|
set search_path = ''
|
|
as $$
|
|
begin
|
|
if old.birth_date is distinct from new.birth_date
|
|
or old.reported_birth_time is distinct from new.reported_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.declared_window_start is distinct from new.declared_window_start
|
|
or old.declared_window_end is distinct from new.declared_window_end
|
|
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_id is distinct from new.timezone_id
|
|
or old.timezone_offset is distinct from new.timezone_offset then
|
|
update public.agentic_rectification_cases
|
|
set status = 'needs_rebaseline',
|
|
updated_at = pg_catalog.now(),
|
|
last_activity_at = pg_catalog.now()
|
|
where user_id = new.id
|
|
and status = any (public.agentic_rectification_resumable_statuses());
|
|
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, birth_time_source, birth_time_period,
|
|
declared_window_start, declared_window_end, uncertainty_before_minutes, uncertainty_after_minutes,
|
|
latitude, longitude, timezone_id, timezone_offset
|
|
on public.profiles
|
|
for each row execute function public.agentic_rectification_profiles_rebaseline_guard();
|
|
|
|
create or replace function public.invalidate_agentic_rectification_results_on_profile_change()
|
|
returns trigger
|
|
language plpgsql
|
|
security definer
|
|
set search_path = ''
|
|
as $$
|
|
begin
|
|
if old.birth_date is distinct from new.birth_date
|
|
or old.reported_birth_time is distinct from new.reported_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.declared_window_start is distinct from new.declared_window_start
|
|
or old.declared_window_end is distinct from new.declared_window_end
|
|
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_id is distinct from new.timezone_id
|
|
or old.timezone_offset is distinct from new.timezone_offset then
|
|
update public.agentic_rectification_results
|
|
set invalidated_at = pg_catalog.now(),
|
|
updated_at = pg_catalog.now()
|
|
where user_id = new.id
|
|
and invalidated_at is null;
|
|
end if;
|
|
return new;
|
|
end;
|
|
$$;
|
|
|
|
revoke all on function public.invalidate_agentic_rectification_results_on_profile_change()
|
|
from public, anon, authenticated;
|
|
drop trigger if exists profiles_invalidate_agentic_rectification_results on public.profiles;
|
|
create trigger profiles_invalidate_agentic_rectification_results
|
|
after update of birth_date, reported_birth_time, birth_time_source, birth_time_period,
|
|
declared_window_start, declared_window_end, uncertainty_before_minutes, uncertainty_after_minutes,
|
|
latitude, longitude, timezone_id, timezone_offset
|
|
on public.profiles
|
|
for each row execute function public.invalidate_agentic_rectification_results_on_profile_change();
|
|
|
|
commit;
|