From c883c401aaf462631748f9a7d907baaa3493bba2 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 20 Jul 2026 23:11:01 +0800 Subject: [PATCH] fix: harden conversational persistence boundaries --- .../conversational-rectification/contracts.ts | 6 +- .../json-bounds.ts | 35 ++- ...00_conversational_rectification_schema.sql | 194 ++++++++++++++++- ...0_conversational_rectification_billing.sql | 203 ++++++++++-------- ...nversational_rectification_transitions.sql | 195 +++++++++++++++-- 5 files changed, 531 insertions(+), 102 deletions(-) diff --git a/frontend/src/lib/conversational-rectification/contracts.ts b/frontend/src/lib/conversational-rectification/contracts.ts index 77cd4ca0..dec03e6e 100644 --- a/frontend/src/lib/conversational-rectification/contracts.ts +++ b/frontend/src/lib/conversational-rectification/contracts.ts @@ -82,6 +82,10 @@ const evidenceRecapEntrySchema = boundedJson(z.object({ summary: boundedNonblankText(1_000), dateLabel: boundedNonblankText(80), }).strict(), 4_096); +const evidenceRecapSchema = boundedJson( + z.array(evidenceRecapEntrySchema).max(20), + 24_576, +); export const conversationalRectificationTurnSchema = boundedJson(z.object({ caseId: caseIdSchema, @@ -92,7 +96,7 @@ export const conversationalRectificationTurnSchema = boundedJson(z.object({ candidate: candidateSchema, technicalReceipt: technicalReceiptSchema, evidenceRequest: evidenceRequestSchema.nullable(), - evidenceRecap: z.array(evidenceRecapEntrySchema).max(20), + evidenceRecap: evidenceRecapSchema, actions: z.array(z.enum([ "answer", "pause", diff --git a/frontend/src/lib/conversational-rectification/json-bounds.ts b/frontend/src/lib/conversational-rectification/json-bounds.ts index a060f0e0..5af98757 100644 --- a/frontend/src/lib/conversational-rectification/json-bounds.ts +++ b/frontend/src/lib/conversational-rectification/json-bounds.ts @@ -1,5 +1,32 @@ import { z } from "zod"; +const POSTGRES_JSON_DECIMAL_SCALE = 1_000_000; +const POSTGRES_JSON_MIN_FRACTION = 1 / POSTGRES_JSON_DECIMAL_SCALE; +const POSTGRES_JSON_MAX_FRACTION_MAGNITUDE = 1_000_000; + +function postgresStableJsonNumber(value: number): boolean { + if (!Number.isFinite(value)) return false; + if (Number.isSafeInteger(value)) return true; + const magnitude = Math.abs(value); + return magnitude >= POSTGRES_JSON_MIN_FRACTION + && magnitude <= POSTGRES_JSON_MAX_FRACTION_MAGNITUDE + && Number.isSafeInteger(value * POSTGRES_JSON_DECIMAL_SCALE); +} + +function postgresJsonNumbersAreStable( + value: unknown, + ancestors: Set = new Set(), +): boolean { + if (typeof value === "number") return postgresStableJsonNumber(value); + if (value === null || typeof value !== "object") return true; + if (ancestors.has(value)) return false; + ancestors.add(value); + const values = Array.isArray(value) ? value : Object.values(value); + const stable = values.every((item) => postgresJsonNumbersAreStable(item, ancestors)); + ancestors.delete(value); + return stable; +} + function postgresSeparatorBytes(value: unknown): number { if (Array.isArray(value)) { return Math.max(0, value.length - 1) @@ -13,9 +40,15 @@ function postgresSeparatorBytes(value: unknown): number { return 0; } -/** Matches PostgreSQL jsonb::text, which adds one space after each comma and colon. */ +/** + * Matches PostgreSQL jsonb::text for the durable numeric contract: safe + * integers or nonzero decimals with at most six places and magnitude <= 1e6. + * PostgreSQL expands exponent-form numerics, so values outside that contract + * return Infinity instead of undercounting their durable representation. + */ export function postgresJsonbTextBytes(value: unknown): number { try { + if (!postgresJsonNumbersAreStable(value)) return Number.POSITIVE_INFINITY; const compact = JSON.stringify(value); if (compact === undefined) return Number.POSITIVE_INFINITY; const serializedValue: unknown = JSON.parse(compact); diff --git a/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql b/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql index 1f2459f2..44703c64 100644 --- a/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql +++ b/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql @@ -58,6 +58,60 @@ exception when others then end; $$; +create or replace function public.conversational_rectification_text_is_nonblank( + p_value text +) +returns boolean +language sql +immutable +strict +set search_path = '' +as $$ + select pg_catalog.char_length(pg_catalog.btrim( + p_value, + U&'\0009\000A\000B\000C\000D\0020\00A0\1680\2000\2001\2002\2003\2004\2005\2006\2007\2008\2009\200A\2028\2029\202F\205F\3000\FEFF' + )) > 0; +$$; + +create or replace function public.conversational_rectification_numbers_are_stable( + p_value jsonb +) +returns boolean +language plpgsql +immutable +strict +set search_path = '' +as $$ +declare + v_item jsonb; + v_number numeric; +begin + if pg_catalog.jsonb_typeof(p_value) = 'number' then + v_number := (p_value #>> '{}')::numeric; + if v_number = pg_catalog.trunc(v_number) then + return pg_catalog.abs(v_number) <= 9007199254740991; + end if; + return pg_catalog.abs(v_number) between 0.000001 and 1000000 + and v_number = pg_catalog.trunc(v_number, 6); + elsif pg_catalog.jsonb_typeof(p_value) = 'array' then + for v_item in select value from pg_catalog.jsonb_array_elements(p_value) loop + if public.conversational_rectification_numbers_are_stable(v_item) is not true then + return false; + end if; + end loop; + elsif pg_catalog.jsonb_typeof(p_value) = 'object' then + for v_item in select value from pg_catalog.jsonb_each(p_value) loop + if public.conversational_rectification_numbers_are_stable(v_item) is not true then + return false; + end if; + end loop; + end if; + return true; +exception when others then + return false; +end; +$$; + create or replace function public.conversational_rectification_text_array_is_bounded( p_value jsonb, p_max_items integer, @@ -94,6 +148,7 @@ declare begin if pg_catalog.jsonb_typeof(p_value) is distinct from 'object' or pg_catalog.octet_length(p_value::text) > 512 + or public.conversational_rectification_numbers_are_stable(p_value) is not true or not public.conversational_rectification_has_only_keys( p_value, array['status', 'representativeTime', 'rangeStart', 'rangeEnd']::text[] @@ -130,6 +185,7 @@ set search_path = '' as $$ select pg_catalog.jsonb_typeof(p_value) = 'object' and pg_catalog.octet_length(p_value::text) <= 8192 + and public.conversational_rectification_numbers_are_stable(p_value) and public.conversational_rectification_has_only_keys( p_value, array[ @@ -166,6 +222,7 @@ set search_path = '' as $$ select pg_catalog.jsonb_typeof(p_value) = 'object' and pg_catalog.octet_length(p_value::text) <= 2048 + and public.conversational_rectification_numbers_are_stable(p_value) and public.conversational_rectification_has_only_keys( p_value, array['domains', 'datePrecision', 'freeTextAllowed']::text[] @@ -193,6 +250,7 @@ set search_path = '' as $$ select pg_catalog.jsonb_typeof(p_value) = 'array' and pg_catalog.octet_length(p_value::text) <= 24576 + and public.conversational_rectification_numbers_are_stable(p_value) and pg_catalog.jsonb_array_length(p_value) <= 20 and not exists ( select 1 @@ -223,6 +281,7 @@ set search_path = '' as $$ select pg_catalog.jsonb_typeof(p_value) = 'array' and pg_catalog.octet_length(p_value::text) <= 512 + and public.conversational_rectification_numbers_are_stable(p_value) and pg_catalog.jsonb_array_length(p_value) <= 5 and not exists ( select 1 @@ -245,6 +304,7 @@ as $$ begin if pg_catalog.jsonb_typeof(p_value) is distinct from 'object' or pg_catalog.octet_length(p_value::text) > 8192 + or public.conversational_rectification_numbers_are_stable(p_value) is not true or not public.conversational_rectification_has_only_keys( p_value, array[ @@ -289,6 +349,103 @@ exception when others then end; $$; +create or replace function public.conversational_rectification_valid_life_event_evidence( + p_value jsonb +) +returns boolean +language plpgsql +immutable +strict +set search_path = '' +as $$ +begin + if pg_catalog.jsonb_typeof(p_value) is distinct from 'object' + or pg_catalog.octet_length(p_value::text) > 16384 + or public.conversational_rectification_numbers_are_stable(p_value) is not true + or not public.conversational_rectification_has_only_keys( + p_value, + array[ + 'id', 'rawText', 'domain', 'eventSummary', 'dateValue', + 'datePrecision', 'extractionStatus', 'scoreable' + ]::text[] + ) + or not (p_value ?& array[ + 'id', 'rawText', 'domain', 'eventSummary', 'dateValue', + 'datePrecision', 'extractionStatus' + ]::text[]) + or pg_catalog.jsonb_typeof(p_value -> 'id') is distinct from 'string' + or not public.conversational_rectification_valid_uuid_text(p_value ->> 'id') + or p_value ->> 'id' !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + or pg_catalog.jsonb_typeof(p_value -> 'rawText') is distinct from 'string' + or pg_catalog.char_length(p_value ->> 'rawText') not between 1 and 4000 + or public.conversational_rectification_text_is_nonblank( + p_value ->> 'rawText' + ) is not true + or pg_catalog.jsonb_typeof(p_value -> 'domain') is distinct from 'string' + or p_value ->> 'domain' not in ( + 'career', 'education', 'relocation', 'relationship', 'family', 'other' + ) + or pg_catalog.jsonb_typeof(p_value -> 'eventSummary') is distinct from 'string' + or pg_catalog.char_length(p_value ->> 'eventSummary') not between 1 and 1000 + or public.conversational_rectification_text_is_nonblank( + p_value ->> 'eventSummary' + ) is not true + or not (p_value ? 'dateValue') + or ( + p_value -> 'dateValue' <> 'null'::jsonb + and ( + pg_catalog.jsonb_typeof(p_value -> 'dateValue') is distinct from 'string' + or pg_catalog.char_length(p_value ->> 'dateValue') not between 1 and 80 + or public.conversational_rectification_text_is_nonblank( + p_value ->> 'dateValue' + ) is not true + ) + ) + or pg_catalog.jsonb_typeof(p_value -> 'datePrecision') is distinct from 'string' + or p_value ->> 'datePrecision' not in ('day', 'month', 'year', 'range', 'unknown') + or pg_catalog.jsonb_typeof(p_value -> 'extractionStatus') is distinct from 'string' + or p_value ->> 'extractionStatus' not in ( + 'clear', 'needs_clarification', 'corrected' + ) + or ( + p_value ? 'scoreable' + and pg_catalog.jsonb_typeof(p_value -> 'scoreable') is distinct from 'boolean' + ) then + return false; + end if; + return true; +exception when others then + return false; +end; +$$; + +create or replace function public.conversational_rectification_valid_life_event_evidence_array( + p_value jsonb +) +returns boolean +language plpgsql +immutable +strict +set search_path = '' +as $$ +declare + v_item jsonb; +begin + if pg_catalog.jsonb_typeof(p_value) is distinct from 'array' + or pg_catalog.jsonb_array_length(p_value) > 20 then + return false; + end if; + for v_item in select value from pg_catalog.jsonb_array_elements(p_value) loop + if public.conversational_rectification_valid_life_event_evidence(v_item) is not true then + return false; + end if; + end loop; + return true; +exception when others then + return false; +end; +$$; + create or replace function public.conversational_rectification_valid_private_candidate( p_value jsonb ) @@ -304,6 +461,7 @@ declare begin if pg_catalog.jsonb_typeof(p_value) is distinct from 'object' or pg_catalog.octet_length(p_value::text) > 65536 + or public.conversational_rectification_numbers_are_stable(p_value) is not true or not public.conversational_rectification_has_only_keys( p_value, array[ @@ -457,6 +615,7 @@ declare begin if pg_catalog.jsonb_typeof(p_value) is distinct from 'object' or pg_catalog.octet_length(p_value::text) > 12000 + or public.conversational_rectification_numbers_are_stable(p_value) is not true or not public.conversational_rectification_has_only_keys( p_value, array[ @@ -574,6 +733,7 @@ set search_path = '' as $$ select pg_catalog.jsonb_typeof(p_value) = 'object' and pg_catalog.octet_length(p_value::text) <= 65536 + and public.conversational_rectification_numbers_are_stable(p_value) and public.conversational_rectification_has_only_keys( p_value, array[ @@ -624,6 +784,7 @@ set search_path = '' as $$ select pg_catalog.jsonb_typeof(p_value) = 'object' and pg_catalog.octet_length(p_value::text) <= 2048 + and public.conversational_rectification_numbers_are_stable(p_value) and public.conversational_rectification_has_only_keys( p_value, array[ @@ -688,6 +849,7 @@ begin if p_action_kind in ('reserve_fee', 'complete_fee', 'release_fee', 'recover_fee') then return coalesce(pg_catalog.jsonb_typeof(p_value) = 'object' and pg_catalog.octet_length(p_value::text) <= 2048 + and public.conversational_rectification_numbers_are_stable(p_value) and public.conversational_rectification_has_only_keys( p_value, array['success', 'credits', 'billing_state', 'error_code']::text[] ) @@ -715,6 +877,7 @@ begin end if; return coalesce(pg_catalog.jsonb_typeof(p_value) = 'object' and pg_catalog.octet_length(p_value::text) <= 69632 + and public.conversational_rectification_numbers_are_stable(p_value) and public.conversational_rectification_has_only_keys( p_value, array[ @@ -903,13 +1066,22 @@ create table if not exists public.birth_time_rectification_event_evidence ( source_turn_id uuid not null, raw_text text not null check ( char_length(raw_text) between 1 and 4000 - and char_length(btrim(raw_text)) > 0 + and public.conversational_rectification_text_is_nonblank(raw_text) ), domain text not null check ( domain in ('career', 'education', 'relocation', 'relationship', 'family', 'other') ), - event_summary text not null check (char_length(event_summary) between 1 and 1000), - date_value text check (date_value is null or char_length(date_value) between 1 and 80), + event_summary text not null check ( + char_length(event_summary) between 1 and 1000 + and public.conversational_rectification_text_is_nonblank(event_summary) + ), + date_value text check ( + date_value is null + or ( + char_length(date_value) between 1 and 80 + and public.conversational_rectification_text_is_nonblank(date_value) + ) + ), date_precision text not null check ( date_precision in ('day', 'month', 'year', 'range', 'unknown') ), @@ -1017,5 +1189,21 @@ grant all on table public.birth_time_rectification_billing to service_role; revoke all on function public.conversational_rectification_billing_receipt_action_id( uuid, text ) from public, anon, authenticated, service_role; +revoke all on function public.conversational_rectification_numbers_are_stable(jsonb) + from public, anon, authenticated; +revoke all on function public.conversational_rectification_text_is_nonblank(text) + from public, anon, authenticated; +revoke all on function public.conversational_rectification_valid_life_event_evidence(jsonb) + from public, anon, authenticated; +revoke all on function public.conversational_rectification_valid_life_event_evidence_array(jsonb) + from public, anon, authenticated; +grant execute on function public.conversational_rectification_numbers_are_stable(jsonb) + to service_role; +grant execute on function public.conversational_rectification_text_is_nonblank(text) + to service_role; +grant execute on function public.conversational_rectification_valid_life_event_evidence(jsonb) + to service_role; +grant execute on function public.conversational_rectification_valid_life_event_evidence_array(jsonb) + to service_role; commit; diff --git a/frontend/supabase/migrations/20260720020000_conversational_rectification_billing.sql b/frontend/supabase/migrations/20260720020000_conversational_rectification_billing.sql index a692962f..70eb47f5 100644 --- a/frontend/supabase/migrations/20260720020000_conversational_rectification_billing.sql +++ b/frontend/supabase/migrations/20260720020000_conversational_rectification_billing.sql @@ -1,5 +1,117 @@ begin; +create or replace function public.recover_conversational_rectification_orphan_reservations( + p_user_id uuid, + p_excluded_case_id uuid default null +) +returns integer +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_orphan public.birth_time_rectification_billing%rowtype; + v_balance integer; + v_recovery_action_id uuid; + v_recovery_fingerprint text; + v_recovery_response jsonb; +begin + if p_user_id is null then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended( + p_user_id::text || ':conversational-rectification-case', + 0 + ) + ); + select profile.credits into v_balance + from public.profiles profile + where profile.id = p_user_id + for update; + if not found then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + + for v_orphan in + select orphan_billing.* + from public.birth_time_rectification_billing orphan_billing + left join public.birth_time_rectification_cases orphan_case + on orphan_case.id = orphan_billing.case_id + where orphan_billing.user_id = p_user_id + and ( + p_excluded_case_id is null + or orphan_billing.case_id <> p_excluded_case_id + ) + and orphan_billing.state = 'reserved' + and orphan_case.id is null + order by orphan_billing.reserved_at, orphan_billing.case_id + for update of orphan_billing + loop + v_recovery_action_id := + public.conversational_rectification_billing_receipt_action_id( + v_orphan.reserve_action_id, + 'recover_fee' + ); + v_recovery_fingerprint := pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to( + pg_catalog.jsonb_build_object( + 'kind', 'recover_fee', 'userId', p_user_id, 'caseId', v_orphan.case_id, + 'expectedVersion', 0, 'actionId', v_recovery_action_id, + 'reserveActionId', v_orphan.reserve_action_id + )::text, + 'UTF8' + )), 'hex'); + + update public.profiles profile + set credits = profile.credits + v_orphan.price, + updated_at = pg_catalog.now() + where profile.id = p_user_id + returning profile.credits into v_balance; + if not found then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + + insert into public.credit_transactions ( + user_id, transaction_type, amount, balance_after, request_id + ) values ( + p_user_id, 'refund', v_orphan.price, v_balance, + 'rectification:' || v_orphan.case_id::text + ); + + update public.birth_time_rectification_billing orphan_billing + set state = 'released', + release_action_id = v_recovery_action_id, + balance_after = v_balance, + released_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where orphan_billing.case_id = v_orphan.case_id + and orphan_billing.user_id = p_user_id + and orphan_billing.state = 'reserved'; + if not found then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + + v_recovery_response := pg_catalog.jsonb_build_object( + 'success', true, 'credits', v_balance, + 'billing_state', 'released', 'error_code', null + ); + insert into public.birth_time_rectification_action_receipts ( + case_id, action_id, user_id, action_kind, expected_turn_version, + result_turn_version, request_fingerprint, request, response + ) values ( + v_orphan.case_id, v_recovery_action_id, p_user_id, 'recover_fee', 0, + 0, v_recovery_fingerprint, + public.conversational_rectification_action_request( + 'recover_fee', p_user_id, v_orphan.case_id, 0, + v_recovery_action_id, v_recovery_fingerprint + ), + v_recovery_response + ); + end loop; + return v_balance; +end; +$$; + create or replace function public.reserve_conversational_rectification_fee( p_user_id uuid, p_case_id uuid, @@ -20,7 +132,6 @@ as $$ declare v_receipt public.birth_time_rectification_action_receipts%rowtype; v_billing public.birth_time_rectification_billing%rowtype; - v_orphan public.birth_time_rectification_billing%rowtype; v_receipt_action_id uuid := public.conversational_rectification_billing_receipt_action_id( p_action_id, @@ -28,9 +139,6 @@ declare ); v_balance integer; v_response jsonb; - v_recovery_action_id uuid; - v_recovery_fingerprint text; - v_recovery_response jsonb; v_fingerprint text := pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to( pg_catalog.jsonb_build_object( 'kind', 'reserve_fee', 'userId', p_user_id, 'caseId', p_case_id, @@ -105,91 +213,15 @@ begin raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; - select profile.credits into v_balance - from public.profiles profile - where profile.id = p_user_id - for update; - if not found then - raise exception 'conversational_billing_failed' using errcode = 'P0001'; - end if; - -- Reservation intentionally precedes the external first-turn calculation, -- so it cannot share a transaction with case creation. A process/device -- loss in that gap leaves no case for the account resume RPC to expose. -- A fresh account-scoped start deterministically releases every such orphan -- under the same account/profile locks before it attempts another debit. - for v_orphan in - select orphan_billing.* - from public.birth_time_rectification_billing orphan_billing - left join public.birth_time_rectification_cases orphan_case - on orphan_case.id = orphan_billing.case_id - where orphan_billing.user_id = p_user_id - and orphan_billing.case_id <> p_case_id - and orphan_billing.state = 'reserved' - and orphan_case.id is null - order by orphan_billing.reserved_at, orphan_billing.case_id - for update of orphan_billing - loop - v_recovery_action_id := - public.conversational_rectification_billing_receipt_action_id( - v_orphan.reserve_action_id, - 'recover_fee' - ); - v_recovery_fingerprint := pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to( - pg_catalog.jsonb_build_object( - 'kind', 'recover_fee', 'userId', p_user_id, 'caseId', v_orphan.case_id, - 'expectedVersion', 0, 'actionId', v_recovery_action_id, - 'reserveActionId', v_orphan.reserve_action_id - )::text, - 'UTF8' - )), 'hex'); - - update public.profiles profile - set credits = profile.credits + v_orphan.price, - updated_at = pg_catalog.now() - where profile.id = p_user_id - returning profile.credits into v_balance; - if not found then - raise exception 'conversational_billing_failed' using errcode = 'P0001'; - end if; - - insert into public.credit_transactions ( - user_id, transaction_type, amount, balance_after, request_id - ) values ( - p_user_id, 'refund', v_orphan.price, v_balance, - 'rectification:' || v_orphan.case_id::text - ); - - update public.birth_time_rectification_billing orphan_billing - set state = 'released', - release_action_id = v_recovery_action_id, - balance_after = v_balance, - released_at = pg_catalog.now(), - updated_at = pg_catalog.now() - where orphan_billing.case_id = v_orphan.case_id - and orphan_billing.user_id = p_user_id - and orphan_billing.state = 'reserved'; - if not found then - raise exception 'conversational_billing_failed' using errcode = 'P0001'; - end if; - - v_recovery_response := pg_catalog.jsonb_build_object( - 'success', true, 'credits', v_balance, - 'billing_state', 'released', 'error_code', null - ); - insert into public.birth_time_rectification_action_receipts ( - case_id, action_id, user_id, action_kind, expected_turn_version, - result_turn_version, request_fingerprint, request, response - ) values ( - v_orphan.case_id, v_recovery_action_id, p_user_id, 'recover_fee', 0, - 0, v_recovery_fingerprint, - public.conversational_rectification_action_request( - 'recover_fee', p_user_id, v_orphan.case_id, 0, - v_recovery_action_id, v_recovery_fingerprint - ), - v_recovery_response - ); - end loop; + v_balance := public.recover_conversational_rectification_orphan_reservations( + p_user_id, + p_case_id + ); select b.* into v_billing from public.birth_time_rectification_billing b @@ -607,6 +639,9 @@ $$; revoke all on function public.reserve_conversational_rectification_fee( uuid, uuid, bigint, uuid, integer ) from public, anon, authenticated; +revoke all on function public.recover_conversational_rectification_orphan_reservations( + uuid, uuid +) from public, anon, authenticated, service_role; revoke all on function public.complete_conversational_rectification_fee( uuid, uuid, bigint, uuid ) from public, anon, authenticated; diff --git a/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql b/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql index 0261a4b9..3ae055d6 100644 --- a/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql +++ b/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql @@ -169,6 +169,129 @@ begin end; $$; +create or replace function public.conversational_rectification_case_fits_load_limits( + p_user_id uuid, + p_case_id uuid, + p_status text, + p_turn_version bigint, + p_latest_turn jsonb, + p_private_candidate jsonb, + p_new_evidence jsonb, + p_validation_receipt jsonb +) +returns boolean +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_evidence_count bigint; + v_receipt_count bigint; + v_existing_evidence jsonb; + v_new_evidence jsonb; + v_validation_receipts jsonb; + v_projection jsonb; +begin + if public.conversational_rectification_valid_public_turn(p_latest_turn) is not true + or public.conversational_rectification_valid_private_candidate( + p_private_candidate + ) is not true + or public.conversational_rectification_valid_life_event_evidence_array( + p_new_evidence + ) is not true + or public.conversational_rectification_valid_validation_receipt( + p_validation_receipt + ) is not true then + return false; + end if; + + select pg_catalog.count(*) into v_evidence_count + from public.birth_time_rectification_event_evidence evidence + where evidence.case_id = p_case_id; + select pg_catalog.count(*) into v_receipt_count + from public.birth_time_rectification_turns turn + where turn.case_id = p_case_id; + if v_evidence_count + pg_catalog.jsonb_array_length(p_new_evidence) > 2000 + or v_receipt_count + 1 > 2000 then + return false; + end if; + + select coalesce(pg_catalog.jsonb_agg( + pg_catalog.jsonb_build_object( + 'id', evidence.id, + 'rawText', evidence.raw_text, + 'domain', evidence.domain, + 'eventSummary', evidence.event_summary, + 'dateValue', evidence.date_value, + 'datePrecision', evidence.date_precision, + 'extractionStatus', evidence.extraction_status, + 'scoreable', evidence.scoreable + ) order by evidence.created_at, evidence.id + ), '[]'::jsonb) into v_existing_evidence + from public.birth_time_rectification_event_evidence evidence + where evidence.case_id = p_case_id; + + select coalesce(pg_catalog.jsonb_agg( + pg_catalog.jsonb_build_object( + 'id', item.value ->> 'id', + 'rawText', item.value ->> 'rawText', + 'domain', item.value ->> 'domain', + 'eventSummary', item.value ->> 'eventSummary', + 'dateValue', case + when item.value -> 'dateValue' = 'null'::jsonb then null + else item.value ->> 'dateValue' + end, + 'datePrecision', item.value ->> 'datePrecision', + 'extractionStatus', item.value ->> 'extractionStatus', + 'scoreable', case + when item.value ? 'scoreable' then (item.value ->> 'scoreable')::boolean + else false + end + ) order by item.ordinality + ), '[]'::jsonb) into v_new_evidence + from pg_catalog.jsonb_array_elements(p_new_evidence) with ordinality item(value, ordinality); + + select coalesce(pg_catalog.jsonb_agg( + turn.output_validation_receipt order by turn.turn_version + ), '[]'::jsonb) || pg_catalog.jsonb_build_array(p_validation_receipt) + into v_validation_receipts + from public.birth_time_rectification_turns turn + where turn.case_id = p_case_id; + + select pg_catalog.jsonb_build_object( + 'case_id', c.id, + 'user_id', c.user_id, + 'status', p_status, + 'turn_version', p_turn_version, + 'revision_of_case_id', c.revision_of_case_id, + 'imported_from_case_id', c.imported_from_case_id, + 'baseline_active_time', case when c.baseline_active_time is null then null + else pg_catalog.to_char(c.baseline_active_time, 'HH24:MI') end, + 'pending_consultation_question', c.pending_consultation_question, + 'billing_state', billing.state, + 'latest_turn', p_latest_turn, + 'declared_birth_input', c.declared_birth_input, + 'private_candidate', p_private_candidate, + 'event_evidence', v_existing_evidence || v_new_evidence, + 'validation_receipts', v_validation_receipts + ) into v_projection + from public.birth_time_rectification_cases c + left join public.birth_time_rectification_billing billing + on billing.case_id = c.id and billing.user_id = c.user_id + where c.id = p_case_id + and c.user_id = p_user_id + and c.journey_protocol = 'conversational-evidence-v3'; + + return v_evidence_count + pg_catalog.jsonb_array_length(p_new_evidence) <= 2000 + and v_receipt_count + 1 <= 2000 + and v_projection is not null + and pg_catalog.octet_length(v_projection::text) <= 4194304; +exception when others then + return false; +end; +$$; + create or replace function public.create_conversational_rectification_case( p_user_id uuid, p_case_id uuid, @@ -435,11 +558,16 @@ begin end if; if v_case.status not in ('active', 'paused', 'confirming') - or pg_catalog.jsonb_typeof(p_turn) is distinct from 'object' - or pg_catalog.jsonb_typeof(p_evidence) is distinct from 'array' - or pg_catalog.jsonb_array_length(p_evidence) > 20 - or pg_catalog.jsonb_typeof(p_validation_receipt) is distinct from 'object' - or pg_catalog.jsonb_typeof(p_private_candidate) is distinct from 'object' + or public.conversational_rectification_valid_public_turn(p_turn) is not true + or public.conversational_rectification_valid_life_event_evidence_array( + p_evidence + ) is not true + or public.conversational_rectification_valid_validation_receipt( + p_validation_receipt + ) is not true + or public.conversational_rectification_valid_private_candidate( + p_private_candidate + ) is not true or p_turn ->> 'caseId' is distinct from p_case_id::text or p_turn ->> 'journeyProtocol' is distinct from 'conversational-evidence-v3' or (p_turn ->> 'turnVersion')::bigint is distinct from p_expected_version + 1 @@ -453,6 +581,12 @@ begin or pg_catalog.jsonb_path_exists(p_turn, '$.**.systemPrompt') then raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; + if public.conversational_rectification_case_fits_load_limits( + p_user_id, p_case_id, p_turn ->> 'status', p_expected_version + 1, + p_turn, p_private_candidate, p_evidence, p_validation_receipt + ) is not true then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; insert into public.birth_time_rectification_turns ( id, case_id, turn_version, narrative, candidate, technical_receipt, @@ -473,9 +607,12 @@ begin select (item ->> 'id')::uuid, p_case_id, v_turn_id, item ->> 'rawText', item ->> 'domain', item ->> 'eventSummary', - nullif(item ->> 'dateValue', ''), item ->> 'datePrecision', + case when item -> 'dateValue' = 'null'::jsonb then null + else item ->> 'dateValue' end, + item ->> 'datePrecision', item ->> 'extractionStatus', - coalesce((item ->> 'scoreable')::boolean, false) + case when item ? 'scoreable' then (item ->> 'scoreable')::boolean + else false end from pg_catalog.jsonb_array_elements(p_evidence) item; update public.birth_time_rectification_cases @@ -576,8 +713,10 @@ begin raise exception 'conversational_billing_failed' using errcode = 'P0001'; end if; if v_case.status not in ('active', 'confirming') - or pg_catalog.jsonb_typeof(p_turn) is distinct from 'object' - or pg_catalog.jsonb_typeof(p_validation_receipt) is distinct from 'object' + or public.conversational_rectification_valid_public_turn(p_turn) is not true + or public.conversational_rectification_valid_validation_receipt( + p_validation_receipt + ) is not true or p_turn ->> 'caseId' is distinct from p_case_id::text or p_turn ->> 'journeyProtocol' is distinct from 'conversational-evidence-v3' or (p_turn ->> 'turnVersion')::bigint is distinct from p_expected_version + 1 @@ -591,6 +730,12 @@ begin or pg_catalog.jsonb_path_exists(p_turn, '$.**.systemPrompt') then raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; + if public.conversational_rectification_case_fits_load_limits( + p_user_id, p_case_id, 'paused', p_expected_version + 1, + p_turn, v_case.candidate_result, '[]'::jsonb, p_validation_receipt + ) is not true then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; insert into public.birth_time_rectification_turns ( case_id, turn_version, narrative, candidate, technical_receipt, @@ -693,8 +838,10 @@ begin raise exception 'conversational_billing_failed' using errcode = 'P0001'; end if; if v_case.status not in ('active', 'paused', 'confirming') - or pg_catalog.jsonb_typeof(p_turn) is distinct from 'object' - or pg_catalog.jsonb_typeof(p_validation_receipt) is distinct from 'object' + or public.conversational_rectification_valid_public_turn(p_turn) is not true + or public.conversational_rectification_valid_validation_receipt( + p_validation_receipt + ) is not true or p_turn ->> 'caseId' is distinct from p_case_id::text or p_turn ->> 'journeyProtocol' is distinct from 'conversational-evidence-v3' or (p_turn ->> 'turnVersion')::bigint is distinct from p_expected_version + 1 @@ -708,6 +855,12 @@ begin or pg_catalog.jsonb_path_exists(p_turn, '$.**.systemPrompt') then raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; + if public.conversational_rectification_case_fits_load_limits( + p_user_id, p_case_id, 'abandoned', p_expected_version + 1, + p_turn, v_case.candidate_result, '[]'::jsonb, p_validation_receipt + ) is not true then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; insert into public.birth_time_rectification_turns ( case_id, turn_version, narrative, candidate, technical_receipt, @@ -848,8 +1001,10 @@ begin or v_current_turn.candidate ->> 'representativeTime' is distinct from v_time or v_current_turn.technical_receipt ->> 'calculationVersion' is distinct from p_calculation_version - or pg_catalog.jsonb_typeof(p_turn) is distinct from 'object' - or pg_catalog.jsonb_typeof(p_validation_receipt) is distinct from 'object' + or public.conversational_rectification_valid_public_turn(p_turn) is not true + or public.conversational_rectification_valid_validation_receipt( + p_validation_receipt + ) is not true or p_turn ->> 'caseId' is distinct from p_case_id::text or p_turn ->> 'journeyProtocol' is distinct from 'conversational-evidence-v3' or (p_turn ->> 'turnVersion')::bigint is distinct from p_expected_version + 1 @@ -867,6 +1022,12 @@ begin or pg_catalog.jsonb_path_exists(p_turn, '$.**.systemPrompt') then raise exception 'conversational_candidate_changed' using errcode = 'P0001'; end if; + if public.conversational_rectification_case_fits_load_limits( + p_user_id, p_case_id, 'completed', p_expected_version + 1, + p_turn, v_case.candidate_result, '[]'::jsonb, p_validation_receipt + ) is not true then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; insert into public.birth_time_rectification_turns ( case_id, turn_version, narrative, candidate, technical_receipt, @@ -1036,6 +1197,11 @@ begin if not found then raise exception 'conversational_case_not_found' using errcode = 'P0001'; end if; + v_profile.credits := + public.recover_conversational_rectification_orphan_reservations( + p_user_id, + null::uuid + ); -- Preserve an explicit nullable clue while omitting inapplicable time-mode -- keys. This yields the same source-discriminated representation accepted -- by new starts and keeps legacy import round-trippable across devices. @@ -1168,6 +1334,9 @@ revoke all on function public.guard_imported_rectification_history() from public, anon, authenticated, service_role; revoke all on function public.conversational_rectification_case_projection(uuid, uuid) from public, anon, authenticated, service_role; +revoke all on function public.conversational_rectification_case_fits_load_limits( + uuid, uuid, text, bigint, jsonb, jsonb, jsonb, jsonb +) from public, anon, authenticated, service_role; revoke all on function public.load_conversational_rectification_case(uuid, uuid) from public, anon, authenticated;