fix conversational rectification persistence gaps

This commit is contained in:
Jesse_Chen
2026-07-20 22:08:08 +08:00
parent 23596851c1
commit e16746fb65
11 changed files with 2338 additions and 170 deletions
@@ -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<Record<string, unknown>> {
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);
@@ -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<typeof conversationalRectificationCommandSchema>;
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<typeof conversationalRectificationTurnSchema>;
@@ -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<T>(schema: z.ZodType<T>, maximumBytes: number): z.ZodType<T> {
return schema.superRefine((value, context) => {
if (postgresJsonbTextBytes(value) > maximumBytes) {
context.addIssue({
code: "custom",
message: `PostgreSQL JSON exceeds ${maximumBytes} UTF-8 bytes`,
});
}
});
}
@@ -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<typeof declaredBirthInputSchema>;
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<typeof privateCandidateSchema>;
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<typeof validationReceiptSchema>;
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<typeof lifeEventEvidenceSchema>;
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),
]);
@@ -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> = T extends (...args: never[]) => unknown
: T;
export type ConversationalRectificationTurnInput = DeepReadonly<ConversationalRectificationTurn>;
export type PrivateCandidateInput = Readonly<Record<string, unknown>>;
export type ValidationReceiptInput = Readonly<Record<string, unknown>>;
export type PrivateCandidateInput = DeepReadonly<PrivateCandidate>;
export type ValidationReceiptInput = DeepReadonly<ValidationReceipt>;
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<Record<string, unknown>>;
privateCandidate?: Readonly<Record<string, unknown>>;
declaredBirthInput?: DeepReadonly<DeclaredBirthInput>;
privateCandidate?: DeepReadonly<PrivateCandidate>;
eventEvidence?: ReadonlyArray<LifeEventEvidenceInput>;
validationReceipts?: ReadonlyArray<Readonly<Record<string, unknown>>>;
validationReceipts?: ReadonlyArray<DeepReadonly<ValidationReceipt>>;
}>;
export type LoadedConversationalRectificationCase =
StoredConversationalRectificationCase & Readonly<{
declaredBirthInput: Readonly<Record<string, unknown>>;
privateCandidate: Readonly<Record<string, unknown>>;
declaredBirthInput: DeepReadonly<DeclaredBirthInput>;
privateCandidate: DeepReadonly<PrivateCandidate>;
eventEvidence: ReadonlyArray<LifeEventEvidenceInput>;
validationReceipts: ReadonlyArray<Readonly<Record<string, unknown>>>;
validationReceipts: ReadonlyArray<DeepReadonly<ValidationReceipt>>;
}>;
type MutationIdentity = Readonly<{
@@ -70,22 +81,13 @@ type MutationIdentity = Readonly<{
export type CreateConversationalRectificationCaseInput = MutationIdentity & Readonly<{
revisionOfCaseId: string | null;
pendingConsultationQuestion: string | null;
declaredBirthInput: Readonly<Record<string, unknown>>;
declaredBirthInput: DeepReadonly<DeclaredBirthInput>;
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<LifeEventEvidence>;
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>): 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<LifeEventEvidenceInput>): ReadonlyArray<LifeEventEvidence> {
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<Record<string, unknown>> {
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;