fix: harden conversational persistence boundaries

This commit is contained in:
Jesse_Chen
2026-07-20 23:11:01 +08:00
parent d6b3ecdb31
commit c883c401aa
5 changed files with 531 additions and 102 deletions
@@ -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",
@@ -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<object> = new Set<object>(),
): 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);
@@ -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;
@@ -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;
@@ -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;