Files
Jyotisha/frontend/supabase/migrations/20260827010000_rectification_candidate_profile_freshness.sql
T
Jesse_Chen 5f855c610c
Independent Staging Quality Gate / validate (push) Failing after 15m32s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(rectification): keep candidate state coherent
2026-08-27 13:59:38 +08:00

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;