diff --git a/frontend/src/lib/conversational-rectification/json-bounds.ts b/frontend/src/lib/conversational-rectification/json-bounds.ts index 5af98757..edafbd0e 100644 --- a/frontend/src/lib/conversational-rectification/json-bounds.ts +++ b/frontend/src/lib/conversational-rectification/json-bounds.ts @@ -1,16 +1,27 @@ 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_DECIMAL_PLACES = 6; +const POSTGRES_JSON_MIN_FRACTION = 0.000001; const POSTGRES_JSON_MAX_FRACTION_MAGNITUDE = 1_000_000; +function canonicalJsonDecimalPlaces(value: number): number | null { + const serialized = JSON.stringify(value); + const match = /^-?\d+(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(serialized); + if (!match) return null; + const fractionLength = match[1]?.length ?? 0; + const exponent = Number(match[2] ?? 0); + return Math.max(0, fractionLength - exponent); +} + function postgresStableJsonNumber(value: number): boolean { if (!Number.isFinite(value)) return false; if (Number.isSafeInteger(value)) return true; const magnitude = Math.abs(value); + const decimalPlaces = canonicalJsonDecimalPlaces(value); return magnitude >= POSTGRES_JSON_MIN_FRACTION && magnitude <= POSTGRES_JSON_MAX_FRACTION_MAGNITUDE - && Number.isSafeInteger(value * POSTGRES_JSON_DECIMAL_SCALE); + && decimalPlaces !== null + && decimalPlaces <= POSTGRES_JSON_MAX_DECIMAL_PLACES; } function postgresJsonNumbersAreStable( diff --git a/frontend/src/lib/conversational-rectification/persistence-contracts.ts b/frontend/src/lib/conversational-rectification/persistence-contracts.ts index 8c2eb6ad..33d35b7e 100644 --- a/frontend/src/lib/conversational-rectification/persistence-contracts.ts +++ b/frontend/src/lib/conversational-rectification/persistence-contracts.ts @@ -37,7 +37,7 @@ const birthplaceSchema = boundedJson(z.object({ } }), 4_096); -const clueSchema = z.string().max(240).nullable(); +const clueSchema = boundedText(240).nullable(); const commonBirthFields = { birthDate: birthDateSchema, birthTimeClue: clueSchema, diff --git a/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql b/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql index 44703c64..0a109cbb 100644 --- a/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql +++ b/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql @@ -51,6 +51,9 @@ strict set search_path = '' as $$ begin + if p_value !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' then + return false; + end if; perform p_value::uuid; return true; exception when others then @@ -58,6 +61,26 @@ exception when others then end; $$; +create or replace function public.conversational_rectification_text_utf16_length( + p_value text +) +returns integer +language sql +immutable +strict +set search_path = '' +as $$ + select pg_catalog.char_length(p_value) + ( + select pg_catalog.count(*)::integer + from pg_catalog.generate_series( + 1, pg_catalog.char_length(p_value) + ) character_positions(character_index) + where pg_catalog.octet_length( + pg_catalog.substr(p_value, character_index, 1) + ) = 4 + ); +$$; + create or replace function public.conversational_rectification_text_is_nonblank( p_value text ) @@ -67,10 +90,10 @@ immutable strict set search_path = '' as $$ - select pg_catalog.char_length(pg_catalog.btrim( + select 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( @@ -130,9 +153,11 @@ as $$ and not exists ( select 1 from pg_catalog.jsonb_array_elements(p_value) item - where pg_catalog.jsonb_typeof(item) <> 'string' - or pg_catalog.char_length(item #>> '{}') not between 1 and p_max_characters - or pg_catalog.char_length(pg_catalog.btrim(item #>> '{}')) = 0 + where pg_catalog.jsonb_typeof(item) is distinct from 'string' + or public.conversational_rectification_text_utf16_length( + item #>> '{}' + ) not between 1 and p_max_characters + or public.conversational_rectification_text_is_nonblank(item #>> '{}') is not true ); $$; @@ -154,6 +179,7 @@ begin array['status', 'representativeTime', 'rangeStart', 'rangeEnd']::text[] ) or not (p_value ?& array['status', 'representativeTime', 'rangeStart', 'rangeEnd']::text[]) + or pg_catalog.jsonb_typeof(p_value -> 'status') is distinct from 'string' or p_value ->> 'status' not in ( 'declared', 'pending_validation', 'ready_for_confirmation', 'confirmed' ) then @@ -198,8 +224,12 @@ as $$ 'candidateDifferenceRefs' ]::text[] and pg_catalog.jsonb_typeof(p_value -> 'calculationVersion') = 'string' - and pg_catalog.char_length(p_value ->> 'calculationVersion') between 1 and 80 - and pg_catalog.char_length(pg_catalog.btrim(p_value ->> 'calculationVersion')) > 0 + and public.conversational_rectification_text_utf16_length( + p_value ->> 'calculationVersion' + ) between 1 and 80 + and public.conversational_rectification_text_is_nonblank( + p_value ->> 'calculationVersion' + ) and public.conversational_rectification_text_array_is_bounded( p_value -> 'stableLayers', 20, 80, 4096 ) @@ -232,10 +262,15 @@ as $$ and pg_catalog.jsonb_array_length(p_value -> 'domains') between 2 and 4 and not exists ( select 1 - from pg_catalog.jsonb_array_elements_text(p_value -> 'domains') domain - where domain not in ('career', 'education', 'relocation', 'relationship', 'family', 'other') + from pg_catalog.jsonb_array_elements(p_value -> 'domains') domain + where pg_catalog.jsonb_typeof(domain) is distinct from 'string' + or domain #>> '{}' not in ( + 'career', 'education', 'relocation', 'relationship', 'family', 'other' + ) ) + and pg_catalog.jsonb_typeof(p_value -> 'datePrecision') = 'string' and p_value ->> 'datePrecision' in ('month_preferred', 'year_accepted') + and pg_catalog.jsonb_typeof(p_value -> 'freeTextAllowed') = 'boolean' and p_value -> 'freeTextAllowed' = 'true'::jsonb; $$; @@ -264,11 +299,19 @@ as $$ or pg_catalog.jsonb_typeof(item -> 'id') <> 'string' or not public.conversational_rectification_valid_uuid_text(item ->> 'id') or pg_catalog.jsonb_typeof(item -> 'summary') <> 'string' - or pg_catalog.char_length(item ->> 'summary') not between 1 and 1000 - or pg_catalog.char_length(pg_catalog.btrim(item ->> 'summary')) = 0 + or public.conversational_rectification_text_utf16_length( + item ->> 'summary' + ) not between 1 and 1000 + or public.conversational_rectification_text_is_nonblank( + item ->> 'summary' + ) is not true or pg_catalog.jsonb_typeof(item -> 'dateLabel') <> 'string' - or pg_catalog.char_length(item ->> 'dateLabel') not between 1 and 80 - or pg_catalog.char_length(pg_catalog.btrim(item ->> 'dateLabel')) = 0 + or public.conversational_rectification_text_utf16_length( + item ->> 'dateLabel' + ) not between 1 and 80 + or public.conversational_rectification_text_is_nonblank( + item ->> 'dateLabel' + ) is not true ); $$; @@ -285,8 +328,9 @@ as $$ and pg_catalog.jsonb_array_length(p_value) <= 5 and not exists ( select 1 - from pg_catalog.jsonb_array_elements_text(p_value) action - where action not in ( + from pg_catalog.jsonb_array_elements(p_value) action + where pg_catalog.jsonb_typeof(action) is distinct from 'string' + or action #>> '{}' not in ( 'answer', 'pause', 'abandon', 'confirm', 'continue_original_question' ) ); @@ -314,19 +358,32 @@ begin ) or not (p_value ?& array['modelId', 'schemaValidated']::text[]) or pg_catalog.jsonb_typeof(p_value -> 'modelId') is distinct from 'string' - or pg_catalog.char_length(p_value ->> 'modelId') not between 1 and 120 - or pg_catalog.char_length(pg_catalog.btrim(p_value ->> 'modelId')) = 0 + or public.conversational_rectification_text_utf16_length( + p_value ->> 'modelId' + ) not between 1 and 120 + or public.conversational_rectification_text_is_nonblank( + p_value ->> 'modelId' + ) is not true or pg_catalog.jsonb_typeof(p_value -> 'schemaValidated') is distinct from 'boolean' then return false; end if; if p_value ? 'validatorVersion' and ( pg_catalog.jsonb_typeof(p_value -> 'validatorVersion') is distinct from 'string' - or pg_catalog.char_length(p_value ->> 'validatorVersion') not between 1 and 80 - or pg_catalog.char_length(pg_catalog.btrim(p_value ->> 'validatorVersion')) = 0 + or public.conversational_rectification_text_utf16_length( + p_value ->> 'validatorVersion' + ) not between 1 and 80 + or public.conversational_rectification_text_is_nonblank( + p_value ->> 'validatorVersion' + ) is not true ) then return false; end if; if p_value ? 'validatedAt' and ( pg_catalog.jsonb_typeof(p_value -> 'validatedAt') is distinct from 'string' - or pg_catalog.char_length(p_value ->> 'validatedAt') not between 20 and 40 + or public.conversational_rectification_text_utf16_length( + p_value ->> 'validatedAt' + ) not between 20 and 40 + or public.conversational_rectification_text_is_nonblank( + p_value ->> 'validatedAt' + ) is not true or p_value ->> 'validatedAt' !~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$' or (p_value ->> 'validatedAt')::timestamptz is null @@ -377,7 +434,9 @@ begin 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_utf16_length( + p_value ->> 'rawText' + ) not between 1 and 4000 or public.conversational_rectification_text_is_nonblank( p_value ->> 'rawText' ) is not true @@ -386,7 +445,9 @@ begin '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_utf16_length( + p_value ->> 'eventSummary' + ) not between 1 and 1000 or public.conversational_rectification_text_is_nonblank( p_value ->> 'eventSummary' ) is not true @@ -395,7 +456,9 @@ begin 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_utf16_length( + p_value ->> 'dateValue' + ) not between 1 and 80 or public.conversational_rectification_text_is_nonblank( p_value ->> 'dateValue' ) is not true @@ -473,8 +536,12 @@ begin ) or not (p_value ? 'calculationVersion') or pg_catalog.jsonb_typeof(p_value -> 'calculationVersion') is distinct from 'string' - or pg_catalog.char_length(p_value ->> 'calculationVersion') not between 1 and 80 - or pg_catalog.char_length(pg_catalog.btrim(p_value ->> 'calculationVersion')) = 0 then + or public.conversational_rectification_text_utf16_length( + p_value ->> 'calculationVersion' + ) not between 1 and 80 + or public.conversational_rectification_text_is_nonblank( + p_value ->> 'calculationVersion' + ) is not true then return false; end if; if p_value ? 'resultId' and p_value -> 'resultId' <> 'null'::jsonb and ( @@ -522,8 +589,11 @@ begin pg_catalog.jsonb_typeof(p_value -> 'suggestedDomains') is distinct from 'array' or pg_catalog.jsonb_array_length(p_value -> 'suggestedDomains') > 6 or exists ( - select 1 from pg_catalog.jsonb_array_elements_text(p_value -> 'suggestedDomains') domain - where domain not in ('career', 'education', 'relocation', 'relationship', 'family', 'other') + select 1 from pg_catalog.jsonb_array_elements(p_value -> 'suggestedDomains') domain + where pg_catalog.jsonb_typeof(domain) is distinct from 'string' + or domain #>> '{}' not in ( + 'career', 'education', 'relocation', 'relationship', 'family', 'other' + ) ) ) then return false; end if; if p_value ? 'scoredHistoricalEvidence' then @@ -564,8 +634,12 @@ begin ) or not (v_item ?& array['label', 'startDate', 'endDate', 'scoreable']::text[]) or pg_catalog.jsonb_typeof(v_item -> 'label') is distinct from 'string' - or pg_catalog.char_length(v_item ->> 'label') not between 1 and 240 - or pg_catalog.char_length(pg_catalog.btrim(v_item ->> 'label')) = 0 + or public.conversational_rectification_text_utf16_length( + v_item ->> 'label' + ) not between 1 and 240 + or public.conversational_rectification_text_is_nonblank( + v_item ->> 'label' + ) is not true or pg_catalog.jsonb_typeof(v_item -> 'startDate') is distinct from 'string' or pg_catalog.jsonb_typeof(v_item -> 'endDate') is distinct from 'string' or not public.conversational_rectification_valid_date_text(v_item ->> 'startDate') @@ -627,9 +701,17 @@ begin or pg_catalog.jsonb_typeof(p_value -> 'birthDate') is distinct from 'string' or not public.conversational_rectification_valid_date_text(p_value ->> 'birthDate') or pg_catalog.jsonb_typeof(p_value -> 'source') is distinct from 'string' + or public.conversational_rectification_text_is_nonblank( + p_value ->> 'source' + ) is not true or p_value -> 'birthTimeClue' <> 'null'::jsonb and ( pg_catalog.jsonb_typeof(p_value -> 'birthTimeClue') is distinct from 'string' - or pg_catalog.char_length(p_value ->> 'birthTimeClue') > 240 + or public.conversational_rectification_text_utf16_length( + p_value ->> 'birthTimeClue' + ) not between 1 and 240 + or public.conversational_rectification_text_is_nonblank( + p_value ->> 'birthTimeClue' + ) is not true ) then return false; end if; v_place := p_value -> 'birthplace'; @@ -650,9 +732,13 @@ begin foreach v_key in array array['city', 'provinceCode', 'cityCode', 'districtCode']::text[] loop if v_place ? v_key and ( pg_catalog.jsonb_typeof(v_place -> v_key) is distinct from 'string' - or pg_catalog.char_length(v_place ->> v_key) not between 1 and + or public.conversational_rectification_text_utf16_length( + v_place ->> v_key + ) not between 1 and case when v_key = 'city' then 120 else 80 end - or pg_catalog.char_length(pg_catalog.btrim(v_place ->> v_key)) = 0 + or public.conversational_rectification_text_is_nonblank( + v_place ->> v_key + ) is not true ) then return false; end if; end loop; if v_place ? 'countryCode' and ( @@ -747,14 +833,21 @@ as $$ 'candidate', 'technicalReceipt', 'evidenceRequest', 'evidenceRecap', 'actions', 'pendingConsultationQuestion' ]::text[] + and pg_catalog.jsonb_typeof(p_value -> 'caseId') = 'string' and public.conversational_rectification_valid_uuid_text(p_value ->> 'caseId') + and pg_catalog.jsonb_typeof(p_value -> 'journeyProtocol') = 'string' and p_value ->> 'journeyProtocol' = 'conversational-evidence-v3' + and pg_catalog.jsonb_typeof(p_value -> 'status') = 'string' and p_value ->> 'status' in ('active', 'paused', 'confirming', 'completed', 'abandoned') and pg_catalog.jsonb_typeof(p_value -> 'turnVersion') = 'number' and p_value ->> 'turnVersion' ~ '^[0-9]+$' and pg_catalog.jsonb_typeof(p_value -> 'narrative') = 'string' - and pg_catalog.char_length(p_value ->> 'narrative') between 1 and 12000 - and pg_catalog.char_length(pg_catalog.btrim(p_value ->> 'narrative')) > 0 + and public.conversational_rectification_text_utf16_length( + p_value ->> 'narrative' + ) between 1 and 12000 + and public.conversational_rectification_text_is_nonblank( + p_value ->> 'narrative' + ) and public.conversational_rectification_valid_candidate(p_value -> 'candidate') and public.conversational_rectification_valid_technical_receipt(p_value -> 'technicalReceipt') and ( @@ -767,10 +860,12 @@ as $$ p_value -> 'pendingConsultationQuestion' = 'null'::jsonb or ( pg_catalog.jsonb_typeof(p_value -> 'pendingConsultationQuestion') = 'string' - and pg_catalog.char_length(p_value ->> 'pendingConsultationQuestion') between 1 and 500 - and pg_catalog.char_length(pg_catalog.btrim( + and public.conversational_rectification_text_utf16_length( p_value ->> 'pendingConsultationQuestion' - )) > 0 + ) between 1 and 500 + and public.conversational_rectification_text_is_nonblank( + p_value ->> 'pendingConsultationQuestion' + ) ) ); $$; @@ -864,14 +959,23 @@ begin ) and ( p_value -> 'billing_state' = 'null'::jsonb - or p_value ->> 'billing_state' in ('reserved', 'charged', 'released', 'migration_waived') + or ( + pg_catalog.jsonb_typeof(p_value -> 'billing_state') = 'string' + and p_value ->> 'billing_state' in ( + 'reserved', 'charged', 'released', 'migration_waived' + ) + ) ) and ( p_value -> 'error_code' = 'null'::jsonb or ( pg_catalog.jsonb_typeof(p_value -> 'error_code') = 'string' - and pg_catalog.char_length(p_value ->> 'error_code') between 1 and 80 - and pg_catalog.char_length(pg_catalog.btrim(p_value ->> 'error_code')) > 0 + and public.conversational_rectification_text_utf16_length( + p_value ->> 'error_code' + ) between 1 and 80 + and public.conversational_rectification_text_is_nonblank( + p_value ->> 'error_code' + ) ) ), false); end if; @@ -916,10 +1020,12 @@ begin ))) and (p_value -> 'pending_consultation_question' = 'null'::jsonb or (pg_catalog.jsonb_typeof(p_value -> 'pending_consultation_question') = 'string' - and pg_catalog.char_length(p_value ->> 'pending_consultation_question') between 1 and 500 - and pg_catalog.char_length(pg_catalog.btrim( + and public.conversational_rectification_text_utf16_length( p_value ->> 'pending_consultation_question' - )) > 0)) + ) between 1 and 500 + and public.conversational_rectification_text_is_nonblank( + p_value ->> 'pending_consultation_question' + ))) and (p_value -> 'billing_state' = 'null'::jsonb or (pg_catalog.jsonb_typeof(p_value -> 'billing_state') = 'string' and p_value ->> 'billing_state' in ( @@ -959,7 +1065,14 @@ alter table public.birth_time_rectification_cases add constraint birth_time_rectification_cases_pending_question_check check ( pending_consultation_question is null - or char_length(pending_consultation_question) between 1 and 500 + or ( + public.conversational_rectification_text_utf16_length( + pending_consultation_question + ) between 1 and 500 + and public.conversational_rectification_text_is_nonblank( + pending_consultation_question + ) + ) ), add constraint birth_time_rectification_cases_declared_birth_input_check check ( @@ -1019,8 +1132,8 @@ create table if not exists public.birth_time_rectification_turns ( references public.birth_time_rectification_cases(id) on delete cascade, turn_version bigint not null check (turn_version >= 0), narrative text not null check ( - char_length(narrative) between 1 and 12000 - and char_length(btrim(narrative)) > 0 + public.conversational_rectification_text_utf16_length(narrative) between 1 and 12000 + and public.conversational_rectification_text_is_nonblank(narrative) ), candidate jsonb not null check ( public.conversational_rectification_valid_candidate(candidate) is true @@ -1065,20 +1178,20 @@ create table if not exists public.birth_time_rectification_event_evidence ( case_id uuid not null, source_turn_id uuid not null, raw_text text not null check ( - char_length(raw_text) between 1 and 4000 + public.conversational_rectification_text_utf16_length(raw_text) between 1 and 4000 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 + public.conversational_rectification_text_utf16_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 + public.conversational_rectification_text_utf16_length(date_value) between 1 and 80 and public.conversational_rectification_text_is_nonblank(date_value) ) ), @@ -1191,6 +1304,8 @@ revoke all on function public.conversational_rectification_billing_receipt_actio ) 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_utf16_length(text) + 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) @@ -1199,6 +1314,8 @@ revoke all on function public.conversational_rectification_valid_life_event_evid 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_utf16_length(text) + 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) diff --git a/frontend/tests/conversational-rectification-store.test.ts b/frontend/tests/conversational-rectification-store.test.ts index 78af6be0..20c48e6e 100644 --- a/frontend/tests/conversational-rectification-store.test.ts +++ b/frontend/tests/conversational-rectification-store.test.ts @@ -478,10 +478,11 @@ test("durable JSON uses PostgreSQL-stable numeric vectors recursively", () => { const stableVector = { zero: 0, minFraction: 0.000001, + ieeeRoundingBoundary: 1.000001, decimal: 0.123456, maxSafe: 9_007_199_254_740_991, }; - assert.equal(postgresJsonbTextBytes(stableVector), 86); + assert.equal(postgresJsonbTextBytes(stableVector), 120); assert.equal(declaredBirthInputSchema.safeParse({ ...storedRow.declared_birth_input, birthplace: { @@ -492,7 +493,8 @@ test("durable JSON uses PostgreSQL-stable numeric vectors recursively", () => { }, }).success, true); - for (const candidateWeights of [[1e-100], [0.1234567]]) { + assert.ok(Number.isSafeInteger(1.000001 * 1_000_000) === false); + for (const candidateWeights of [[1e-7], [1e-100], [0.0000012], [0.1234567]]) { assert.equal(privateCandidateSchema.safeParse({ resultId, calculationVersion: "rectification-v3.1", @@ -520,6 +522,87 @@ test("durable JSON uses PostgreSQL-stable numeric vectors recursively", () => { assert.equal(postgresJsonbTextBytes({ nested: [{ score: 1e-100 }] }), Number.POSITIVE_INFINITY); }); +test("durable text uses ECMAScript nonblank and UTF-16 maximum semantics", () => { + const unicodeWhitespace = "\u00a0\u2007\ufeff"; + const validAstral80 = "😀".repeat(40); + const invalidAstral82 = "😀".repeat(41); + + assert.equal(validationReceiptSchema.safeParse({ + modelId: "😀".repeat(60), + schemaValidated: true, + }).success, true); + assert.equal(validationReceiptSchema.safeParse({ + modelId: "😀".repeat(61), + schemaValidated: true, + }).success, false); + assert.equal(privateCandidateSchema.safeParse({ + resultId, + calculationVersion: validAstral80, + }).success, true); + assert.equal(privateCandidateSchema.safeParse({ + resultId, + calculationVersion: invalidAstral82, + }).success, false); + + for (const invalid of [ + { ...firstTurn, narrative: unicodeWhitespace }, + { + ...firstTurn, + technicalReceipt: { ...firstTurn.technicalReceipt, calculationVersion: unicodeWhitespace }, + }, + { + ...firstTurn, + technicalReceipt: { ...firstTurn.technicalReceipt, stableLayers: [unicodeWhitespace] }, + }, + { + ...firstTurn, + evidenceRecap: [{ id: resultId, summary: unicodeWhitespace, dateLabel: "2020-01" }], + }, + { ...firstTurn, pendingConsultationQuestion: unicodeWhitespace }, + ]) { + assert.equal(conversationalRectificationTurnSchema.safeParse(invalid).success, false); + } + + for (const invalid of [ + { modelId: unicodeWhitespace, schemaValidated: true }, + { modelId: "model", schemaValidated: true, validatorVersion: unicodeWhitespace }, + { modelId: "model", schemaValidated: true, issues: [unicodeWhitespace] }, + ]) { + assert.equal(validationReceiptSchema.safeParse(invalid).success, false); + } + + for (const invalid of [ + { resultId, calculationVersion: unicodeWhitespace }, + { resultId, calculationVersion: "v1", candidateModelRefs: [unicodeWhitespace] }, + { + resultId, + calculationVersion: "v1", + futureWindows: [{ + label: unicodeWhitespace, + startDate: "2020-01-01", + endDate: "2020-01-02", + scoreable: false, + }], + }, + { + resultId, + calculationVersion: "v1", + workingState: { phase: "initial", iteration: 0, notes: [unicodeWhitespace] }, + }, + ]) { + assert.equal(privateCandidateSchema.safeParse(invalid).success, false); + } + + assert.equal(declaredBirthInputSchema.safeParse({ + ...storedRow.declared_birth_input, + birthTimeClue: unicodeWhitespace, + }).success, false); + assert.equal(declaredBirthInputSchema.safeParse({ + ...storedRow.declared_birth_input, + birthplace: { ...storedRow.declared_birth_input.birthplace, city: unicodeWhitespace }, + }).success, false); +}); + test("evidence recap enforces the SQL-matched aggregate byte limit", () => { const evidenceRecap = Array.from({ length: 9 }, (_, index) => ({ id: `00000000-0000-4000-8000-${(700 + index).toString().padStart(12, "0")}`, @@ -630,6 +713,29 @@ test("durable private and receipt schemas accept boundaries and reject oversize }).success, false); }); +test("durable UUID text is canonical hyphenated syntax and case-insensitive", () => { + const canonical = "a9890e09-d535-46f0-9a36-86017515a5a1"; + const compact = canonical.replaceAll("-", ""); + const braced = `{${canonical}}`; + + for (const validResultId of [canonical, canonical.toUpperCase()]) { + assert.equal(privateCandidateSchema.safeParse({ + resultId: validResultId, + calculationVersion: "v1", + }).success, true); + } + for (const invalidResultId of [compact, braced]) { + assert.equal(privateCandidateSchema.safeParse({ + resultId: invalidResultId, + calculationVersion: "v1", + }).success, false); + assert.equal(conversationalRectificationTurnSchema.safeParse({ + ...firstTurn, + evidenceRecap: [{ id: invalidResultId, summary: "summary", dateLabel: "2020-01" }], + }).success, false); + } +}); + test("public turn JSON fields reject field and byte boundary violations", () => { assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({ ...storedRow, diff --git a/tests/test_conversational_rectification_contract.py b/tests/test_conversational_rectification_contract.py index b7bde3c7..1fa68960 100644 --- a/tests/test_conversational_rectification_contract.py +++ b/tests/test_conversational_rectification_contract.py @@ -47,8 +47,8 @@ def test_v3_schema_is_account_scoped_bounded_and_service_role_only() -> None: ) assert "primary key (case_id, turn_version)" in sql - assert "char_length(narrative) between 1 and 12000" in sql - assert "char_length(raw_text) between 1 and 4000" in sql + assert "conversational_rectification_text_utf16_length(narrative) between 1 and 12000" in sql + assert "conversational_rectification_text_utf16_length(raw_text) between 1 and 4000" in sql assert "date_precision in ('day', 'month', 'year', 'range', 'unknown')" in sql assert "extraction_status in ('clear', 'needs_clarification', 'corrected')" in sql assert "primary key (case_id, action_id)" in sql @@ -310,6 +310,13 @@ def test_durable_json_numbers_and_evidence_recap_share_postgres_bounds() -> None sql = _normalized(SCHEMA) assert "create or replace function public.conversational_rectification_numbers_are_stable" in sql + numeric = _function(sql, "conversational_rectification_numbers_are_stable") + for invariant in ( + "abs(v_number) <= 9007199254740991", + "abs(v_number) between 0.000001 and 1000000", + "v_number = pg_catalog.trunc(v_number, 6)", + ): + assert invariant in numeric for validator in ( "conversational_rectification_valid_declared_birth_input", "conversational_rectification_valid_private_candidate", @@ -322,6 +329,71 @@ def test_durable_json_numbers_and_evidence_recap_share_postgres_bounds() -> None assert "octet_length(p_value::text) <= 24576" in recap +def test_sql_json_strings_and_enum_arrays_reject_null_and_non_string_values() -> None: + sql = _normalized(SCHEMA) + assert "jsonb_array_elements_text" not in sql + + candidate = _function(sql, "conversational_rectification_valid_candidate") + assert "jsonb_typeof(p_value -> 'status') is distinct from 'string'" in candidate + + request = _function(sql, "conversational_rectification_valid_evidence_request") + assert "jsonb_typeof(p_value -> 'dateprecision') = 'string'" in request + assert "jsonb_array_elements(p_value -> 'domains')" in request + assert "jsonb_typeof(domain) is distinct from 'string'" in request + + actions = _function(sql, "conversational_rectification_valid_actions") + assert "jsonb_array_elements(p_value)" in actions + assert "jsonb_typeof(action) is distinct from 'string'" in actions + + private = _function(sql, "conversational_rectification_valid_private_candidate") + assert "jsonb_array_elements(p_value -> 'suggesteddomains')" in private + assert "jsonb_typeof(domain) is distinct from 'string'" in private + + public_turn = _function(sql, "conversational_rectification_valid_public_turn") + for key in ("caseid", "journeyprotocol", "status"): + assert f"jsonb_typeof(p_value -> '{key}') = 'string'" in public_turn + + +def test_sql_uuid_text_matches_zod_canonical_hyphenated_syntax_at_all_json_boundaries() -> None: + sql = _normalized(SCHEMA) + uuid = _function(sql, "conversational_rectification_valid_uuid_text") + assert "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" in uuid + assert "perform p_value::uuid" in uuid + + for validator in ( + "conversational_rectification_valid_evidence_recap", + "conversational_rectification_valid_life_event_evidence", + "conversational_rectification_valid_private_candidate", + "conversational_rectification_valid_public_turn", + "conversational_rectification_valid_action_request", + "conversational_rectification_valid_action_response", + ): + assert "conversational_rectification_valid_uuid_text" in _function(sql, validator) + + +def test_sql_nonblank_and_maximum_text_rules_match_ecmascript_and_utf16() -> None: + sql = _normalized(SCHEMA) + assert "create or replace function public.conversational_rectification_text_utf16_length" in sql + + for validator, minimum_nonblank_calls, minimum_utf16_calls in ( + ("conversational_rectification_text_array_is_bounded", 1, 1), + ("conversational_rectification_valid_technical_receipt", 1, 1), + ("conversational_rectification_valid_evidence_recap", 2, 2), + ("conversational_rectification_valid_validation_receipt", 2, 3), + ("conversational_rectification_valid_life_event_evidence", 3, 3), + ("conversational_rectification_valid_private_candidate", 2, 2), + ("conversational_rectification_valid_declared_birth_input", 2, 2), + ("conversational_rectification_valid_public_turn", 2, 2), + ("conversational_rectification_valid_action_response", 2, 2), + ): + body = _function(sql, validator) + assert body.count("conversational_rectification_text_is_nonblank") >= minimum_nonblank_calls + assert body.count("conversational_rectification_text_utf16_length") >= minimum_utf16_calls + + for durable_column in ("narrative", "raw_text", "event_summary", "date_value"): + assert f"conversational_rectification_text_utf16_length({durable_column})" in sql + + def test_life_event_evidence_is_strictly_validated_before_insert() -> None: schema = _normalized(SCHEMA) transitions = _normalized(TRANSITIONS) diff --git a/tests/test_conversational_rectification_postgres_runtime.py b/tests/test_conversational_rectification_postgres_runtime.py index e64bd3e1..fa519a63 100644 --- a/tests/test_conversational_rectification_postgres_runtime.py +++ b/tests/test_conversational_rectification_postgres_runtime.py @@ -638,6 +638,7 @@ def test_postgres_uses_the_shared_stable_numeric_boundary_vectors( stable_vector = { "zero": 0, "minFraction": 0.000001, + "ieeeRoundingBoundary": 1.000001, "decimal": 0.123456, "maxSafe": 9_007_199_254_740_991, } @@ -649,11 +650,14 @@ def test_postgres_uses_the_shared_stable_numeric_boundary_vectors( )::text; """ ) - assert json.loads(numeric_result) == {"valid": True, "bytes": 86} + assert json.loads(numeric_result) == {"valid": True, "bytes": 120} for invalid in ( + {"nested": [{"score": 1e-7}]}, {"nested": [{"score": 1e-100}]}, + {"nested": [{"score": 0.0000012}]}, {"nested": [{"score": 0.1234567}]}, + {"nested": [{"score": 1_000_000.000001}]}, {"nested": [{"score": 9_007_199_254_740_992}]}, ): assert pg14_database.sql( @@ -678,6 +682,123 @@ def test_postgres_uses_the_shared_stable_numeric_boundary_vectors( ) == "false" +def test_json_text_validators_align_types_uuid_unicode_and_utf16( + pg14_database: PgDatabase, +) -> None: + case_id = "00000000-0000-4000-8000-000000000960" + turn = _valid_turn(case_id) + private_candidate = _valid_private_candidate() + unicode_whitespace = "\u00a0\u2007\ufeff" + canonical_uuid = "a9890e09-d535-46f0-9a36-86017515a5a1" + compact_uuid = canonical_uuid.replace("-", "") + + uuid_result = json.loads(pg14_database.sql( + f""" + select pg_catalog.jsonb_build_object( + 'lower', public.conversational_rectification_valid_uuid_text({_text(canonical_uuid)}), + 'upper', public.conversational_rectification_valid_uuid_text({_text(canonical_uuid.upper())}), + 'compact', public.conversational_rectification_valid_uuid_text({_text(compact_uuid)}), + 'braced', public.conversational_rectification_valid_uuid_text({_text('{' + canonical_uuid + '}')}) + )::text; + """ + )) + assert uuid_result == {"lower": True, "upper": True, "compact": False, "braced": False} + + invalid_turns = ( + {**turn, "candidate": {**turn["candidate"], "status": None}}, + { + **turn, + "technicalReceipt": {**turn["technicalReceipt"], "stableLayers": [None]}, + }, + { + **turn, + "technicalReceipt": {**turn["technicalReceipt"], "sensitiveLayers": [42]}, + }, + { + **turn, + "evidenceRequest": {**turn["evidenceRequest"], "domains": ["career", None]}, + }, + { + **turn, + "evidenceRequest": {**turn["evidenceRequest"], "datePrecision": None}, + }, + {**turn, "actions": [None]}, + {**turn, "status": None}, + {**turn, "narrative": unicode_whitespace}, + { + **turn, + "evidenceRecap": [{ + "id": compact_uuid, + "summary": "summary", + "dateLabel": "2020-01", + }], + }, + ) + for invalid in invalid_turns: + assert pg14_database.sql( + "select public.conversational_rectification_valid_public_turn(" + f"{_jsonb(invalid)})::text" + ) == "false" + + invalid_private_candidates = ( + {**private_candidate, "resultId": compact_uuid}, + {**private_candidate, "suggestedDomains": ["career", None]}, + {**private_candidate, "d1Stability": None}, + {**private_candidate, "calculationVersion": unicode_whitespace}, + { + **private_candidate, + "workingState": {"phase": "initial", "iteration": 0, "notes": [unicode_whitespace]}, + }, + { + **private_candidate, + "futureWindows": [{ + "label": unicode_whitespace, + "startDate": "2020-01-01", + "endDate": "2020-01-02", + "scoreable": False, + }], + }, + ) + for invalid in invalid_private_candidates: + assert pg14_database.sql( + "select public.conversational_rectification_valid_private_candidate(" + f"{_jsonb(invalid)})::text" + ) == "false" + + invalid_declared = { + **_valid_declared_birth_input(), + "birthTimeClue": unicode_whitespace, + "birthplace": { + **_valid_declared_birth_input()["birthplace"], + "city": unicode_whitespace, + }, + } + assert pg14_database.sql( + "select public.conversational_rectification_valid_declared_birth_input(" + f"{_jsonb(invalid_declared)})::text" + ) == "false" + + assert pg14_database.sql( + "select public.conversational_rectification_valid_validation_receipt(" + f"{_jsonb({'modelId': unicode_whitespace, 'schemaValidated': True})})::text" + ) == "false" + assert pg14_database.sql( + "select public.conversational_rectification_valid_action_response(" + f"{_jsonb({'success': False, 'credits': 7, 'billing_state': None, 'error_code': unicode_whitespace})}, " + "'reserve_fee')::text" + ) == "false" + + assert pg14_database.sql( + "select public.conversational_rectification_text_utf16_length(" + f"{_text('😀' * 40)})::text" + ) == "80" + for model_id, expected in (("😀" * 60, "true"), ("😀" * 61, "false")): + assert pg14_database.sql( + "select public.conversational_rectification_valid_validation_receipt(" + f"{_jsonb({'modelId': model_id, 'schemaValidated': True})})::text" + ) == expected + + def test_save_rejects_invalid_evidence_without_discarding_or_coercing_fields( pg14_database: PgDatabase, ) -> None: