From e16746fb657b07c507b6c4f5fc76428ef429daaa Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 20 Jul 2026 22:08:08 +0800 Subject: [PATCH] fix conversational rectification persistence gaps --- .../conversational-rectification/billing.ts | 30 +- .../conversational-rectification/contracts.ts | 66 +- .../json-bounds.ts | 38 + .../persistence-contracts.ts | 252 ++++++ .../lib/conversational-rectification/store.ts | 139 +-- ...00_conversational_rectification_schema.sql | 820 +++++++++++++++++- ...0_conversational_rectification_billing.sql | 126 ++- ...nversational_rectification_transitions.sql | 133 ++- ...conversational-rectification-store.test.ts | 262 +++++- ...t_conversational_rectification_contract.py | 68 ++ ...sational_rectification_postgres_runtime.py | 574 ++++++++++++ 11 files changed, 2338 insertions(+), 170 deletions(-) create mode 100644 frontend/src/lib/conversational-rectification/json-bounds.ts create mode 100644 frontend/src/lib/conversational-rectification/persistence-contracts.ts create mode 100644 tests/test_conversational_rectification_postgres_runtime.py diff --git a/frontend/src/lib/conversational-rectification/billing.ts b/frontend/src/lib/conversational-rectification/billing.ts index b494e3e4..2b363b7f 100644 --- a/frontend/src/lib/conversational-rectification/billing.ts +++ b/frontend/src/lib/conversational-rectification/billing.ts @@ -5,6 +5,7 @@ import { mapConversationalRectificationStoreError, type ConversationalRectificationRpcClient, } from "./store.ts"; +import { billingReceiptResponseSchema } from "./persistence-contracts.ts"; export type ConversationalRectificationBillingIdentity = Readonly<{ userId: string; @@ -22,24 +23,31 @@ export type ConversationalRectificationBillingResult = Readonly<{ billingState: "reserved" | "charged" | "released" | "migration_waived"; }>; -const billingRowSchema = z.object({ - success: z.boolean(), - credits: z.number().int().nonnegative().nullable(), - billing_state: z.enum(["reserved", "charged", "released", "migration_waived"]).nullable(), - error_code: z.string().nullable(), +const billingIdentitySchema = z.object({ + userId: z.string().uuid(), + caseId: z.string().uuid(), + expectedVersion: z.number().int().nonnegative(), + actionId: z.string().uuid(), }).strict(); function billingArgs( input: ConversationalRectificationBillingIdentity, ): Readonly> { - if (input.caseId !== conversationalRectificationCaseIdForStartAction(input.actionId)) { + const parsed = billingIdentitySchema.safeParse({ + userId: input.userId, + caseId: input.caseId, + expectedVersion: input.expectedVersion, + actionId: input.actionId, + }); + if (!parsed.success + || parsed.data.caseId !== conversationalRectificationCaseIdForStartAction(parsed.data.actionId)) { throw new ConversationalRectificationError("action_conflict"); } return { - p_user_id: input.userId, - p_case_id: input.caseId, - p_expected_version: input.expectedVersion, - p_action_id: input.actionId, + p_user_id: parsed.data.userId, + p_case_id: parsed.data.caseId, + p_expected_version: parsed.data.expectedVersion, + p_action_id: parsed.data.actionId, }; } @@ -74,7 +82,7 @@ export class ConversationalRectificationBilling { ? new ConversationalRectificationError("billing_failed") : mapped; } - const parsed = billingRowSchema.safeParse(unwrapBillingRow(data)); + const parsed = billingReceiptResponseSchema.safeParse(unwrapBillingRow(data)); if (!parsed.success) throw new ConversationalRectificationError("billing_failed"); if (!parsed.data.success || !parsed.data.billing_state || parsed.data.credits === null) { throw billingRejection(parsed.data.error_code); diff --git a/frontend/src/lib/conversational-rectification/contracts.ts b/frontend/src/lib/conversational-rectification/contracts.ts index 38080e76..77cd4ca0 100644 --- a/frontend/src/lib/conversational-rectification/contracts.ts +++ b/frontend/src/lib/conversational-rectification/contracts.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { boundedJson } from "./json-bounds.ts"; const actionIdSchema = z.string().uuid(); const caseIdSchema = z.string().uuid(); @@ -13,6 +14,11 @@ const evidenceDomainSchema = z.enum([ "other", ]); +const boundedNonblankText = (maximum: number) => z.string() + .min(1) + .max(maximum) + .refine((value) => value.trim().length > 0, "text must contain a non-whitespace character"); + const actionCommandSchema = z.object({ caseId: caseIdSchema, actionId: actionIdSchema, @@ -51,34 +57,42 @@ export const conversationalRectificationCommandSchema = z.discriminatedUnion("ty export type ConversationalRectificationCommand = z.infer; -export const conversationalRectificationTurnSchema = z.object({ +const candidateSchema = boundedJson(z.object({ + status: z.enum(["declared", "pending_validation", "ready_for_confirmation", "confirmed"]), + representativeTime: timeSchema.nullable(), + rangeStart: timeSchema.nullable(), + rangeEnd: timeSchema.nullable(), +}).strict(), 512); + +const technicalReceiptSchema = boundedJson(z.object({ + calculationVersion: boundedNonblankText(80), + stableLayers: z.array(boundedNonblankText(80)).max(20), + sensitiveLayers: z.array(boundedNonblankText(80)).max(20), + candidateDifferenceRefs: z.array(boundedNonblankText(120)).max(40), +}).strict(), 8_192); + +const evidenceRequestSchema = boundedJson(z.object({ + domains: z.array(evidenceDomainSchema).min(2).max(4), + datePrecision: z.enum(["month_preferred", "year_accepted"]), + freeTextAllowed: z.literal(true), +}).strict(), 2_048); + +const evidenceRecapEntrySchema = boundedJson(z.object({ + id: z.string().uuid(), + summary: boundedNonblankText(1_000), + dateLabel: boundedNonblankText(80), +}).strict(), 4_096); + +export const conversationalRectificationTurnSchema = boundedJson(z.object({ caseId: caseIdSchema, journeyProtocol: z.literal("conversational-evidence-v3"), status: z.enum(["active", "paused", "confirming", "completed", "abandoned"]), turnVersion: turnVersionSchema, - narrative: z.string().trim().min(1).max(12_000), - candidate: z.object({ - status: z.enum(["declared", "pending_validation", "ready_for_confirmation", "confirmed"]), - representativeTime: timeSchema.nullable(), - rangeStart: timeSchema.nullable(), - rangeEnd: timeSchema.nullable(), - }).strict(), - technicalReceipt: z.object({ - calculationVersion: z.string().trim().min(1).max(80), - stableLayers: z.array(z.string().trim().min(1).max(80)).max(20), - sensitiveLayers: z.array(z.string().trim().min(1).max(80)).max(20), - candidateDifferenceRefs: z.array(z.string().trim().min(1).max(120)).max(40), - }).strict(), - evidenceRequest: z.object({ - domains: z.array(evidenceDomainSchema).min(2).max(4), - datePrecision: z.enum(["month_preferred", "year_accepted"]), - freeTextAllowed: z.literal(true), - }).strict().nullable(), - evidenceRecap: z.array(z.object({ - id: z.string().uuid(), - summary: z.string(), - dateLabel: z.string(), - }).strict()).max(20), + narrative: boundedNonblankText(12_000), + candidate: candidateSchema, + technicalReceipt: technicalReceiptSchema, + evidenceRequest: evidenceRequestSchema.nullable(), + evidenceRecap: z.array(evidenceRecapEntrySchema).max(20), actions: z.array(z.enum([ "answer", "pause", @@ -86,7 +100,7 @@ export const conversationalRectificationTurnSchema = z.object({ "confirm", "continue_original_question", ])).max(5), - pendingConsultationQuestion: z.string().max(500).nullable(), -}).strict(); + pendingConsultationQuestion: boundedNonblankText(500).nullable(), +}).strict(), 65_536); export type ConversationalRectificationTurn = z.infer; diff --git a/frontend/src/lib/conversational-rectification/json-bounds.ts b/frontend/src/lib/conversational-rectification/json-bounds.ts new file mode 100644 index 00000000..a060f0e0 --- /dev/null +++ b/frontend/src/lib/conversational-rectification/json-bounds.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +function postgresSeparatorBytes(value: unknown): number { + if (Array.isArray(value)) { + return Math.max(0, value.length - 1) + + value.reduce((total, item) => total + postgresSeparatorBytes(item), 0); + } + if (value !== null && typeof value === "object") { + const values = Object.values(value); + return (values.length === 0 ? 0 : (2 * values.length) - 1) + + values.reduce((total, item) => total + postgresSeparatorBytes(item), 0); + } + return 0; +} + +/** Matches PostgreSQL jsonb::text, which adds one space after each comma and colon. */ +export function postgresJsonbTextBytes(value: unknown): number { + try { + const compact = JSON.stringify(value); + if (compact === undefined) return Number.POSITIVE_INFINITY; + const serializedValue: unknown = JSON.parse(compact); + return new TextEncoder().encode(compact).byteLength + + postgresSeparatorBytes(serializedValue); + } catch { + return Number.POSITIVE_INFINITY; + } +} + +export function boundedJson(schema: z.ZodType, maximumBytes: number): z.ZodType { + return schema.superRefine((value, context) => { + if (postgresJsonbTextBytes(value) > maximumBytes) { + context.addIssue({ + code: "custom", + message: `PostgreSQL JSON exceeds ${maximumBytes} UTF-8 bytes`, + }); + } + }); +} diff --git a/frontend/src/lib/conversational-rectification/persistence-contracts.ts b/frontend/src/lib/conversational-rectification/persistence-contracts.ts new file mode 100644 index 00000000..8c2eb6ad --- /dev/null +++ b/frontend/src/lib/conversational-rectification/persistence-contracts.ts @@ -0,0 +1,252 @@ +import { z } from "zod"; +import { conversationalRectificationTurnSchema } from "./contracts.ts"; +import { boundedJson } from "./json-bounds.ts"; + +const uuidSchema = z.string().uuid(); +const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/); +const boundedText = (maximum: number) => z.string() + .min(1) + .max(maximum) + .refine((value) => value.trim().length > 0, "text must contain a non-whitespace character"); + +const birthDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((value) => { + const [year, month, day] = value.split("-").map(Number); + if (year === undefined || month === undefined || day === undefined) return false; + if (year < 1_000 || year > 9_999 || month < 1 || month > 12 || day < 1) return false; + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return day <= (days[month - 1] ?? 0); +}, "invalid calendar date"); + +const locationCodeSchema = boundedText(80); +const birthplaceSchema = boundedJson(z.object({ + city: boundedText(120).optional(), + countryCode: z.string().regex(/^[A-Z0-9-]{1,8}$/).optional(), + provinceCode: locationCodeSchema.optional(), + cityCode: locationCodeSchema.optional(), + districtCode: locationCodeSchema.optional(), + latitude: z.number().finite().min(-90).max(90).optional(), + longitude: z.number().finite().min(-180).max(180).optional(), + timezoneOffset: z.number().finite().min(-12).max(14), +}).strict().superRefine((value, context) => { + if (!value.city && !value.cityCode) { + context.addIssue({ code: "custom", message: "city or cityCode is required" }); + } + if ((value.latitude === undefined) !== (value.longitude === undefined)) { + context.addIssue({ code: "custom", message: "coordinates must be supplied as a pair" }); + } +}), 4_096); + +const clueSchema = z.string().max(240).nullable(); +const commonBirthFields = { + birthDate: birthDateSchema, + birthTimeClue: clueSchema, + birthplace: birthplaceSchema, +} as const; +const periodSchema = z.enum([ + "early_morning", + "morning", + "afternoon", + "evening", + "late_night", +]); + +const hospitalDeclarationSchema = z.object({ + ...commonBirthFields, + source: z.literal("hospital_record"), + reportedTime: timeSchema, + uncertaintyBeforeMinutes: z.literal(2), + uncertaintyAfterMinutes: z.literal(2), +}).strict(); +const familyDeclarationSchema = z.object({ + ...commonBirthFields, + source: z.literal("family_exact"), + reportedTime: timeSchema, + uncertaintyBeforeMinutes: z.union([z.literal(5), z.literal(10), z.literal(15)]), + uncertaintyAfterMinutes: z.union([z.literal(5), z.literal(10), z.literal(15)]), +}).strict().refine( + (value) => value.uncertaintyBeforeMinutes === value.uncertaintyAfterMinutes, + "family uncertainty must be symmetric", +); +const approximateDeclarationSchema = z.object({ + ...commonBirthFields, + source: z.literal("approximate"), + reportedTime: timeSchema, + uncertaintyBeforeMinutes: z.union([z.literal(15), z.literal(30), z.literal(60)]), + uncertaintyAfterMinutes: z.union([z.literal(15), z.literal(30), z.literal(60)]), +}).strict().refine( + (value) => value.uncertaintyBeforeMinutes === value.uncertaintyAfterMinutes, + "approximate uncertainty must be symmetric", +); +const periodDeclarationSchema = z.object({ + ...commonBirthFields, + source: z.literal("period_only"), + reportedPeriod: periodSchema, +}).strict(); +const unknownDeclarationSchema = z.object({ + ...commonBirthFields, + source: z.literal("unknown"), +}).strict(); +const legacyDeclarationSchema = z.object({ + ...commonBirthFields, + source: z.literal("legacy_import"), + reportedTime: timeSchema.optional(), + reportedPeriod: periodSchema.optional(), + uncertaintyBeforeMinutes: z.number().int().min(0).max(720).optional(), + uncertaintyAfterMinutes: z.number().int().min(0).max(720).optional(), +}).strict().superRefine((value, context) => { + const hasTime = typeof value.reportedTime === "string"; + const hasPeriod = typeof value.reportedPeriod === "string"; + if (hasTime && hasPeriod) { + context.addIssue({ code: "custom", message: "legacy declaration has two time modes" }); + } + const before = value.uncertaintyBeforeMinutes; + const after = value.uncertaintyAfterMinutes; + if ((before === undefined) !== (after === undefined) + || (!hasTime && (before !== undefined || after !== undefined))) { + context.addIssue({ code: "custom", message: "legacy uncertainty is incoherent" }); + } +}); + +export const declaredBirthInputSchema = boundedJson(z.union([ + hospitalDeclarationSchema, + familyDeclarationSchema, + approximateDeclarationSchema, + periodDeclarationSchema, + unknownDeclarationSchema, + legacyDeclarationSchema, +]), 12_000); +export type DeclaredBirthInput = z.infer; + +const evidenceDomainSchema = z.enum([ + "career", + "education", + "relocation", + "relationship", + "family", + "other", +]); +const scoredEvidenceSchema = boundedJson(z.object({ + evidenceId: uuidSchema, + domain: evidenceDomainSchema, + candidateTime: timeSchema.nullable(), + score: z.number().finite().min(-1_000_000).max(1_000_000), + ruleRefs: z.array(boundedText(120)).max(40), +}).strict(), 8_192); +const futureWindowSchema = boundedJson(z.object({ + label: boundedText(240), + startDate: birthDateSchema, + endDate: birthDateSchema, + scoreable: z.literal(false), +}).strict().refine((value) => value.startDate <= value.endDate, "window order is invalid"), 2_048); +const privateWorkingStateSchema = boundedJson(z.object({ + phase: z.enum(["initial", "collecting_evidence", "rescoring", "ready", "confirmed"]), + iteration: z.number().int().min(0).max(100), + notes: z.array(boundedText(240)).max(20), +}).strict(), 8_192); + +export const privateCandidateSchema = boundedJson(z.object({ + resultId: uuidSchema.nullable().optional(), + representativeTime: timeSchema.nullable().optional(), + rangeStart: timeSchema.nullable().optional(), + rangeEnd: timeSchema.nullable().optional(), + calculationVersion: boundedText(80), + candidateWeights: z.array(z.number().finite().min(0).max(1)).max(1_440).optional(), + candidateModelRefs: z.array(boundedText(120)).max(80).optional(), + d1Stability: z.enum(["stable", "sensitive", "unavailable"]).optional(), + boundaryDistanceMinutes: z.number().int().min(0).max(1_440).nullable().optional(), + supportedSensitiveLayers: z.array(boundedText(80)).max(40).optional(), + scoredHistoricalEvidence: z.array(scoredEvidenceSchema).max(100).optional(), + suggestedDomains: z.array(evidenceDomainSchema).max(6).optional(), + futureWindows: z.array(futureWindowSchema).max(20).optional(), + workingState: privateWorkingStateSchema.optional(), +}).strict().superRefine((value, context) => { + const hasRangeStart = value.rangeStart !== undefined; + const hasRangeEnd = value.rangeEnd !== undefined; + if (hasRangeStart !== hasRangeEnd + || (hasRangeStart && (value.rangeStart === null) !== (value.rangeEnd === null))) { + context.addIssue({ code: "custom", message: "candidate range must be supplied as a pair" }); + } +}), 65_536); +export type PrivateCandidate = z.infer; + +export const validationReceiptSchema = boundedJson(z.object({ + modelId: boundedText(120), + schemaValidated: z.boolean(), + validatorVersion: boundedText(80).optional(), + validatedAt: z.string().max(40).datetime({ offset: true }).optional(), + retryCount: z.number().int().min(0).max(2).optional(), + fallbackUsed: z.boolean().optional(), + issues: z.array(boundedText(240)).max(20).optional(), +}).strict(), 8_192); +export type ValidationReceipt = z.infer; + +export const lifeEventEvidenceSchema = boundedJson(z.object({ + id: uuidSchema, + rawText: boundedText(4_000), + domain: evidenceDomainSchema, + eventSummary: boundedText(1_000), + dateValue: boundedText(80).nullable(), + datePrecision: z.enum(["day", "month", "year", "range", "unknown"]), + extractionStatus: z.enum(["clear", "needs_clarification", "corrected"]), + scoreable: z.boolean().optional(), +}).strict(), 16_384); +export type LifeEventEvidence = z.infer; + +const mutationKindSchema = z.enum([ + "create", + "save_turn", + "pause", + "abandon", + "confirm", + "import_legacy", + "reserve_fee", + "complete_fee", + "release_fee", + "recover_fee", +]); +export const conversationalRectificationActionReceiptRequestSchema = boundedJson(z.object({ + kind: mutationKindSchema, + userId: uuidSchema, + caseId: uuidSchema, + expectedVersion: z.number().int().nonnegative(), + actionId: uuidSchema, + requestFingerprint: z.string().regex(/^[0-9a-f]{64}$/), +}).strict(), 2_048); + +export const billingReceiptResponseSchema = boundedJson(z.object({ + success: z.boolean(), + credits: z.number().int().nonnegative().nullable(), + billing_state: z.enum(["reserved", "charged", "released", "migration_waived"]).nullable(), + error_code: boundedText(80).nullable(), +}).strict(), 2_048); + +export const storedCaseRowSchema = boundedJson(z.object({ + case_id: uuidSchema, + user_id: uuidSchema, + status: z.enum(["starting", "active", "paused", "confirming", "completed", "abandoned"]), + turn_version: z.number().int().nonnegative(), + revision_of_case_id: uuidSchema.nullable(), + imported_from_case_id: uuidSchema.nullable(), + baseline_active_time: timeSchema.nullable(), + pending_consultation_question: boundedText(500).nullable(), + billing_state: z.enum(["reserved", "charged", "released", "migration_waived"]).nullable(), + latest_turn: conversationalRectificationTurnSchema, + declared_birth_input: declaredBirthInputSchema.optional(), + private_candidate: privateCandidateSchema.optional(), + event_evidence: z.array(lifeEventEvidenceSchema).max(2_000).optional(), + validation_receipts: z.array(validationReceiptSchema).max(2_000).optional(), +}).strict(), 4_194_304); + +const publicStoredCaseRowSchema = storedCaseRowSchema.refine( + (value) => value.declared_birth_input === undefined + && value.private_candidate === undefined + && value.event_evidence === undefined + && value.validation_receipts === undefined, + "action receipt response cannot contain private state", +); + +export const conversationalRectificationActionReceiptResponseSchema = z.union([ + billingReceiptResponseSchema, + boundedJson(publicStoredCaseRowSchema, 69_632), +]); diff --git a/frontend/src/lib/conversational-rectification/store.ts b/frontend/src/lib/conversational-rectification/store.ts index bb547e7f..6571f4fd 100644 --- a/frontend/src/lib/conversational-rectification/store.ts +++ b/frontend/src/lib/conversational-rectification/store.ts @@ -7,6 +7,17 @@ import { ConversationalRectificationError, type ConversationalRectificationErrorCode, } from "./errors.ts"; +import { + declaredBirthInputSchema, + lifeEventEvidenceSchema, + privateCandidateSchema, + storedCaseRowSchema, + validationReceiptSchema, + type DeclaredBirthInput, + type LifeEventEvidence, + type PrivateCandidate, + type ValidationReceipt, +} from "./persistence-contracts.ts"; export type ConversationalRectificationRpcError = Readonly<{ code?: string; @@ -32,8 +43,8 @@ type DeepReadonly = T extends (...args: never[]) => unknown : T; export type ConversationalRectificationTurnInput = DeepReadonly; -export type PrivateCandidateInput = Readonly>; -export type ValidationReceiptInput = Readonly>; +export type PrivateCandidateInput = DeepReadonly; +export type ValidationReceiptInput = DeepReadonly; export type StoredConversationalRectificationCase = Readonly<{ caseId: string; @@ -46,18 +57,18 @@ export type StoredConversationalRectificationCase = Readonly<{ pendingConsultationQuestion: string | null; billingState: "reserved" | "charged" | "released" | "migration_waived" | null; latestTurn: ConversationalRectificationTurn; - declaredBirthInput?: Readonly>; - privateCandidate?: Readonly>; + declaredBirthInput?: DeepReadonly; + privateCandidate?: DeepReadonly; eventEvidence?: ReadonlyArray; - validationReceipts?: ReadonlyArray>>; + validationReceipts?: ReadonlyArray>; }>; export type LoadedConversationalRectificationCase = StoredConversationalRectificationCase & Readonly<{ - declaredBirthInput: Readonly>; - privateCandidate: Readonly>; + declaredBirthInput: DeepReadonly; + privateCandidate: DeepReadonly; eventEvidence: ReadonlyArray; - validationReceipts: ReadonlyArray>>; + validationReceipts: ReadonlyArray>; }>; type MutationIdentity = Readonly<{ @@ -70,22 +81,13 @@ type MutationIdentity = Readonly<{ export type CreateConversationalRectificationCaseInput = MutationIdentity & Readonly<{ revisionOfCaseId: string | null; pendingConsultationQuestion: string | null; - declaredBirthInput: Readonly>; + declaredBirthInput: DeepReadonly; firstTurn: ConversationalRectificationTurnInput; validationReceipt: ValidationReceiptInput; privateCandidate: PrivateCandidateInput; }>; -export type LifeEventEvidenceInput = Readonly<{ - id: string; - rawText: string; - domain: "career" | "education" | "relocation" | "relationship" | "family" | "other"; - eventSummary: string; - dateValue: string | null; - datePrecision: "day" | "month" | "year" | "range" | "unknown"; - extractionStatus: "clear" | "needs_clarification" | "corrected"; - scoreable?: boolean; -}>; +export type LifeEventEvidenceInput = DeepReadonly; export type SaveConversationalRectificationTurnInput = MutationIdentity & Readonly<{ turn: ConversationalRectificationTurnInput; @@ -146,32 +148,6 @@ export function mapConversationalRectificationStoreError( return new ConversationalRectificationError(domainCode ?? "store_unavailable"); } -const storedCaseRowSchema = z.object({ - case_id: z.string().uuid(), - user_id: z.string().uuid(), - status: z.enum(["starting", "active", "paused", "confirming", "completed", "abandoned"]), - turn_version: z.number().int().nonnegative(), - revision_of_case_id: z.string().uuid().nullable(), - imported_from_case_id: z.string().uuid().nullable(), - baseline_active_time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).nullable(), - pending_consultation_question: z.string().min(1).max(500).nullable(), - billing_state: z.enum(["reserved", "charged", "released", "migration_waived"]).nullable(), - latest_turn: conversationalRectificationTurnSchema, - declared_birth_input: z.record(z.unknown()).optional(), - private_candidate: z.record(z.unknown()).optional(), - event_evidence: z.array(z.object({ - id: z.string().uuid(), - rawText: z.string().trim().min(1).max(4_000), - domain: z.enum(["career", "education", "relocation", "relationship", "family", "other"]), - eventSummary: z.string().min(1).max(1_000), - dateValue: z.string().min(1).max(80).nullable(), - datePrecision: z.enum(["day", "month", "year", "range", "unknown"]), - extractionStatus: z.enum(["clear", "needs_clarification", "corrected"]), - scoreable: z.boolean(), - }).strict()).optional(), - validation_receipts: z.array(z.record(z.unknown())).optional(), -}).strict(); - function unwrapSingle(data: unknown, allowNull: boolean): unknown { if (Array.isArray(data)) { if (data.length === 0 && allowNull) return null; @@ -211,12 +187,54 @@ function requirePublicTurn(turn: ConversationalRectificationTurnInput): Conversa return parsed.data; } +function invalidDurableInput(): never { + throw new ConversationalRectificationError("action_conflict"); +} + +function requireDeclaredBirthInput(input: DeepReadonly): DeclaredBirthInput { + const parsed = declaredBirthInputSchema.safeParse(input); + if (!parsed.success) return invalidDurableInput(); + return parsed.data; +} + +function requirePrivateCandidate(input: PrivateCandidateInput): PrivateCandidate { + const parsed = privateCandidateSchema.safeParse(input); + if (!parsed.success) return invalidDurableInput(); + return parsed.data; +} + +function requireValidationReceipt(input: ValidationReceiptInput): ValidationReceipt { + const parsed = validationReceiptSchema.safeParse(input); + if (!parsed.success) return invalidDurableInput(); + return parsed.data; +} + +function requireEvidence(input: ReadonlyArray): ReadonlyArray { + const parsed = z.array(lifeEventEvidenceSchema).max(20).safeParse(input); + if (!parsed.success) return invalidDurableInput(); + return parsed.data; +} + +const mutationIdentitySchema = z.object({ + userId: z.string().uuid(), + caseId: z.string().uuid(), + expectedVersion: z.number().int().nonnegative(), + actionId: z.string().uuid(), +}).strict(); + function mutationArgs(input: MutationIdentity): Readonly> { + const parsed = mutationIdentitySchema.safeParse({ + userId: input.userId, + caseId: input.caseId, + expectedVersion: input.expectedVersion, + actionId: input.actionId, + }); + if (!parsed.success) return invalidDurableInput(); return { - p_user_id: input.userId, - p_case_id: input.caseId, - p_expected_version: input.expectedVersion, - p_action_id: input.actionId, + p_user_id: parsed.data.userId, + p_case_id: parsed.data.caseId, + p_expected_version: parsed.data.expectedVersion, + p_action_id: parsed.data.actionId, }; } @@ -259,10 +277,10 @@ export class ConversationalRectificationStore { ...mutationArgs(input), p_revision_of_case_id: input.revisionOfCaseId, p_pending_consultation_question: input.pendingConsultationQuestion, - p_declared_birth_input: input.declaredBirthInput, + p_declared_birth_input: requireDeclaredBirthInput(input.declaredBirthInput), p_first_turn: requirePublicTurn(input.firstTurn), - p_validation_receipt: input.validationReceipt, - p_private_candidate: input.privateCandidate, + p_validation_receipt: requireValidationReceipt(input.validationReceipt), + p_private_candidate: requirePrivateCandidate(input.privateCandidate), }); if (!result) throw new ConversationalRectificationError("store_unavailable"); return result; @@ -292,9 +310,9 @@ export class ConversationalRectificationStore { const result = await this.callCaseRpc("save_conversational_rectification_turn", { ...mutationArgs(input), p_turn: requirePublicTurn(input.turn), - p_evidence: input.evidence, - p_validation_receipt: input.validationReceipt, - p_private_candidate: input.privateCandidate, + p_evidence: requireEvidence(input.evidence), + p_validation_receipt: requireValidationReceipt(input.validationReceipt), + p_private_candidate: requirePrivateCandidate(input.privateCandidate), }); if (!result) throw new ConversationalRectificationError("store_unavailable"); return result; @@ -319,7 +337,7 @@ export class ConversationalRectificationStore { const result = await this.callCaseRpc(functionName, { ...mutationArgs(input), p_turn: requirePublicTurn(input.turn), - p_validation_receipt: input.validationReceipt, + p_validation_receipt: requireValidationReceipt(input.validationReceipt), }); if (!result) throw new ConversationalRectificationError("store_unavailable"); return result; @@ -334,7 +352,7 @@ export class ConversationalRectificationStore { p_time: input.time, p_calculation_version: input.calculationVersion, p_turn: requirePublicTurn(input.turn), - p_validation_receipt: input.validationReceipt, + p_validation_receipt: requireValidationReceipt(input.validationReceipt), }); if (!result) throw new ConversationalRectificationError("store_unavailable"); return result; @@ -346,14 +364,17 @@ export class ConversationalRectificationStore { if (input.caseId !== conversationalRectificationCaseIdForStartAction(input.actionId)) { throw new ConversationalRectificationError("action_conflict"); } + if (!Number.isSafeInteger(input.price) || input.price < 1 || input.price > 1_000_000) { + throw new ConversationalRectificationError("action_conflict"); + } const result = await this.callCaseRpc("import_legacy_conversational_rectification_case", { ...mutationArgs(input), p_legacy_case_id: input.legacyCaseId, p_price: input.price, p_pending_consultation_question: input.pendingConsultationQuestion, p_first_turn: requirePublicTurn(input.firstTurn), - p_validation_receipt: input.validationReceipt, - p_private_candidate: input.privateCandidate, + p_validation_receipt: requireValidationReceipt(input.validationReceipt), + p_private_candidate: requirePrivateCandidate(input.privateCandidate), }); if (!result) throw new ConversationalRectificationError("store_unavailable"); return result; diff --git a/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql b/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql index ad8e47e6..1f2459f2 100644 --- a/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql +++ b/frontend/supabase/migrations/20260720010000_conversational_rectification_schema.sql @@ -1,5 +1,773 @@ begin; +create or replace function public.conversational_rectification_has_only_keys( + p_value jsonb, + p_allowed text[] +) +returns boolean +language sql +immutable +strict +set search_path = '' +as $$ + select pg_catalog.jsonb_typeof(p_value) = 'object' + and not exists ( + select 1 + from pg_catalog.jsonb_object_keys(p_value) key + where not (key = any (p_allowed)) + ); +$$; + +create or replace function public.conversational_rectification_valid_time_text(p_value text) +returns boolean +language sql +immutable +strict +set search_path = '' +as $$ + select p_value ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'; +$$; + +create or replace function public.conversational_rectification_valid_date_text(p_value text) +returns boolean +language plpgsql +immutable +strict +set search_path = '' +as $$ +begin + return p_value ~ '^[1-9][0-9]{3}-[0-9]{2}-[0-9]{2}$' + and pg_catalog.to_char(p_value::date, 'YYYY-MM-DD') = p_value; +exception when others then + return false; +end; +$$; + +create or replace function public.conversational_rectification_valid_uuid_text(p_value text) +returns boolean +language plpgsql +immutable +strict +set search_path = '' +as $$ +begin + perform p_value::uuid; + 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, + p_max_characters integer, + p_max_bytes integer +) +returns boolean +language sql +immutable +strict +set search_path = '' +as $$ + select pg_catalog.jsonb_typeof(p_value) = 'array' + and pg_catalog.jsonb_array_length(p_value) <= p_max_items + and pg_catalog.octet_length(p_value::text) <= p_max_bytes + 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 + ); +$$; + +create or replace function public.conversational_rectification_valid_candidate(p_value jsonb) +returns boolean +language plpgsql +immutable +strict +set search_path = '' +as $$ +declare + v_key text; +begin + if pg_catalog.jsonb_typeof(p_value) is distinct from 'object' + or pg_catalog.octet_length(p_value::text) > 512 + or not public.conversational_rectification_has_only_keys( + p_value, + array['status', 'representativeTime', 'rangeStart', 'rangeEnd']::text[] + ) + or not (p_value ?& array['status', 'representativeTime', 'rangeStart', 'rangeEnd']::text[]) + or p_value ->> 'status' not in ( + 'declared', 'pending_validation', 'ready_for_confirmation', 'confirmed' + ) then + return false; + end if; + foreach v_key in array array['representativeTime', 'rangeStart', 'rangeEnd']::text[] loop + if p_value -> v_key <> 'null'::jsonb + and ( + pg_catalog.jsonb_typeof(p_value -> v_key) is distinct from 'string' + or not public.conversational_rectification_valid_time_text(p_value ->> v_key) + ) then + return false; + end if; + end loop; + return true; +exception when others then + return false; +end; +$$; + +create or replace function public.conversational_rectification_valid_technical_receipt( + p_value jsonb +) +returns boolean +language sql +immutable +strict +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_has_only_keys( + p_value, + array[ + 'calculationVersion', 'stableLayers', 'sensitiveLayers', + 'candidateDifferenceRefs' + ]::text[] + ) + and p_value ?& array[ + 'calculationVersion', 'stableLayers', 'sensitiveLayers', + '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_array_is_bounded( + p_value -> 'stableLayers', 20, 80, 4096 + ) + and public.conversational_rectification_text_array_is_bounded( + p_value -> 'sensitiveLayers', 20, 80, 4096 + ) + and public.conversational_rectification_text_array_is_bounded( + p_value -> 'candidateDifferenceRefs', 40, 120, 8192 + ); +$$; + +create or replace function public.conversational_rectification_valid_evidence_request( + p_value jsonb +) +returns boolean +language sql +immutable +strict +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_has_only_keys( + p_value, + array['domains', 'datePrecision', 'freeTextAllowed']::text[] + ) + and p_value ?& array['domains', 'datePrecision', 'freeTextAllowed']::text[] + and pg_catalog.jsonb_typeof(p_value -> 'domains') = 'array' + 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') + ) + and p_value ->> 'datePrecision' in ('month_preferred', 'year_accepted') + and p_value -> 'freeTextAllowed' = 'true'::jsonb; +$$; + +create or replace function public.conversational_rectification_valid_evidence_recap( + p_value jsonb +) +returns boolean +language sql +immutable +strict +set search_path = '' +as $$ + select pg_catalog.jsonb_typeof(p_value) = 'array' + and pg_catalog.octet_length(p_value::text) <= 24576 + and pg_catalog.jsonb_array_length(p_value) <= 20 + and not exists ( + select 1 + from pg_catalog.jsonb_array_elements(p_value) item + where pg_catalog.jsonb_typeof(item) <> 'object' + or pg_catalog.octet_length(item::text) > 4096 + or not public.conversational_rectification_has_only_keys( + item, array['id', 'summary', 'dateLabel']::text[] + ) + or not (item ?& array['id', 'summary', 'dateLabel']::text[]) + 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 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 + ); +$$; + +create or replace function public.conversational_rectification_valid_actions(p_value jsonb) +returns boolean +language sql +immutable +strict +set search_path = '' +as $$ + select pg_catalog.jsonb_typeof(p_value) = 'array' + and pg_catalog.octet_length(p_value::text) <= 512 + 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 ( + 'answer', 'pause', 'abandon', 'confirm', 'continue_original_question' + ) + ); +$$; + +create or replace function public.conversational_rectification_valid_validation_receipt( + 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) > 8192 + or not public.conversational_rectification_has_only_keys( + p_value, + array[ + 'modelId', 'schemaValidated', 'validatorVersion', 'validatedAt', + 'retryCount', 'fallbackUsed', 'issues' + ]::text[] + ) + 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 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 + ) 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 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 + ) then return false; end if; + if p_value ? 'retryCount' and ( + pg_catalog.jsonb_typeof(p_value -> 'retryCount') is distinct from 'number' + or p_value ->> 'retryCount' !~ '^[0-9]+$' + or (p_value ->> 'retryCount')::integer not between 0 and 2 + ) then return false; end if; + if p_value ? 'fallbackUsed' + and pg_catalog.jsonb_typeof(p_value -> 'fallbackUsed') is distinct from 'boolean' then + return false; + end if; + if p_value ? 'issues' and not public.conversational_rectification_text_array_is_bounded( + p_value -> 'issues', 20, 240, 8192 + ) then return false; end if; + return true; +exception when others then + return false; +end; +$$; + +create or replace function public.conversational_rectification_valid_private_candidate( + p_value jsonb +) +returns boolean +language plpgsql +immutable +strict +set search_path = '' +as $$ +declare + v_item jsonb; + v_key text; +begin + if pg_catalog.jsonb_typeof(p_value) is distinct from 'object' + or pg_catalog.octet_length(p_value::text) > 65536 + or not public.conversational_rectification_has_only_keys( + p_value, + array[ + 'resultId', 'representativeTime', 'rangeStart', 'rangeEnd', + 'calculationVersion', 'candidateWeights', 'candidateModelRefs', + 'd1Stability', 'boundaryDistanceMinutes', 'supportedSensitiveLayers', + 'scoredHistoricalEvidence', 'suggestedDomains', 'futureWindows', 'workingState' + ]::text[] + ) + 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 + return false; + end if; + if p_value ? 'resultId' and p_value -> 'resultId' <> 'null'::jsonb and ( + pg_catalog.jsonb_typeof(p_value -> 'resultId') is distinct from 'string' + or not public.conversational_rectification_valid_uuid_text(p_value ->> 'resultId') + ) then return false; end if; + foreach v_key in array array['representativeTime', 'rangeStart', 'rangeEnd']::text[] loop + if p_value ? v_key and p_value -> v_key <> 'null'::jsonb and ( + pg_catalog.jsonb_typeof(p_value -> v_key) is distinct from 'string' + or not public.conversational_rectification_valid_time_text(p_value ->> v_key) + ) then return false; end if; + end loop; + if (p_value ? 'rangeStart') <> (p_value ? 'rangeEnd') + or (p_value -> 'rangeStart' = 'null'::jsonb) <> (p_value -> 'rangeEnd' = 'null'::jsonb) then + return false; + end if; + if p_value ? 'candidateWeights' and ( + pg_catalog.jsonb_typeof(p_value -> 'candidateWeights') is distinct from 'array' + or pg_catalog.jsonb_array_length(p_value -> 'candidateWeights') > 1440 + or exists ( + select 1 from pg_catalog.jsonb_array_elements(p_value -> 'candidateWeights') weight + where pg_catalog.jsonb_typeof(weight) <> 'number' + or (weight #>> '{}')::numeric not between 0 and 1 + ) + ) then return false; end if; + if p_value ? 'candidateModelRefs' and not public.conversational_rectification_text_array_is_bounded( + p_value -> 'candidateModelRefs', 80, 120, 16384 + ) then return false; end if; + if p_value ? 'supportedSensitiveLayers' and not public.conversational_rectification_text_array_is_bounded( + p_value -> 'supportedSensitiveLayers', 40, 80, 8192 + ) then return false; end if; + if p_value ? 'd1Stability' and p_value ->> 'd1Stability' not in ( + 'stable', 'sensitive', 'unavailable' + ) then return false; end if; + if p_value ? 'd1Stability' + and pg_catalog.jsonb_typeof(p_value -> 'd1Stability') is distinct from 'string' then + return false; + end if; + if p_value ? 'boundaryDistanceMinutes' and p_value -> 'boundaryDistanceMinutes' <> 'null'::jsonb and ( + pg_catalog.jsonb_typeof(p_value -> 'boundaryDistanceMinutes') is distinct from 'number' + or p_value ->> 'boundaryDistanceMinutes' !~ '^[0-9]+$' + or (p_value ->> 'boundaryDistanceMinutes')::integer not between 0 and 1440 + ) then return false; end if; + if p_value ? 'suggestedDomains' and ( + 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') + ) + ) then return false; end if; + if p_value ? 'scoredHistoricalEvidence' then + if pg_catalog.jsonb_typeof(p_value -> 'scoredHistoricalEvidence') is distinct from 'array' + or pg_catalog.jsonb_array_length(p_value -> 'scoredHistoricalEvidence') > 100 then + return false; + end if; + for v_item in select value from pg_catalog.jsonb_array_elements(p_value -> 'scoredHistoricalEvidence') loop + if pg_catalog.jsonb_typeof(v_item) is distinct from 'object' + or pg_catalog.octet_length(v_item::text) > 8192 + or not public.conversational_rectification_has_only_keys( + v_item, array['evidenceId', 'domain', 'candidateTime', 'score', 'ruleRefs']::text[] + ) + or not (v_item ?& array['evidenceId', 'domain', 'candidateTime', 'score', 'ruleRefs']::text[]) + or pg_catalog.jsonb_typeof(v_item -> 'evidenceId') is distinct from 'string' + or not public.conversational_rectification_valid_uuid_text(v_item ->> 'evidenceId') + or pg_catalog.jsonb_typeof(v_item -> 'domain') is distinct from 'string' + or v_item ->> 'domain' not in ('career', 'education', 'relocation', 'relationship', 'family', 'other') + or (v_item -> 'candidateTime' <> 'null'::jsonb and ( + pg_catalog.jsonb_typeof(v_item -> 'candidateTime') is distinct from 'string' + or not public.conversational_rectification_valid_time_text(v_item ->> 'candidateTime') + )) + or pg_catalog.jsonb_typeof(v_item -> 'score') is distinct from 'number' + or (v_item ->> 'score')::numeric not between -1000000 and 1000000 + or not public.conversational_rectification_text_array_is_bounded( + v_item -> 'ruleRefs', 40, 120, 8192 + ) then return false; end if; + end loop; + end if; + if p_value ? 'futureWindows' then + if pg_catalog.jsonb_typeof(p_value -> 'futureWindows') is distinct from 'array' + or pg_catalog.jsonb_array_length(p_value -> 'futureWindows') > 20 then return false; end if; + for v_item in select value from pg_catalog.jsonb_array_elements(p_value -> 'futureWindows') loop + if pg_catalog.jsonb_typeof(v_item) is distinct from 'object' + or pg_catalog.octet_length(v_item::text) > 2048 + or not public.conversational_rectification_has_only_keys( + v_item, array['label', 'startDate', 'endDate', 'scoreable']::text[] + ) + 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 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') + or not public.conversational_rectification_valid_date_text(v_item ->> 'endDate') + or v_item ->> 'startDate' > v_item ->> 'endDate' + or v_item -> 'scoreable' <> 'false'::jsonb then return false; end if; + end loop; + end if; + if p_value ? 'workingState' then + v_item := p_value -> 'workingState'; + if pg_catalog.jsonb_typeof(v_item) is distinct from 'object' + or pg_catalog.octet_length(v_item::text) > 8192 + or not public.conversational_rectification_has_only_keys( + v_item, array['phase', 'iteration', 'notes']::text[] + ) + or not (v_item ?& array['phase', 'iteration', 'notes']::text[]) + or pg_catalog.jsonb_typeof(v_item -> 'phase') is distinct from 'string' + or v_item ->> 'phase' not in ('initial', 'collecting_evidence', 'rescoring', 'ready', 'confirmed') + or pg_catalog.jsonb_typeof(v_item -> 'iteration') is distinct from 'number' + or v_item ->> 'iteration' !~ '^[0-9]+$' + or (v_item ->> 'iteration')::integer not between 0 and 100 + or not public.conversational_rectification_text_array_is_bounded( + v_item -> 'notes', 20, 240, 8192 + ) then return false; end if; + end if; + return true; +exception when others then + return false; +end; +$$; + +create or replace function public.conversational_rectification_valid_declared_birth_input( + p_value jsonb +) +returns boolean +language plpgsql +immutable +strict +set search_path = '' +as $$ +declare + v_place jsonb; + v_source text; + v_key text; + v_before integer; + v_after integer; +begin + if pg_catalog.jsonb_typeof(p_value) is distinct from 'object' + or pg_catalog.octet_length(p_value::text) > 12000 + or not public.conversational_rectification_has_only_keys( + p_value, + array[ + 'birthDate', 'source', 'birthTimeClue', 'birthplace', 'reportedTime', + 'reportedPeriod', 'uncertaintyBeforeMinutes', 'uncertaintyAfterMinutes' + ]::text[] + ) + or not (p_value ?& array['birthDate', 'source', 'birthTimeClue', 'birthplace']::text[]) + 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 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 + ) then return false; end if; + + v_place := p_value -> 'birthplace'; + if pg_catalog.jsonb_typeof(v_place) is distinct from 'object' + or pg_catalog.octet_length(v_place::text) > 4096 + or not public.conversational_rectification_has_only_keys( + v_place, + array[ + 'city', 'countryCode', 'provinceCode', 'cityCode', 'districtCode', + 'latitude', 'longitude', 'timezoneOffset' + ]::text[] + ) + or not (v_place ? 'timezoneOffset') + or not ((v_place ? 'city') or (v_place ? 'cityCode')) + or pg_catalog.jsonb_typeof(v_place -> 'timezoneOffset') is distinct from 'number' + or (v_place ->> 'timezoneOffset')::numeric not between -12 and 14 + or (v_place ? 'latitude') <> (v_place ? 'longitude') then return false; end if; + 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 + case when v_key = 'city' then 120 else 80 end + or pg_catalog.char_length(pg_catalog.btrim(v_place ->> v_key)) = 0 + ) then return false; end if; + end loop; + if v_place ? 'countryCode' and ( + pg_catalog.jsonb_typeof(v_place -> 'countryCode') is distinct from 'string' + or v_place ->> 'countryCode' !~ '^[A-Z0-9-]{1,8}$' + ) then return false; end if; + if v_place ? 'latitude' and ( + pg_catalog.jsonb_typeof(v_place -> 'latitude') is distinct from 'number' + or pg_catalog.jsonb_typeof(v_place -> 'longitude') is distinct from 'number' + or (v_place ->> 'latitude')::numeric not between -90 and 90 + or (v_place ->> 'longitude')::numeric not between -180 and 180 + ) then return false; end if; + + v_source := p_value ->> 'source'; + if v_source not in ( + 'hospital_record', 'family_exact', 'approximate', 'period_only', 'unknown', 'legacy_import' + ) then return false; end if; + if p_value ? 'reportedTime' and ( + pg_catalog.jsonb_typeof(p_value -> 'reportedTime') is distinct from 'string' + or not public.conversational_rectification_valid_time_text(p_value ->> 'reportedTime') + ) then return false; end if; + if p_value ? 'reportedPeriod' and ( + pg_catalog.jsonb_typeof(p_value -> 'reportedPeriod') is distinct from 'string' + or p_value ->> 'reportedPeriod' not in ( + 'early_morning', 'morning', 'afternoon', 'evening', 'late_night' + ) + ) then return false; end if; + if p_value ? 'uncertaintyBeforeMinutes' then + if pg_catalog.jsonb_typeof(p_value -> 'uncertaintyBeforeMinutes') is distinct from 'number' + or pg_catalog.jsonb_typeof(p_value -> 'uncertaintyAfterMinutes') is distinct from 'number' + or p_value ->> 'uncertaintyBeforeMinutes' !~ '^[0-9]+$' + or p_value ->> 'uncertaintyAfterMinutes' !~ '^[0-9]+$' then + return false; + end if; + v_before := (p_value ->> 'uncertaintyBeforeMinutes')::integer; + v_after := (p_value ->> 'uncertaintyAfterMinutes')::integer; + elsif p_value ? 'uncertaintyAfterMinutes' then + return false; + end if; + + if v_source = 'hospital_record' then + return p_value ? 'reportedTime' + and not (p_value ? 'reportedPeriod') + and v_before = 2 and v_after = 2; + elsif v_source = 'family_exact' then + return p_value ? 'reportedTime' + and not (p_value ? 'reportedPeriod') + and v_before = v_after and v_before in (5, 10, 15); + elsif v_source = 'approximate' then + return p_value ? 'reportedTime' + and not (p_value ? 'reportedPeriod') + and v_before = v_after and v_before in (15, 30, 60); + elsif v_source = 'period_only' then + return p_value ? 'reportedPeriod' + and not (p_value ? 'reportedTime') + and not (p_value ? 'uncertaintyBeforeMinutes') + and not (p_value ? 'uncertaintyAfterMinutes'); + elsif v_source = 'unknown' then + return not (p_value ? 'reportedPeriod') + and not (p_value ? 'reportedTime') + and not (p_value ? 'uncertaintyBeforeMinutes') + and not (p_value ? 'uncertaintyAfterMinutes'); + end if; + return not ((p_value ? 'reportedTime') and (p_value ? 'reportedPeriod')) + and ((p_value ? 'reportedTime') or v_before is null) + and (v_before is null or (v_before between 0 and 720 and v_after between 0 and 720)); +exception when others then + return false; +end; +$$; + +create or replace function public.conversational_rectification_valid_public_turn(p_value jsonb) +returns boolean +language sql +immutable +strict +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_has_only_keys( + p_value, + array[ + 'caseId', 'journeyProtocol', 'status', 'turnVersion', 'narrative', + 'candidate', 'technicalReceipt', 'evidenceRequest', 'evidenceRecap', + 'actions', 'pendingConsultationQuestion' + ]::text[] + ) + and p_value ?& array[ + 'caseId', 'journeyProtocol', 'status', 'turnVersion', 'narrative', + 'candidate', 'technicalReceipt', 'evidenceRequest', 'evidenceRecap', + 'actions', 'pendingConsultationQuestion' + ]::text[] + and public.conversational_rectification_valid_uuid_text(p_value ->> 'caseId') + and p_value ->> 'journeyProtocol' = 'conversational-evidence-v3' + 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_valid_candidate(p_value -> 'candidate') + and public.conversational_rectification_valid_technical_receipt(p_value -> 'technicalReceipt') + and ( + p_value -> 'evidenceRequest' = 'null'::jsonb + or public.conversational_rectification_valid_evidence_request(p_value -> 'evidenceRequest') + ) + and public.conversational_rectification_valid_evidence_recap(p_value -> 'evidenceRecap') + and public.conversational_rectification_valid_actions(p_value -> 'actions') + and ( + 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( + p_value ->> 'pendingConsultationQuestion' + )) > 0 + ) + ); +$$; + +create or replace function public.conversational_rectification_valid_action_request(p_value jsonb) +returns boolean +language sql +immutable +strict +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_has_only_keys( + p_value, + array[ + 'kind', 'userId', 'caseId', 'expectedVersion', 'actionId', 'requestFingerprint' + ]::text[] + ) + and p_value ?& array[ + 'kind', 'userId', 'caseId', 'expectedVersion', 'actionId', 'requestFingerprint' + ]::text[] + and pg_catalog.jsonb_typeof(p_value -> 'kind') = 'string' + and p_value ->> 'kind' in ( + 'create', 'save_turn', 'pause', 'abandon', 'confirm', 'import_legacy', + 'reserve_fee', 'complete_fee', 'release_fee', 'recover_fee' + ) + and pg_catalog.jsonb_typeof(p_value -> 'userId') = 'string' + and public.conversational_rectification_valid_uuid_text(p_value ->> 'userId') + 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 -> 'actionId') = 'string' + and public.conversational_rectification_valid_uuid_text(p_value ->> 'actionId') + and pg_catalog.jsonb_typeof(p_value -> 'expectedVersion') = 'number' + and p_value ->> 'expectedVersion' ~ '^[0-9]+$' + and pg_catalog.jsonb_typeof(p_value -> 'requestFingerprint') = 'string' + and p_value ->> 'requestFingerprint' ~ '^[0-9a-f]{64}$'; +$$; + +create or replace function public.conversational_rectification_action_request( + p_kind text, + p_user_id uuid, + p_case_id uuid, + p_expected_version bigint, + p_action_id uuid, + p_request_fingerprint text +) +returns jsonb +language sql +immutable +strict +set search_path = '' +as $$ + select pg_catalog.jsonb_build_object( + 'kind', p_kind, + 'userId', p_user_id, + 'caseId', p_case_id, + 'expectedVersion', p_expected_version, + 'actionId', p_action_id, + 'requestFingerprint', p_request_fingerprint + ); +$$; + +create or replace function public.conversational_rectification_valid_action_response( + p_value jsonb, + p_action_kind text +) +returns boolean +language plpgsql +immutable +strict +set search_path = '' +as $$ +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_has_only_keys( + p_value, array['success', 'credits', 'billing_state', 'error_code']::text[] + ) + and p_value ?& array['success', 'credits', 'billing_state', 'error_code']::text[] + and pg_catalog.jsonb_typeof(p_value -> 'success') = 'boolean' + and ( + p_value -> 'credits' = 'null'::jsonb + or ( + pg_catalog.jsonb_typeof(p_value -> 'credits') = 'number' + and p_value ->> 'credits' ~ '^[0-9]+$' + ) + ) + and ( + p_value -> 'billing_state' = 'null'::jsonb + or 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 + ) + ), false); + end if; + return coalesce(pg_catalog.jsonb_typeof(p_value) = 'object' + and pg_catalog.octet_length(p_value::text) <= 69632 + and public.conversational_rectification_has_only_keys( + p_value, + array[ + 'case_id', 'user_id', 'status', 'turn_version', 'revision_of_case_id', + 'imported_from_case_id', 'baseline_active_time', 'pending_consultation_question', + 'billing_state', 'latest_turn' + ]::text[] + ) + and p_value ?& array[ + 'case_id', 'user_id', 'status', 'turn_version', 'revision_of_case_id', + 'imported_from_case_id', 'baseline_active_time', 'pending_consultation_question', + 'billing_state', 'latest_turn' + ]::text[] + and pg_catalog.jsonb_typeof(p_value -> 'case_id') = 'string' + and public.conversational_rectification_valid_uuid_text(p_value ->> 'case_id') + and pg_catalog.jsonb_typeof(p_value -> 'user_id') = 'string' + and public.conversational_rectification_valid_uuid_text(p_value ->> 'user_id') + and pg_catalog.jsonb_typeof(p_value -> 'status') = 'string' + and p_value ->> 'status' in ('starting', 'active', 'paused', 'confirming', 'completed', 'abandoned') + and pg_catalog.jsonb_typeof(p_value -> 'turn_version') = 'number' + and p_value ->> 'turn_version' ~ '^[0-9]+$' + and (p_value -> 'revision_of_case_id' = 'null'::jsonb + or (pg_catalog.jsonb_typeof(p_value -> 'revision_of_case_id') = 'string' + and public.conversational_rectification_valid_uuid_text( + p_value ->> 'revision_of_case_id' + ))) + and (p_value -> 'imported_from_case_id' = 'null'::jsonb + or (pg_catalog.jsonb_typeof(p_value -> 'imported_from_case_id') = 'string' + and public.conversational_rectification_valid_uuid_text( + p_value ->> 'imported_from_case_id' + ))) + and (p_value -> 'baseline_active_time' = 'null'::jsonb + or (pg_catalog.jsonb_typeof(p_value -> 'baseline_active_time') = 'string' + and public.conversational_rectification_valid_time_text( + p_value ->> 'baseline_active_time' + ))) + 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( + p_value ->> 'pending_consultation_question' + )) > 0)) + and (p_value -> 'billing_state' = 'null'::jsonb + or (pg_catalog.jsonb_typeof(p_value -> 'billing_state') = 'string' + and p_value ->> 'billing_state' in ( + 'reserved', 'charged', 'released', 'migration_waived' + ))) + and public.conversational_rectification_valid_public_turn(p_value -> 'latest_turn'), false); +exception when others then + return false; +end; +$$; + alter table public.birth_time_rectification_cases add column if not exists revision_of_case_id uuid, add column if not exists imported_from_case_id uuid, @@ -12,6 +780,9 @@ alter table public.birth_time_rectification_cases drop constraint if exists birth_time_rectification_cases_imported_from_case_id_fkey, drop constraint if exists birth_time_rectification_cases_pending_question_check, drop constraint if exists birth_time_rectification_cases_declared_birth_input_check, + drop constraint if exists birth_time_rectification_cases_private_candidate_v3_check, + drop constraint if exists birth_time_rectification_cases_turn_state_v3_check, + drop constraint if exists birth_time_rectification_cases_journey_snapshot_v3_check, drop constraint if exists birth_time_rectification_cases_journey_protocol_check, drop constraint if exists birth_time_rectification_cases_status_check; @@ -31,6 +802,25 @@ alter table public.birth_time_rectification_cases check ( jsonb_typeof(declared_birth_input) = 'object' and octet_length(declared_birth_input::text) <= 12000 + and ( + journey_protocol <> 'conversational-evidence-v3' + or public.conversational_rectification_valid_declared_birth_input(declared_birth_input) is true + ) + ), + add constraint birth_time_rectification_cases_private_candidate_v3_check + check ( + journey_protocol <> 'conversational-evidence-v3' + or public.conversational_rectification_valid_private_candidate(candidate_result) is true + ), + add constraint birth_time_rectification_cases_turn_state_v3_check + check ( + journey_protocol <> 'conversational-evidence-v3' + or public.conversational_rectification_valid_public_turn(turn_state) is true + ), + add constraint birth_time_rectification_cases_journey_snapshot_v3_check + check ( + journey_protocol <> 'conversational-evidence-v3' + or public.conversational_rectification_valid_public_turn(journey_snapshot) is true ), add constraint birth_time_rectification_cases_journey_protocol_check check (journey_protocol in ( @@ -69,21 +859,24 @@ create table if not exists public.birth_time_rectification_turns ( char_length(narrative) between 1 and 12000 and char_length(btrim(narrative)) > 0 ), - candidate jsonb not null check (jsonb_typeof(candidate) = 'object'), - technical_receipt jsonb not null check (jsonb_typeof(technical_receipt) = 'object'), + candidate jsonb not null check ( + public.conversational_rectification_valid_candidate(candidate) is true + ), + technical_receipt jsonb not null check ( + public.conversational_rectification_valid_technical_receipt(technical_receipt) is true + ), evidence_request jsonb check ( - evidence_request is null or jsonb_typeof(evidence_request) = 'object' + evidence_request is null + or public.conversational_rectification_valid_evidence_request(evidence_request) is true ), evidence_recap jsonb not null default '[]'::jsonb check ( - jsonb_typeof(evidence_recap) = 'array' - and jsonb_array_length(evidence_recap) <= 20 + public.conversational_rectification_valid_evidence_recap(evidence_recap) is true ), actions jsonb not null default '[]'::jsonb check ( - jsonb_typeof(actions) = 'array' - and jsonb_array_length(actions) <= 5 + public.conversational_rectification_valid_actions(actions) is true ), - output_validation_receipt jsonb not null default '{}'::jsonb check ( - jsonb_typeof(output_validation_receipt) = 'object' + output_validation_receipt jsonb not null check ( + public.conversational_rectification_valid_validation_receipt(output_validation_receipt) is true ), created_at timestamptz not null default now(), primary key (case_id, turn_version), @@ -137,12 +930,17 @@ create table if not exists public.birth_time_rectification_action_receipts ( user_id uuid not null references auth.users(id) on delete cascade, action_kind text not null check (action_kind in ( 'create', 'save_turn', 'pause', 'abandon', 'confirm', 'import_legacy', - 'reserve_fee', 'complete_fee', 'release_fee' + 'reserve_fee', 'complete_fee', 'release_fee', 'recover_fee' )), expected_turn_version bigint not null check (expected_turn_version >= 0), result_turn_version bigint not null check (result_turn_version >= 0), request_fingerprint text not null check (request_fingerprint ~ '^[0-9a-f]{64}$'), - response jsonb not null check (jsonb_typeof(response) = 'object'), + request jsonb not null check ( + public.conversational_rectification_valid_action_request(request) is true + ), + response jsonb not null check ( + public.conversational_rectification_valid_action_response(response, action_kind) is true + ), created_at timestamptz not null default now(), primary key (case_id, action_id), check (not jsonb_path_exists(response, '$.**.candidateWeights')), diff --git a/frontend/supabase/migrations/20260720020000_conversational_rectification_billing.sql b/frontend/supabase/migrations/20260720020000_conversational_rectification_billing.sql index 44b4a5aa..a692962f 100644 --- a/frontend/supabase/migrations/20260720020000_conversational_rectification_billing.sql +++ b/frontend/supabase/migrations/20260720020000_conversational_rectification_billing.sql @@ -20,6 +20,7 @@ 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, @@ -27,6 +28,9 @@ 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, @@ -109,17 +113,83 @@ begin raise exception 'conversational_billing_failed' using errcode = 'P0001'; end if; - perform 1 - from public.birth_time_rectification_billing active_billing - where active_billing.user_id = p_user_id - and active_billing.case_id <> p_case_id - -- A charged row belongs to a created case; the unfinished-case check - -- above governs it. Only an orphan reservation has no case row to find. - and active_billing.state = 'reserved' - for update; - if found then - raise exception 'conversational_action_conflict' 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; select b.* into v_billing from public.birth_time_rectification_billing b @@ -136,10 +206,14 @@ begin ); insert into public.birth_time_rectification_action_receipts ( case_id, action_id, user_id, action_kind, expected_turn_version, - result_turn_version, request_fingerprint, response + result_turn_version, request_fingerprint, request, response ) values ( p_case_id, v_receipt_action_id, p_user_id, 'reserve_fee', 0, - 0, v_fingerprint, v_response + 0, v_fingerprint, + public.conversational_rectification_action_request( + 'reserve_fee', p_user_id, p_case_id, 0, p_action_id, v_fingerprint + ), + v_response ); return query select false, v_balance, null::text, 'insufficient_credits'::text; return; @@ -175,10 +249,14 @@ begin ); insert into public.birth_time_rectification_action_receipts ( case_id, action_id, user_id, action_kind, expected_turn_version, - result_turn_version, request_fingerprint, response + result_turn_version, request_fingerprint, request, response ) values ( p_case_id, v_receipt_action_id, p_user_id, 'reserve_fee', 0, - 0, v_fingerprint, v_response + 0, v_fingerprint, + public.conversational_rectification_action_request( + 'reserve_fee', p_user_id, p_case_id, 0, p_action_id, v_fingerprint + ), + v_response ); return query select true, v_balance, 'reserved'::text, null::text; @@ -314,10 +392,15 @@ begin ); insert into public.birth_time_rectification_action_receipts ( case_id, action_id, user_id, action_kind, expected_turn_version, - result_turn_version, request_fingerprint, response + result_turn_version, request_fingerprint, request, response ) values ( p_case_id, v_receipt_action_id, p_user_id, 'complete_fee', p_expected_version, - v_case.turn_version, v_fingerprint, v_response + v_case.turn_version, v_fingerprint, + public.conversational_rectification_action_request( + 'complete_fee', p_user_id, p_case_id, p_expected_version, + p_action_id, v_fingerprint + ), + v_response ); return query select v_success, v_balance, v_state, v_error_code; end; @@ -507,10 +590,15 @@ begin ); insert into public.birth_time_rectification_action_receipts ( case_id, action_id, user_id, action_kind, expected_turn_version, - result_turn_version, request_fingerprint, response + result_turn_version, request_fingerprint, request, response ) values ( p_case_id, v_receipt_action_id, p_user_id, 'release_fee', p_expected_version, - v_result_version, v_fingerprint, v_response + v_result_version, v_fingerprint, + public.conversational_rectification_action_request( + 'release_fee', p_user_id, p_case_id, p_expected_version, + p_action_id, v_fingerprint + ), + v_response ); return query select v_success, v_balance, v_state, v_error_code; end; diff --git a/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql b/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql index 441fb7a4..0261a4b9 100644 --- a/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql +++ b/frontend/supabase/migrations/20260720030000_conversational_rectification_transitions.sql @@ -286,13 +286,16 @@ begin end if; end if; - if pg_catalog.jsonb_typeof(p_declared_birth_input) is distinct from 'object' - or pg_catalog.octet_length(p_declared_birth_input::text) > 12000 - or pg_catalog.jsonb_typeof(p_first_turn) is distinct from 'object' - 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 nullif(p_declared_birth_input ->> 'birthDate', '') is null - or nullif(p_declared_birth_input ->> 'source', '') is null + if public.conversational_rectification_valid_declared_birth_input( + p_declared_birth_input + ) is not true + or public.conversational_rectification_valid_public_turn(p_first_turn) 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_first_turn ->> 'caseId' is distinct from p_case_id::text or p_first_turn ->> 'journeyProtocol' is distinct from 'conversational-evidence-v3' or (p_first_turn ->> 'turnVersion')::bigint is distinct from 0 @@ -347,10 +350,14 @@ begin v_response := public.conversational_rectification_case_projection(p_user_id, p_case_id); insert into public.birth_time_rectification_action_receipts ( case_id, action_id, user_id, action_kind, expected_turn_version, - result_turn_version, request_fingerprint, response + result_turn_version, request_fingerprint, request, response ) values ( p_case_id, p_action_id, p_user_id, 'create', p_expected_version, - 0, v_fingerprint, v_response + 0, v_fingerprint, + public.conversational_rectification_action_request( + 'create', p_user_id, p_case_id, p_expected_version, p_action_id, v_fingerprint + ), + v_response ); return v_response; end; @@ -491,10 +498,15 @@ begin v_response := public.conversational_rectification_case_projection(p_user_id, p_case_id); insert into public.birth_time_rectification_action_receipts ( case_id, action_id, user_id, action_kind, expected_turn_version, - result_turn_version, request_fingerprint, response + result_turn_version, request_fingerprint, request, response ) values ( p_case_id, p_action_id, p_user_id, 'save_turn', p_expected_version, - p_expected_version + 1, v_fingerprint, v_response + p_expected_version + 1, v_fingerprint, + public.conversational_rectification_action_request( + 'save_turn', p_user_id, p_case_id, p_expected_version, + p_action_id, v_fingerprint + ), + v_response ); return v_response; end; @@ -604,10 +616,14 @@ begin v_response := public.conversational_rectification_case_projection(p_user_id, p_case_id); insert into public.birth_time_rectification_action_receipts ( case_id, action_id, user_id, action_kind, expected_turn_version, - result_turn_version, request_fingerprint, response + result_turn_version, request_fingerprint, request, response ) values ( p_case_id, p_action_id, p_user_id, 'pause', p_expected_version, - p_expected_version + 1, v_fingerprint, v_response + p_expected_version + 1, v_fingerprint, + public.conversational_rectification_action_request( + 'pause', p_user_id, p_case_id, p_expected_version, p_action_id, v_fingerprint + ), + v_response ); return v_response; end; @@ -717,10 +733,14 @@ begin v_response := public.conversational_rectification_case_projection(p_user_id, p_case_id); insert into public.birth_time_rectification_action_receipts ( case_id, action_id, user_id, action_kind, expected_turn_version, - result_turn_version, request_fingerprint, response + result_turn_version, request_fingerprint, request, response ) values ( p_case_id, p_action_id, p_user_id, 'abandon', p_expected_version, - p_expected_version + 1, v_fingerprint, v_response + p_expected_version + 1, v_fingerprint, + public.conversational_rectification_action_request( + 'abandon', p_user_id, p_case_id, p_expected_version, p_action_id, v_fingerprint + ), + v_response ); return v_response; end; @@ -888,10 +908,14 @@ begin v_response := public.conversational_rectification_case_projection(p_user_id, p_case_id); insert into public.birth_time_rectification_action_receipts ( case_id, action_id, user_id, action_kind, expected_turn_version, - result_turn_version, request_fingerprint, response + result_turn_version, request_fingerprint, request, response ) values ( p_case_id, p_action_id, p_user_id, 'confirm', p_expected_version, - p_expected_version + 1, v_fingerprint, v_response + p_expected_version + 1, v_fingerprint, + public.conversational_rectification_action_request( + 'confirm', p_user_id, p_case_id, p_expected_version, p_action_id, v_fingerprint + ), + v_response ); return v_response; end; @@ -1012,27 +1036,40 @@ begin if not found then raise exception 'conversational_case_not_found' using errcode = 'P0001'; end if; - v_declared_birth_input := pg_catalog.jsonb_strip_nulls( - pg_catalog.jsonb_build_object( - 'birthDate', pg_catalog.to_char(v_legacy.reported_date, 'YYYY-MM-DD'), - 'reportedTime', case when v_legacy.reported_time is null then null - else pg_catalog.to_char(v_legacy.reported_time, 'HH24:MI') end, - 'reportedPeriod', v_legacy.reported_period, - 'source', v_legacy.source, - 'birthTimeClue', v_profile.birth_time_clue, - 'uncertaintyBeforeMinutes', v_legacy.uncertainty_before_minutes, - 'uncertaintyAfterMinutes', v_legacy.uncertainty_after_minutes, - 'birthplace', pg_catalog.jsonb_strip_nulls(pg_catalog.jsonb_build_object( - 'countryCode', v_profile.country_code, - 'provinceCode', v_profile.province_code, - 'cityCode', v_profile.city_code, - 'districtCode', v_profile.district_code, - 'latitude', v_profile.latitude, - 'longitude', v_profile.longitude, - 'timezoneOffset', v_profile.timezone_offset - )) - ) + -- 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. + v_declared_birth_input := pg_catalog.jsonb_build_object( + 'birthDate', pg_catalog.to_char(v_legacy.reported_date, 'YYYY-MM-DD'), + 'source', v_legacy.source, + 'birthTimeClue', v_profile.birth_time_clue, + 'birthplace', pg_catalog.jsonb_strip_nulls(pg_catalog.jsonb_build_object( + 'countryCode', v_profile.country_code, + 'provinceCode', v_profile.province_code, + 'cityCode', v_profile.city_code, + 'districtCode', v_profile.district_code, + 'latitude', v_profile.latitude, + 'longitude', v_profile.longitude, + 'timezoneOffset', v_profile.timezone_offset + )) ); + if v_legacy.reported_time is not null then + v_declared_birth_input := v_declared_birth_input || pg_catalog.jsonb_build_object( + 'reportedTime', pg_catalog.to_char(v_legacy.reported_time, 'HH24:MI') + ); + end if; + if v_legacy.reported_period is not null then + v_declared_birth_input := v_declared_birth_input || pg_catalog.jsonb_build_object( + 'reportedPeriod', v_legacy.reported_period + ); + end if; + if v_legacy.uncertainty_before_minutes is not null + or v_legacy.uncertainty_after_minutes is not null then + v_declared_birth_input := v_declared_birth_input || pg_catalog.jsonb_build_object( + 'uncertaintyBeforeMinutes', v_legacy.uncertainty_before_minutes, + 'uncertaintyAfterMinutes', v_legacy.uncertainty_after_minutes + ); + end if; select b.* into v_billing from public.birth_time_rectification_billing b where b.user_id = p_user_id and b.state = 'reserved' @@ -1040,9 +1077,16 @@ begin if found then raise exception 'conversational_action_conflict' using errcode = 'P0001'; end if; - if pg_catalog.jsonb_typeof(p_first_turn) is distinct from 'object' - or pg_catalog.jsonb_typeof(p_validation_receipt) is distinct from 'object' - or pg_catalog.jsonb_typeof(p_private_candidate) is distinct from 'object' + if public.conversational_rectification_valid_declared_birth_input( + v_declared_birth_input + ) is not true + or public.conversational_rectification_valid_public_turn(p_first_turn) 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_first_turn ->> 'caseId' is distinct from p_case_id::text or p_first_turn ->> 'journeyProtocol' is distinct from 'conversational-evidence-v3' or (p_first_turn ->> 'turnVersion')::bigint is distinct from 0 @@ -1104,10 +1148,15 @@ begin v_response := public.conversational_rectification_case_projection(p_user_id, p_case_id); insert into public.birth_time_rectification_action_receipts ( case_id, action_id, user_id, action_kind, expected_turn_version, - result_turn_version, request_fingerprint, response + result_turn_version, request_fingerprint, request, response ) values ( p_case_id, p_action_id, p_user_id, 'import_legacy', p_expected_version, - 0, v_fingerprint, v_response + 0, v_fingerprint, + public.conversational_rectification_action_request( + 'import_legacy', p_user_id, p_case_id, p_expected_version, + p_action_id, v_fingerprint + ), + v_response ); return v_response; end; diff --git a/frontend/tests/conversational-rectification-store.test.ts b/frontend/tests/conversational-rectification-store.test.ts index 67646f34..ae7abd5f 100644 --- a/frontend/tests/conversational-rectification-store.test.ts +++ b/frontend/tests/conversational-rectification-store.test.ts @@ -10,6 +10,14 @@ import { type ConversationalRectificationRpcClient, } from "../src/lib/conversational-rectification/store.ts"; import { ConversationalRectificationBilling } from "../src/lib/conversational-rectification/billing.ts"; +import { + conversationalRectificationActionReceiptRequestSchema, + conversationalRectificationActionReceiptResponseSchema, + declaredBirthInputSchema, + privateCandidateSchema, + validationReceiptSchema, +} from "../src/lib/conversational-rectification/persistence-contracts.ts"; +import { postgresJsonbTextBytes } from "../src/lib/conversational-rectification/json-bounds.ts"; const userId = "00000000-0000-4000-8000-000000000101"; const caseId = "00000000-0000-4000-8000-000000000102"; @@ -61,6 +69,8 @@ const storedRow = { reportedTime: "05:20", source: "approximate", birthTimeClue: "家人记得天刚亮", + uncertaintyBeforeMinutes: 30, + uncertaintyAfterMinutes: 30, birthplace: { countryCode: "TW", provinceCode: "TPE", @@ -114,6 +124,8 @@ test("creates an account-level case and first public turn through one RPC", asyn reportedTime: "05:20", source: "approximate", birthTimeClue: "家人记得天刚亮", + uncertaintyBeforeMinutes: 30, + uncertaintyAfterMinutes: 30, birthplace: { countryCode: "TW", provinceCode: "TPE", @@ -147,6 +159,8 @@ test("creates an account-level case and first public turn through one RPC", asyn reportedTime: "05:20", source: "approximate", birthTimeClue: "家人记得天刚亮", + uncertaintyBeforeMinutes: 30, + uncertaintyAfterMinutes: 30, birthplace: { countryCode: "TW", provinceCode: "TPE", @@ -212,10 +226,10 @@ test("binds every paid start to the public action as its recoverable case id", a expectedVersion: 0, revisionOfCaseId: null, pendingConsultationQuestion: null, - declaredBirthInput: { birthDate: "1990-01-01", source: "approximate" }, + declaredBirthInput: storedRow.declared_birth_input as never, firstTurn, validationReceipt, - privateCandidate: {}, + privateCandidate: storedRow.private_candidate, }), (error: unknown) => error instanceof ConversationalRectificationError && error.code === "action_conflict", @@ -402,3 +416,247 @@ test("billing rejections use stable domain errors instead of raw RPC messages", && error.code === "billing_failed", ); }); + +test("declared birth input is strict, source-aware, bounded, and location-complete", () => { + assert.equal(declaredBirthInputSchema.safeParse(storedRow.declared_birth_input).success, true); + const commonDeclaration = { + birthDate: "1990-01-01", + birthTimeClue: null, + birthplace: { + city: "Taipei", + latitude: 25.03, + longitude: 121.56, + timezoneOffset: 8, + }, + }; + for (const value of [ + { ...commonDeclaration, source: "hospital_record", reportedTime: "05:20", uncertaintyBeforeMinutes: 2, uncertaintyAfterMinutes: 2 }, + { ...commonDeclaration, source: "family_exact", reportedTime: "05:20", uncertaintyBeforeMinutes: 10, uncertaintyAfterMinutes: 10 }, + { ...commonDeclaration, source: "period_only", reportedPeriod: "morning" }, + { ...commonDeclaration, source: "unknown" }, + { ...commonDeclaration, source: "legacy_import", reportedTime: "05:20", uncertaintyBeforeMinutes: 0, uncertaintyAfterMinutes: 0 }, + ]) { + assert.equal(declaredBirthInputSchema.safeParse(value).success, true); + } + + const invalid = [ + { ...storedRow.declared_birth_input, unexpected: true }, + { ...storedRow.declared_birth_input, birthDate: "2023-02-30" }, + { ...storedRow.declared_birth_input, birthTimeClue: undefined }, + { ...storedRow.declared_birth_input, birthplace: undefined }, + { ...storedRow.declared_birth_input, source: "unrecognized" }, + { + ...storedRow.declared_birth_input, + uncertaintyAfterMinutes: 60, + }, + { + ...storedRow.declared_birth_input, + reportedPeriod: "morning", + }, + { + ...storedRow.declared_birth_input, + birthplace: { ...storedRow.declared_birth_input.birthplace, latitude: 91 }, + }, + { + ...storedRow.declared_birth_input, + birthplace: { timezoneOffset: 8 }, + }, + ]; + for (const value of invalid) { + assert.equal(declaredBirthInputSchema.safeParse(value).success, false); + } + const exactClue = declaredBirthInputSchema.parse({ + ...storedRow.declared_birth_input, + birthTimeClue: " exact clue spacing ", + }); + assert.equal(exactClue.birthTimeClue, " exact clue spacing "); +}); + +test("durable private and receipt schemas accept boundaries and reject oversize or unknown fields", () => { + assert.equal(postgresJsonbTextBytes({ a: [1, 2] }), 13); + assert.equal(validationReceiptSchema.safeParse({ + modelId: "m".repeat(120), + schemaValidated: true, + validatorVersion: "v".repeat(80), + retryCount: 2, + fallbackUsed: false, + issues: ["i".repeat(240)], + }).success, true); + assert.equal(validationReceiptSchema.safeParse({ + modelId: "m".repeat(121), + schemaValidated: true, + }).success, false); + assert.equal(validationReceiptSchema.safeParse({ + modelId: `${"m".repeat(120)} `, + schemaValidated: true, + }).success, false); + assert.equal(validationReceiptSchema.safeParse({ + modelId: "model", + schemaValidated: true, + validatedAt: "2026-07-20T12:00:00.123456789012345678901Z", + }).success, false); + + const candidate = { + resultId, + representativeTime: "05:21", + calculationVersion: "rectification-v3.1", + candidateWeights: Array.from({ length: 1_440 }, () => 0.5), + candidateModelRefs: ["model-ref"], + suggestedDomains: ["career", "relocation"], + }; + assert.equal(privateCandidateSchema.safeParse(candidate).success, true); + assert.equal(privateCandidateSchema.safeParse({ + ...candidate, + candidateWeights: [...candidate.candidateWeights, 0.5], + }).success, false); + assert.equal(privateCandidateSchema.safeParse({ + calculationVersion: "v1", + rangeStart: null, + }).success, false); + assert.equal(privateCandidateSchema.safeParse({ ...candidate, secretExtra: true }).success, false); + + const request = { + kind: "save_turn", + userId, + caseId, + expectedVersion: 0, + actionId, + requestFingerprint: "a".repeat(64), + }; + assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse(request).success, true); + assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse({ + ...request, + extra: "not durable", + }).success, false); + assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse({ + ...request, + requestFingerprint: "a".repeat(65), + }).success, false); + assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({ + success: false, + credits: 7, + billing_state: null, + error_code: "e".repeat(80), + }).success, true); + assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({ + success: false, + credits: 7, + billing_state: null, + error_code: "e".repeat(81), + }).success, false); +}); + +test("public turn JSON fields reject field and byte boundary violations", () => { + assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({ + ...storedRow, + declared_birth_input: undefined, + private_candidate: undefined, + event_evidence: undefined, + validation_receipts: undefined, + }).success, true); + + const exact = { + ...firstTurn, + technicalReceipt: { + ...firstTurn.technicalReceipt, + calculationVersion: "v".repeat(80), + }, + evidenceRecap: [{ + id: "00000000-0000-4000-8000-000000000108", + summary: "事".repeat(1_000), + dateLabel: "d".repeat(80), + }], + }; + assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({ + ...storedRow, + latest_turn: exact, + declared_birth_input: undefined, + private_candidate: undefined, + event_evidence: undefined, + validation_receipts: undefined, + }).success, true); + + const nearTurnLimit = { + ...exact, + narrative: "n".repeat(12_000), + evidenceRecap: Array.from({ length: 16 }, (_, index) => ({ + id: `00000000-0000-4000-8000-${(300 + index).toString().padStart(12, "0")}`, + summary: "事".repeat(1_000), + dateLabel: "d".repeat(80), + })), + }; + assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({ + ...storedRow, + latest_turn: nearTurnLimit, + declared_birth_input: undefined, + private_candidate: undefined, + event_evidence: undefined, + validation_receipts: undefined, + }).success, true); + + for (const latest_turn of [ + { + ...exact, + candidate: { ...exact.candidate, unknown: true }, + }, + { + ...exact, + technicalReceipt: { ...exact.technicalReceipt, calculationVersion: "v".repeat(81) }, + }, + { + ...exact, + evidenceRecap: [{ ...exact.evidenceRecap[0], summary: "事".repeat(1_001) }], + }, + { + ...exact, + narrative: "n".repeat(12_000), + evidenceRecap: Array.from({ length: 20 }, (_, index) => ({ + id: `00000000-0000-4000-8000-${(200 + index).toString().padStart(12, "0")}`, + summary: "事".repeat(1_000), + dateLabel: "d".repeat(80), + })), + }, + ]) { + assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({ + ...storedRow, + latest_turn, + declared_birth_input: undefined, + private_candidate: undefined, + event_evidence: undefined, + validation_receipts: undefined, + }).success, false); + } +}); + +test("store rejects invalid durable inputs before issuing an RPC", async () => { + let calls = 0; + const store = new ConversationalRectificationStore(rpcClient(() => { + calls += 1; + return storedRow; + })); + + await assert.rejects(store.createCaseWithFirstTurn({ + userId, + caseId, + actionId: caseId, + expectedVersion: 0, + revisionOfCaseId: null, + pendingConsultationQuestion: null, + declaredBirthInput: { + birthDate: "1990-01-01", + source: "approximate", + reportedTime: "05:20", + birthTimeClue: null, + uncertaintyBeforeMinutes: 30, + uncertaintyAfterMinutes: 30, + } as never, + firstTurn, + validationReceipt, + privateCandidate: { + resultId, + calculationVersion: "rectification-v3.1", + }, + }), (error: unknown) => error instanceof ConversationalRectificationError + && error.code === "action_conflict"); + assert.equal(calls, 0); +}); diff --git a/tests/test_conversational_rectification_contract.py b/tests/test_conversational_rectification_contract.py index 554b7955..f556aba9 100644 --- a/tests/test_conversational_rectification_contract.py +++ b/tests/test_conversational_rectification_contract.py @@ -262,6 +262,74 @@ def test_start_identity_and_account_concurrency_are_server_guarded() -> None: assert "raise exception 'conversational_action_conflict'" in create +def test_new_account_start_recovers_a_committed_pre_case_reservation() -> None: + reserve = _function(_normalized(BILLING), "reserve_conversational_rectification_fee") + + assert "orphan_billing.state = 'reserved'" in reserve + assert "orphan_case.id is null" in reserve + assert "set credits = profile.credits + v_orphan.price" in reserve + assert "'refund', v_orphan.price" in reserve + assert "set state = 'released'" in reserve + assert "'recover_fee'" in reserve + assert "v_orphan.case_id" in reserve + assert reserve.index("set state = 'released'") < reserve.index( + "set credits = profile.credits - p_price" + ) + + +def test_durable_json_columns_have_byte_and_field_shape_guards() -> None: + sql = _normalized(SCHEMA) + for validator in ( + "conversational_rectification_valid_candidate", + "conversational_rectification_valid_technical_receipt", + "conversational_rectification_valid_evidence_recap", + "conversational_rectification_valid_validation_receipt", + "conversational_rectification_valid_private_candidate", + "conversational_rectification_valid_action_request", + "conversational_rectification_valid_action_response", + ): + assert f"create or replace function public.{validator}" in sql + assert "octet_length" in _function(sql, validator) + + assert "request jsonb not null" in sql + assert "conversational_rectification_valid_candidate(candidate)" in sql + assert "conversational_rectification_valid_technical_receipt(technical_receipt)" in sql + assert "conversational_rectification_valid_evidence_recap(evidence_recap)" in sql + assert "conversational_rectification_valid_validation_receipt(output_validation_receipt)" in sql + assert "conversational_rectification_valid_action_request(request)" in sql + assert "conversational_rectification_valid_action_response(response, action_kind)" in sql + assert "conversational_rectification_valid_private_candidate(candidate_result)" in sql + + +def test_declared_birth_input_is_strict_source_aware_and_location_complete() -> None: + sql = _normalized(SCHEMA) + body = _function(sql, "conversational_rectification_valid_declared_birth_input") + + for invariant in ( + "octet_length", + "birthdate", + "birthtimeclue", + "birthplace", + "timezoneoffset", + "latitude", + "longitude", + "citycode", + "reportedtime", + "reportedperiod", + "uncertaintybeforeminutes", + "uncertaintyafterminutes", + "hospital_record", + "family_exact", + "approximate", + "period_only", + "unknown", + "legacy_import", + ): + assert invariant in body + assert "conversational_rectification_has_only_keys" in body + assert "conversational_rectification_valid_declared_birth_input(declared_birth_input)" in sql + + def test_one_public_start_action_has_noncolliding_internal_billing_receipts() -> None: billing = _normalized(BILLING) for name, kind in ( diff --git a/tests/test_conversational_rectification_postgres_runtime.py b/tests/test_conversational_rectification_postgres_runtime.py new file mode 100644 index 00000000..2a0179b1 --- /dev/null +++ b/tests/test_conversational_rectification_postgres_runtime.py @@ -0,0 +1,574 @@ +import json +import os +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +MIGRATIONS = ROOT / "frontend" / "supabase" / "migrations" +PG14_BIN_CANDIDATES = ( + Path(os.environ.get("PG14_BIN", "/path/that/does/not/exist")), + Path("/opt/homebrew/opt/postgresql@14/bin"), + Path("/usr/local/opt/postgresql@14/bin"), +) + + +def _postgres_14_bin() -> Path | None: + for candidate in PG14_BIN_CANDIDATES: + initdb = candidate / "initdb" + if not initdb.is_file(): + continue + version = subprocess.run( + [str(initdb), "--version"], + check=False, + capture_output=True, + text=True, + ) + if version.returncode == 0 and " 14." in version.stdout: + return candidate + return None + + +PG14_BIN = _postgres_14_bin() +pytestmark = pytest.mark.skipif(PG14_BIN is None, reason="PostgreSQL 14 binaries are unavailable") + + +@dataclass(frozen=True) +class PgDatabase: + bin_dir: Path + socket_dir: Path + port: int + + def command(self, *extra: str) -> list[str]: + return [ + str(self.bin_dir / "psql"), + "-X", + "-v", + "ON_ERROR_STOP=1", + "-h", + str(self.socket_dir), + "-p", + str(self.port), + "-U", + "postgres", + "-d", + "postgres", + *extra, + ] + + def sql(self, statement: str) -> str: + completed = subprocess.run( + self.command("-A", "-t", "-q", "-c", statement), + check=False, + capture_output=True, + text=True, + errors="replace", + ) + assert completed.returncode == 0, completed.stderr + return completed.stdout.strip() + + def rejects(self, statement: str) -> bool: + completed = subprocess.run( + self.command("-A", "-t", "-q", "-c", statement), + check=False, + capture_output=True, + text=True, + errors="replace", + ) + return completed.returncode != 0 + + +@pytest.fixture(scope="module") +def pg14_database() -> PgDatabase: + assert PG14_BIN is not None + with tempfile.TemporaryDirectory( + prefix="rectification-pg14-", + dir="/private/tmp", + ) as temporary_root: + root = Path(temporary_root) + data_dir = root / "data" + socket_dir = root / "socket" + socket_dir.mkdir() + port = 55439 + + init = subprocess.run( + [ + str(PG14_BIN / "initdb"), + "-D", + str(data_dir), + "-A", + "trust", + "-U", + "postgres", + "--no-locale", + "-E", + "UTF8", + ], + check=False, + capture_output=True, + text=True, + ) + assert init.returncode == 0, f"{init.stdout}\n{init.stderr}" + start = subprocess.run( + [ + str(PG14_BIN / "pg_ctl"), + "-D", + str(data_dir), + "-l", + str(root / "postgres.log"), + "-o", + f"-F -k {socket_dir} -p {port} -c listen_addresses=''", + "-w", + "start", + ], + check=False, + capture_output=True, + text=True, + ) + assert start.returncode == 0, f"{start.stdout}\n{start.stderr}" + + database = PgDatabase(PG14_BIN, socket_dir, port) + try: + database.sql( + """ + create role anon nologin; + create role authenticated nologin; + create role service_role nologin; + create schema auth; + create table auth.users ( + id uuid primary key, + email text + ); + create function auth.uid() returns uuid + language sql stable set search_path = '' + as 'select null::uuid'; + create function auth.jwt() returns jsonb + language sql stable set search_path = '' + as 'select ''{}''::jsonb'; + """ + ) + for migration in sorted(MIGRATIONS.glob("*.sql")): + applied = subprocess.run( + database.command("-q", "-f", str(migration)), + check=False, + capture_output=True, + text=True, + ) + assert applied.returncode == 0, f"{migration.name}:\n{applied.stderr}" + yield database + finally: + subprocess.run( + [str(PG14_BIN / "pg_ctl"), "-D", str(data_dir), "-m", "fast", "-w", "stop"], + check=False, + capture_output=True, + text=True, + ) + + +def _jsonb(value: object) -> str: + encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")).replace("'", "''") + return f"'{encoded}'::jsonb" + + +def _text(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def _create_user(database: PgDatabase, user_id: str, credits: int = 20) -> None: + database.sql( + f""" + insert into auth.users (id, email) + values ('{user_id}'::uuid, 'synthetic@example.invalid'); + update public.profiles + set credits = {credits}, + birth_date = '1990-01-01', + active_birth_time = '04:58', + birth_time = '04:58', + country_code = 'TW', + province_code = 'TPE', + city_code = 'TPE-CITY', + district_code = 'DAAN', + latitude = 25.0268, + longitude = 121.5434, + timezone_offset = 8 + where id = '{user_id}'::uuid; + """ + ) + + +def _reserve(database: PgDatabase, user_id: str, action_id: str, price: int = 3) -> dict[str, object]: + result = database.sql( + f""" + select row_to_json(reservation)::text + from public.reserve_conversational_rectification_fee( + '{user_id}'::uuid, + '{action_id}'::uuid, + 0, + '{action_id}'::uuid, + {price} + ) reservation; + """ + ) + return json.loads(result) + + +def _valid_declared_birth_input() -> dict[str, object]: + return { + "birthDate": "1990-01-01", + "reportedTime": "05:20", + "source": "approximate", + "birthTimeClue": "synthetic dawn clue", + "uncertaintyBeforeMinutes": 30, + "uncertaintyAfterMinutes": 30, + "birthplace": { + "countryCode": "TW", + "provinceCode": "TPE", + "cityCode": "TPE-CITY", + "districtCode": "DAAN", + "latitude": 25.0268, + "longitude": 121.5434, + "timezoneOffset": 8, + }, + } + + +def _valid_turn(case_id: str) -> dict[str, object]: + return { + "caseId": case_id, + "journeyProtocol": "conversational-evidence-v3", + "status": "active", + "turnVersion": 0, + "narrative": "Synthetic public narrative.", + "candidate": { + "status": "pending_validation", + "representativeTime": "05:21", + "rangeStart": "05:10", + "rangeEnd": "05:30", + }, + "technicalReceipt": { + "calculationVersion": "rectification-v3.1", + "stableLayers": ["D1"], + "sensitiveLayers": ["D9"], + "candidateDifferenceRefs": ["difference-1"], + }, + "evidenceRequest": { + "domains": ["career", "relocation"], + "datePrecision": "month_preferred", + "freeTextAllowed": True, + }, + "evidenceRecap": [], + "actions": ["answer", "pause", "abandon"], + "pendingConsultationQuestion": None, + } + + +def _valid_private_candidate() -> dict[str, object]: + return { + "resultId": "00000000-0000-4000-8000-000000000991", + "representativeTime": "05:21", + "rangeStart": "05:10", + "rangeEnd": "05:30", + "calculationVersion": "rectification-v3.1", + "candidateWeights": [0.6, 0.4], + "candidateModelRefs": ["model-1"], + "suggestedDomains": ["career", "relocation"], + } + + +def _create_case( + database: PgDatabase, + user_id: str, + action_id: str, + declared_birth_input: dict[str, object], + private_candidate: dict[str, object] | None = None, +) -> str: + return database.sql( + f""" + select public.create_conversational_rectification_case( + '{user_id}'::uuid, + '{action_id}'::uuid, + 0, + '{action_id}'::uuid, + null, + null, + {_jsonb(declared_birth_input)}, + {_jsonb(_valid_turn(action_id))}, + {_jsonb({"modelId": "synthetic-model", "schemaValidated": True})}, + {_jsonb(private_candidate or _valid_private_candidate())} + )::text; + """ + ) + + +def test_committed_pre_case_reservation_is_recovered_by_a_fresh_account_action( + pg14_database: PgDatabase, +) -> None: + user_id = "00000000-0000-4000-8000-000000000901" + lost_action = "00000000-0000-4000-8000-000000000902" + fresh_action = "00000000-0000-4000-8000-000000000903" + _create_user(pg14_database, user_id, credits=10) + + first = _reserve(pg14_database, user_id, lost_action) + assert first == { + "success": True, + "credits": 7, + "billing_state": "reserved", + "error_code": None, + } + assert pg14_database.sql( + f"select public.load_conversational_rectification_case('{user_id}'::uuid, null) is null" + ) == "t" + + recovered = _reserve(pg14_database, user_id, fresh_action) + assert recovered == first + accounting = json.loads(pg14_database.sql( + f""" + select jsonb_build_object( + 'credits', profile.credits, + 'oldState', old_billing.state, + 'newState', new_billing.state, + 'reserveCount', count(*) filter (where tx.transaction_type = 'reserve'), + 'refundCount', count(*) filter (where tx.transaction_type = 'refund') + )::text + from public.profiles profile + join public.birth_time_rectification_billing old_billing + on old_billing.case_id = '{lost_action}'::uuid + join public.birth_time_rectification_billing new_billing + on new_billing.case_id = '{fresh_action}'::uuid + left join public.credit_transactions tx on tx.user_id = profile.id + where profile.id = '{user_id}'::uuid + group by profile.credits, old_billing.state, new_billing.state; + """ + )) + assert accounting == { + "credits": 7, + "oldState": "released", + "newState": "reserved", + "reserveCount": 2, + "refundCount": 1, + } + + assert _reserve(pg14_database, user_id, lost_action) == first + assert pg14_database.sql( + f"select count(*) from public.credit_transactions where user_id = '{user_id}'::uuid" + ) == "3" + + +@pytest.mark.parametrize( + ("user_id", "action_id", "declared"), + [ + ( + "00000000-0000-4000-8000-000000000911", + "00000000-0000-4000-8000-000000000912", + { + "birthDate": "1990-01-01", + "reportedTime": "05:20", + "source": "approximate", + "birthTimeClue": None, + "uncertaintyBeforeMinutes": 30, + "uncertaintyAfterMinutes": 30, + }, + ), + ( + "00000000-0000-4000-8000-000000000921", + "00000000-0000-4000-8000-000000000922", + {**_valid_declared_birth_input(), "unknownField": True}, + ), + ( + "00000000-0000-4000-8000-000000000931", + "00000000-0000-4000-8000-000000000932", + { + **_valid_declared_birth_input(), + "uncertaintyAfterMinutes": 60, + "birthplace": {**_valid_declared_birth_input()["birthplace"], "latitude": 91}, + }, + ), + ], +) +def test_invalid_declared_birth_input_is_rejected_before_case_persistence( + pg14_database: PgDatabase, + user_id: str, + action_id: str, + declared: dict[str, object], +) -> None: + _create_user(pg14_database, user_id) + _reserve(pg14_database, user_id, action_id) + assert pg14_database.rejects( + f""" + select public.create_conversational_rectification_case( + '{user_id}'::uuid, '{action_id}'::uuid, 0, '{action_id}'::uuid, + null, null, {_jsonb(declared)}, {_jsonb(_valid_turn(action_id))}, + {_jsonb({"modelId": "synthetic-model", "schemaValidated": True})}, + {_jsonb(_valid_private_candidate())} + ); + """ + ) + assert pg14_database.sql( + f"select count(*) from public.birth_time_rectification_cases where id = '{action_id}'::uuid" + ) == "0" + + +def test_valid_declared_birth_input_round_trips_across_account_load(pg14_database: PgDatabase) -> None: + user_id = "00000000-0000-4000-8000-000000000941" + action_id = "00000000-0000-4000-8000-000000000942" + declared = _valid_declared_birth_input() + _create_user(pg14_database, user_id) + _reserve(pg14_database, user_id, action_id) + _create_case(pg14_database, user_id, action_id, declared) + + loaded = json.loads(pg14_database.sql( + f"select public.load_conversational_rectification_case('{user_id}'::uuid, null)::text" + )) + assert loaded["declared_birth_input"] == declared + + +def test_database_rejects_oversize_or_unknown_durable_json(pg14_database: PgDatabase) -> None: + user_id = "00000000-0000-4000-8000-000000000951" + action_id = "00000000-0000-4000-8000-000000000952" + _create_user(pg14_database, user_id) + _reserve(pg14_database, user_id, action_id) + _create_case(pg14_database, user_id, action_id, _valid_declared_birth_input()) + + base_turn = _valid_turn(action_id) + boundary_turn = { + **base_turn, + "turnVersion": 1, + "technicalReceipt": { + **base_turn["technicalReceipt"], + "calculationVersion": "v" * 80, + }, + "evidenceRecap": [ + { + "id": "00000000-0000-4000-8000-000000000953", + "summary": "事" * 1_000, + "dateLabel": "d" * 80, + } + ], + } + pg14_database.sql( + f""" + begin; + insert into public.birth_time_rectification_turns ( + case_id, turn_version, narrative, candidate, technical_receipt, + evidence_request, evidence_recap, actions, output_validation_receipt + ) values ( + '{action_id}'::uuid, 1, {_text(str(boundary_turn['narrative']))}, + {_jsonb(boundary_turn['candidate'])}, {_jsonb(boundary_turn['technicalReceipt'])}, + {_jsonb(boundary_turn['evidenceRequest'])}, {_jsonb(boundary_turn['evidenceRecap'])}, + {_jsonb(boundary_turn['actions'])}, + {_jsonb({'modelId': 'm' * 120, 'schemaValidated': True})} + ); + update public.birth_time_rectification_cases + set candidate_result = {_jsonb({ + **_valid_private_candidate(), + 'candidateWeights': [0.5] * 1_440, + })} + where id = '{action_id}'::uuid; + rollback; + """ + ) + invalid_turns = [ + {**base_turn, "turnVersion": 1, "candidate": {**base_turn["candidate"], "extra": True}}, + { + **base_turn, + "turnVersion": 1, + "technicalReceipt": { + **base_turn["technicalReceipt"], + "calculationVersion": "v" * 81, + }, + }, + { + **base_turn, + "turnVersion": 1, + "technicalReceipt": { + **base_turn["technicalReceipt"], + "calculationVersion": "v" * 80 + " ", + }, + }, + { + **base_turn, + "turnVersion": 1, + "evidenceRecap": [{ + "id": "00000000-0000-4000-8000-000000000953", + "summary": "事" * 1_001, + "dateLabel": "2020-01", + }], + }, + ] + for turn in invalid_turns: + assert pg14_database.rejects( + f""" + begin; + insert into public.birth_time_rectification_turns ( + case_id, turn_version, narrative, candidate, technical_receipt, + evidence_request, evidence_recap, actions, output_validation_receipt + ) values ( + '{action_id}'::uuid, 1, {_text(str(turn['narrative']))}, + {_jsonb(turn['candidate'])}, {_jsonb(turn['technicalReceipt'])}, + {_jsonb(turn['evidenceRequest'])}, {_jsonb(turn['evidenceRecap'])}, + {_jsonb(turn['actions'])}, + {_jsonb({"modelId": "synthetic-model", "schemaValidated": True})} + ); + rollback; + """ + ) + + assert pg14_database.rejects( + f""" + update public.birth_time_rectification_cases + set candidate_result = {_jsonb({ + **_valid_private_candidate(), + "candidateWeights": [0.5] * 1_441, + })} + where id = '{action_id}'::uuid; + """ + ) + assert pg14_database.rejects( + f""" + begin; + insert into public.birth_time_rectification_turns ( + case_id, turn_version, narrative, candidate, technical_receipt, + evidence_request, evidence_recap, actions, output_validation_receipt + ) values ( + '{action_id}'::uuid, 1, 'Synthetic narrative', + {_jsonb(base_turn['candidate'])}, {_jsonb(base_turn['technicalReceipt'])}, + {_jsonb(base_turn['evidenceRequest'])}, '[]'::jsonb, + {_jsonb(base_turn['actions'])}, + {_jsonb({"modelId": "m" * 121, "schemaValidated": True})} + ); + rollback; + """ + ) + assert pg14_database.sql( + f""" + select public.conversational_rectification_valid_action_response( + {_jsonb({ + "success": True, + "credits": 7, + "billing_state": "reserved", + "error_code": None, + "extra": "x" * 70_000, + })}, + 'reserve_fee' + )::text; + """ + ) == "false" + assert pg14_database.rejects( + f""" + update public.birth_time_rectification_action_receipts + set request = request - 'caseId' + where user_id = '{user_id}'::uuid and action_kind = 'reserve_fee'; + """ + ) + assert pg14_database.rejects( + f""" + update public.birth_time_rectification_action_receipts + set response = pg_catalog.jsonb_set( + response, '{{error_code}}', {_jsonb('e' * 81)}, true + ) + where user_id = '{user_id}'::uuid and action_kind = 'reserve_fee'; + """ + )