fix: harden conversational persistence boundaries
This commit is contained in:
@@ -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);
|
||||
|
||||
+191
-3
@@ -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;
|
||||
|
||||
+119
-84
@@ -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;
|
||||
|
||||
+182
-13
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user