fix conversational rectification persistence gaps
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user