fix(rectification): address task 7 review findings

This commit is contained in:
Jesse_Chen
2026-07-21 02:53:40 +08:00
parent 9e64b71710
commit 1112d8460b
11 changed files with 894 additions and 86 deletions
@@ -13,7 +13,11 @@ import {
type ConversationalRectificationPacketBuildInput,
type ConversationalRectificationService,
} from "../../../lib/conversational-rectification/orchestrator.ts";
import type { DeclaredBirthInput, LifeEventEvidence } from "../../../lib/conversational-rectification/persistence-contracts.ts";
import {
declaredBirthInputSchema,
type DeclaredBirthInput,
type LifeEventEvidence,
} from "../../../lib/conversational-rectification/persistence-contracts.ts";
import type { RectificationNarrativeGenerator } from "../../../lib/conversational-rectification/narrative-agent.ts";
import type { BirthTimeJourneyEngine, RectificationQuestionnaire } from "../../../lib/birth-time-journey-service.ts";
import type { CandidateResult, LifeEvent } from "../../../lib/birth-time-evidence.ts";
@@ -92,10 +96,7 @@ function integer(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) ? value : null;
}
function declaredBirthInputFromProfile(value: unknown): {
readonly declaredBirthInput: unknown;
readonly revisionOfCaseId: string | null;
} {
function declaredBirthInputFromProfile(value: unknown): DeclaredBirthInput {
const profile = profileRecord(value);
if (!profile) throw new ConversationalRectificationError("profile_incomplete");
const birthDate = text(profile.birth_date);
@@ -160,9 +161,38 @@ function declaredBirthInputFromProfile(value: unknown): {
default:
throw new ConversationalRectificationError("profile_incomplete");
}
const parsed = declaredBirthInputSchema.safeParse(declaredBirthInput);
if (!parsed.success) throw new ConversationalRectificationError("profile_incomplete");
return parsed.data;
}
export type ProductionConversationalRectificationProfileDependencies = Readonly<{
loadProfile(userId: string): Promise<unknown>;
loadRectificationCase(userId: string, caseId: string): Promise<unknown>;
}>;
export async function loadProductionConversationalRectificationProfile(
dependencies: ProductionConversationalRectificationProfileDependencies,
userId: string,
): Promise<Readonly<{
declaredBirthInput: DeclaredBirthInput;
revisionOfCaseId: string | null;
}>> {
const profileValue = await dependencies.loadProfile(userId);
const profile = profileRecord(profileValue);
if (!profile) throw new ConversationalRectificationError("profile_incomplete");
const declaredBirthInput = declaredBirthInputFromProfile(profile);
const priorCaseId = text(profile.rectification_case_id);
if (!priorCaseId) return { declaredBirthInput, revisionOfCaseId: null };
const prior = profileRecord(await dependencies.loadRectificationCase(userId, priorCaseId));
const terminalV3Revision = prior
&& text(prior.id) === priorCaseId
&& text(prior.journey_protocol) === "conversational-evidence-v3"
&& (text(prior.status) === "completed" || text(prior.status) === "abandoned");
return {
declaredBirthInput,
revisionOfCaseId: text(profile.rectification_case_id),
revisionOfCaseId: terminalV3Revision ? priorCaseId : null,
};
}
@@ -195,12 +225,12 @@ function declaredRange(input: DeclaredBirthInput): { readonly startTime: string;
late_night: { startTime: "23:00", endTime: "03:59" },
}[input.reportedPeriod];
}
if (input.source === "unknown") return { startTime: "00:01", endTime: "23:59" };
if (input.source === "unknown") return { startTime: "00:00", endTime: "23:59" };
if (input.source === "legacy_import" && !input.reportedTime) {
if (input.reportedPeriod) {
return declaredRange({ ...input, source: "period_only", reportedPeriod: input.reportedPeriod });
}
return { startTime: "00:01", endTime: "23:59" };
return { startTime: "00:00", endTime: "23:59" };
}
const reportedTime = input.reportedTime;
if (!reportedTime) throw new ConversationalRectificationError("profile_incomplete");
@@ -223,6 +253,28 @@ function scanCoordinates(range: { readonly startTime: string; readonly endTime:
};
}
function boundedScanRanges(range: { readonly startTime: string; readonly endTime: string }) {
const start = minute(range.startTime);
let end = minute(range.endTime);
if (end < start) end += 1_440;
if (end - start <= 360) return [range];
const ranges: Array<{ readonly startTime: string; readonly endTime: string }> = [];
let cursor = start;
while (end - cursor > 360) {
ranges.push({ startTime: clock(cursor), endTime: clock(cursor + 360) });
cursor += 360;
}
if (cursor < end) {
// A symmetric integer-minute scan needs an even endpoint span. Pull an
// odd final span back by one minute, overlapping rather than inventing a
// minute outside the user's declared range.
const finalStart = (end - cursor) % 2 === 0 ? cursor : cursor - 1;
ranges.push({ startTime: clock(finalStart), endTime: clock(end) });
}
return ranges;
}
function currentRange(input: ConversationalRectificationPacketBuildInput) {
const start = input.privateCandidate?.rangeStart;
const end = input.privateCandidate?.rangeEnd;
@@ -262,6 +314,65 @@ function sampleTimes(scan: RectificationQuestionnaire): readonly { readonly samp
return links;
}
function timeOffsetFromRangeStart(time: string, rangeStart: string): number {
const start = minute(rangeStart);
let value = minute(time);
if (value < start) value += 1_440;
return value - start;
}
function timeIsInsideRange(
time: string,
range: { readonly startTime: string; readonly endTime: string },
): boolean {
const offset = timeOffsetFromRangeStart(time, range.startTime);
const endOffset = timeOffsetFromRangeStart(range.endTime, range.startTime);
return offset <= endOffset;
}
function mergeQuestionnaireScans(
scans: readonly RectificationQuestionnaire[],
range: { readonly startTime: string; readonly endTime: string },
): RectificationQuestionnaire {
const first = scans[0];
if (!first) throw new ConversationalRectificationError("service_unavailable");
if (scans.length === 1) return first;
const byTime = new Map<string, {
sample: RectificationQuestionnaire["samples"][number];
rawSample: unknown;
}>();
const questions = new Map<string, RectificationQuestionnaire["questions"][number]>();
for (const scan of scans) {
for (const question of scan.questions) {
if (!questions.has(question.id)) questions.set(question.id, question);
}
const rawCandidateScan = profileRecord(scan.raw.candidate_scan);
const rawSamples = Array.isArray(rawCandidateScan?.samples) ? rawCandidateScan.samples : [];
for (const link of sampleTimes(scan)) {
const sample = scan.samples[link.sampleIndex];
const rawSample = rawSamples[link.sampleIndex];
if (!sample || rawSample === undefined || !timeIsInsideRange(link.time, range)) continue;
if (!byTime.has(link.time)) byTime.set(link.time, { sample, rawSample });
}
}
const merged = [...byTime.entries()].sort(([left], [right]) =>
timeOffsetFromRangeStart(left, range.startTime)
- timeOffsetFromRangeStart(right, range.startTime));
const firstCandidateScan = profileRecord(first.raw.candidate_scan) ?? {};
return {
questions: [...questions.values()],
samples: merged.map(([, item]) => item.sample),
raw: {
...first.raw,
candidate_scan: {
...firstCandidateScan,
samples: merged.map(([, item]) => item.rawSample),
},
},
};
}
function layerMetadata(scan: RectificationQuestionnaire, calculationVersion: string) {
const layers = [
["D1", "ascendantSign"],
@@ -292,7 +403,7 @@ function boundaryDistance(range: { readonly startTime: string; readonly endTime:
return Math.max(0, Math.min(value - start, end - value));
}
async function buildProductionPacket(
export async function buildProductionConversationalRectificationPacket(
engine: BirthTimeJourneyEngine,
input: ConversationalRectificationPacketBuildInput,
) {
@@ -316,15 +427,20 @@ async function buildProductionPacket(
const selectedRange = eventScore?.winningSegment
? { startTime: eventScore.winningSegment.startTime, endTime: eventScore.winningSegment.endTime }
: baseRange;
const scanPoint = scanCoordinates(selectedRange);
const { questionnaire } = await engine.scan({
birthTime: `${input.declaredBirthInput.birthDate} ${scanPoint.centerTime}`,
uncertaintyMinutes: scanPoint.uncertaintyMinutes,
lat: place.latitude,
lon: place.longitude,
tz: place.timezoneOffset,
ayanamsa: "lahiri",
});
const questionnaires: RectificationQuestionnaire[] = [];
for (const scanRange of boundedScanRanges(selectedRange)) {
const scanPoint = scanCoordinates(scanRange);
const { questionnaire } = await engine.scan({
birthTime: `${input.declaredBirthInput.birthDate} ${scanPoint.centerTime}`,
uncertaintyMinutes: scanPoint.uncertaintyMinutes,
lat: place.latitude,
lon: place.longitude,
tz: place.timezoneOffset,
ayanamsa: "lahiri",
});
questionnaires.push(questionnaire);
}
const questionnaire = mergeQuestionnaireScans(questionnaires, selectedRange);
const candidateDifferences = await engine.buildDifferencePacket({
caseId: input.caseId,
asOfDate: input.asOfDate,
@@ -346,7 +462,7 @@ async function buildProductionPacket(
: candidateDifferences.packet.scoringVersion;
const metadata = layerMetadata(questionnaire, calculationVersion);
const representative = eventScore?.winningSegment?.representativeTime
?? scanPoint.centerTime;
?? scanCoordinates(selectedRange).centerTime;
const { buildRectificationTechnicalPacket } = await import(
"../../../lib/conversational-rectification/technical-packet.ts"
);
@@ -420,15 +536,29 @@ async function createProductionService(
billing: createSupabaseConversationalRectificationBilling(admin),
get rectificationPriceCredits() { return priceCredits(); },
async loadDeclaredProfile(userId) {
const { data, error } = await profileClient
.from("profiles")
.select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,rectification_case_id")
.eq("id", userId)
.maybeSingle();
if (error) throw new ConversationalRectificationError("store_unavailable");
return declaredBirthInputFromProfile(data);
return loadProductionConversationalRectificationProfile({
async loadProfile(receivedUserId) {
const { data, error } = await profileClient
.from("profiles")
.select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,rectification_case_id")
.eq("id", receivedUserId)
.maybeSingle();
if (error) throw new ConversationalRectificationError("store_unavailable");
return data;
},
async loadRectificationCase(receivedUserId, receivedCaseId) {
const { data, error } = await admin
.from("birth_time_rectification_cases")
.select("id,journey_protocol,status")
.eq("id", receivedCaseId)
.eq("user_id", receivedUserId)
.maybeSingle();
if (error) throw new ConversationalRectificationError("store_unavailable");
return data;
},
}, userId);
},
buildTechnicalPacket: (input) => buildProductionPacket(engine, input),
buildTechnicalPacket: (input) => buildProductionConversationalRectificationPacket(engine, input),
narrativeGenerator,
asOfDate: () => new Date().toISOString().slice(0, 10),
});
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import {
conversationalRectificationCommandSchema,
conversationalRectificationTurnSchema,
@@ -60,7 +61,7 @@ export type ConversationalRectificationPacketBuildInput = Readonly<{
export type ConversationalRectificationServicePorts = Readonly<{
store: Pick<ConversationalRectificationStore,
"createCaseWithFirstTurn" | "loadCase" | "saveTurn" | "pause" | "abandon" | "confirm">;
"createCaseWithFirstTurn" | "loadCase" | "loadActionReceipt" | "saveTurn" | "pause" | "abandon" | "confirm">;
billing: Pick<ConversationalRectificationBilling, "reserve" | "complete" | "release">;
rectificationPriceCredits: number;
loadDeclaredProfile(userId: string): Promise<ConversationalRectificationProfile>;
@@ -81,7 +82,8 @@ export type ConversationalRectificationService = Readonly<{
}>;
const transitionValidatorVersion = "conversational-rectification-orchestrator-v1";
const directionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不知道|不确定)/;
const explicitDirectionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不想(?:谈|说|回答)|拒绝回答)/;
const genericUncertaintyPattern = /(?:不知道|不确定)/;
function safeFailure(error: unknown): ConversationalRectificationError {
return error instanceof ConversationalRectificationError
@@ -100,6 +102,20 @@ function parseCommand<Type extends ConversationalRectificationCommand["type"]>(
return parsed.data as CommandOf<Type>;
}
type MutableCommand = Extract<ConversationalRectificationCommand, {
readonly type: "answer" | "pause" | "abandon" | "confirm";
}>;
function commandFingerprint(command: MutableCommand): string {
const identity = command.type === "answer"
? [command.type, command.caseId, command.actionId, command.turnVersion,
command.domain ?? null, command.answer]
: command.type === "confirm"
? [command.type, command.caseId, command.actionId, command.turnVersion, command.time]
: [command.type, command.caseId, command.actionId, command.turnVersion];
return createHash("sha256").update(JSON.stringify(identity), "utf8").digest("hex");
}
function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationTurn {
const parsed = conversationalRectificationTurnSchema.safeParse(value.latestTurn);
if (!parsed.success) throw new ConversationalRectificationError("store_unavailable");
@@ -347,6 +363,27 @@ export function createConversationalRectificationService(
}
}
async function replayMutation(
userId: string,
command: MutableCommand,
actionKind: "save_turn" | "pause" | "abandon" | "confirm",
fingerprint: string,
): Promise<ConversationalRectificationTurn | null> {
try {
const receipt = await ports.store.loadActionReceipt({
userId,
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
actionKind,
commandFingerprint: fingerprint,
});
return receipt ? publicTurn(receipt) : null;
} catch (error) {
throw safeFailure(error);
}
}
function extractedEvidence(command: CommandOf<"answer">): readonly LifeEventEvidence[] {
let extracted: readonly LifeEventEvidence[];
try {
@@ -505,12 +542,14 @@ export function createConversationalRectificationService(
async resume(userId, rawCommand) {
const command = parseCommand("resume", rawCommand);
const current = await load(userId, command.caseId);
requireExactVersion(current, command.turnVersion);
return publicTurn(current);
},
async answer(userId, rawCommand) {
const command = parseCommand("answer", rawCommand);
const fingerprint = commandFingerprint(command);
const receipt = await replayMutation(userId, command, "save_turn", fingerprint);
if (receipt) return receipt;
const current = await load(userId, command.caseId);
requireMutable(current);
const evidence = extractedEvidence(command);
@@ -522,6 +561,7 @@ export function createConversationalRectificationService(
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
turn: current.latestTurn,
evidence,
validationReceipt: latestReceipt(current),
@@ -536,7 +576,9 @@ export function createConversationalRectificationService(
const scoreableEvidence = evidence.filter((item) => item.scoreable === true
&& item.extractionStatus !== "needs_clarification");
const directionChange = directionChangePattern.test(command.answer);
const explicitDirectionChange = explicitDirectionChangePattern.test(command.answer);
const directionChange = explicitDirectionChange
|| (scoreableEvidence.length === 0 && genericUncertaintyPattern.test(command.answer));
if (directionChange || scoreableEvidence.length === 0) {
const next = nonScoringTurn({
current,
@@ -550,6 +592,7 @@ export function createConversationalRectificationService(
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
turn: next.turn,
evidence,
validationReceipt: next.receipt,
@@ -593,6 +636,7 @@ export function createConversationalRectificationService(
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
turn: next.turn,
evidence,
validationReceipt: narrative.validationReceipt,
@@ -618,6 +662,7 @@ export function createConversationalRectificationService(
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
turn,
evidence,
validationReceipt: narrative.validationReceipt,
@@ -631,6 +676,9 @@ export function createConversationalRectificationService(
async pause(userId, rawCommand) {
const command = parseCommand("pause", rawCommand);
const fingerprint = commandFingerprint(command);
const receipt = await replayMutation(userId, command, "pause", fingerprint);
if (receipt) return receipt;
const current = await load(userId, command.caseId);
if (current.turnVersion === command.turnVersion + 1 && current.status === "paused") {
try {
@@ -639,6 +687,7 @@ export function createConversationalRectificationService(
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
turn: current.latestTurn,
validationReceipt: latestReceipt(current),
});
@@ -662,6 +711,7 @@ export function createConversationalRectificationService(
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
turn: next.turn,
validationReceipt: next.receipt,
}));
@@ -672,6 +722,9 @@ export function createConversationalRectificationService(
async abandon(userId, rawCommand) {
const command = parseCommand("abandon", rawCommand);
const fingerprint = commandFingerprint(command);
const receipt = await replayMutation(userId, command, "abandon", fingerprint);
if (receipt) return receipt;
const current = await load(userId, command.caseId);
if (current.turnVersion === command.turnVersion + 1 && current.status === "abandoned") {
try {
@@ -680,6 +733,7 @@ export function createConversationalRectificationService(
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
turn: current.latestTurn,
validationReceipt: latestReceipt(current),
}));
@@ -700,6 +754,7 @@ export function createConversationalRectificationService(
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
turn: next.turn,
validationReceipt: next.receipt,
}));
@@ -710,6 +765,9 @@ export function createConversationalRectificationService(
async confirm(userId, rawCommand) {
const command = parseCommand("confirm", rawCommand);
const fingerprint = commandFingerprint(command);
const receipt = await replayMutation(userId, command, "confirm", fingerprint);
if (receipt) return receipt;
const current = await load(userId, command.caseId);
if (current.turnVersion === command.turnVersion + 1 && current.status === "completed") {
const resultId = current.privateCandidate.resultId;
@@ -720,6 +778,7 @@ export function createConversationalRectificationService(
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
resultId,
time: command.time,
calculationVersion: current.privateCandidate.calculationVersion,
@@ -755,6 +814,7 @@ export function createConversationalRectificationService(
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
resultId,
time: command.time,
calculationVersion: current.privateCandidate.calculationVersion,
@@ -212,6 +212,7 @@ export const conversationalRectificationActionReceiptRequestSchema = boundedJson
expectedVersion: z.number().int().nonnegative(),
actionId: uuidSchema,
requestFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
commandFingerprint: z.string().regex(/^[0-9a-f]{64}$/).optional(),
}).strict(), 2_048);
export const billingReceiptResponseSchema = boundedJson(z.object({
@@ -78,6 +78,20 @@ type MutationIdentity = Readonly<{
actionId: string;
}>;
type CommandMutationIdentity = MutationIdentity & Readonly<{
commandFingerprint: string;
}>;
export type ConversationalRectificationActionKind =
| "save_turn"
| "pause"
| "abandon"
| "confirm";
export type LoadConversationalRectificationActionReceiptInput = CommandMutationIdentity & Readonly<{
actionKind: ConversationalRectificationActionKind;
}>;
export type CreateConversationalRectificationCaseInput = MutationIdentity & Readonly<{
revisionOfCaseId: string | null;
pendingConsultationQuestion: string | null;
@@ -89,19 +103,19 @@ export type CreateConversationalRectificationCaseInput = MutationIdentity & Read
export type LifeEventEvidenceInput = DeepReadonly<LifeEventEvidence>;
export type SaveConversationalRectificationTurnInput = MutationIdentity & Readonly<{
export type SaveConversationalRectificationTurnInput = CommandMutationIdentity & Readonly<{
turn: ConversationalRectificationTurnInput;
evidence: ReadonlyArray<LifeEventEvidenceInput>;
validationReceipt: ValidationReceiptInput;
privateCandidate: PrivateCandidateInput;
}>;
export type ConversationalRectificationTransitionInput = MutationIdentity & Readonly<{
export type ConversationalRectificationTransitionInput = CommandMutationIdentity & Readonly<{
turn: ConversationalRectificationTurnInput;
validationReceipt: ValidationReceiptInput;
}>;
export type ConfirmConversationalRectificationInput = MutationIdentity & Readonly<{
export type ConfirmConversationalRectificationInput = CommandMutationIdentity & Readonly<{
resultId: string;
time: string;
calculationVersion: string;
@@ -174,10 +188,14 @@ function parseStoredCase(data: unknown, allowNull = false): StoredConversational
pendingConsultationQuestion: value.pending_consultation_question,
billingState: value.billing_state,
latestTurn: value.latest_turn,
declaredBirthInput: value.declared_birth_input,
privateCandidate: value.private_candidate,
eventEvidence: value.event_evidence,
validationReceipts: value.validation_receipts,
...(value.declared_birth_input === undefined
? {} : { declaredBirthInput: value.declared_birth_input }),
...(value.private_candidate === undefined
? {} : { privateCandidate: value.private_candidate }),
...(value.event_evidence === undefined
? {} : { eventEvidence: value.event_evidence }),
...(value.validation_receipts === undefined
? {} : { validationReceipts: value.validation_receipts }),
});
}
@@ -222,6 +240,9 @@ const mutationIdentitySchema = z.object({
actionId: z.string().uuid(),
}).strict();
const commandFingerprintSchema = z.string().regex(/^[0-9a-f]{64}$/);
const actionKindSchema = z.enum(["save_turn", "pause", "abandon", "confirm"]);
function mutationArgs(input: MutationIdentity): Readonly<Record<string, unknown>> {
const parsed = mutationIdentitySchema.safeParse({
userId: input.userId,
@@ -238,6 +259,12 @@ function mutationArgs(input: MutationIdentity): Readonly<Record<string, unknown>
};
}
function commandFingerprint(input: CommandMutationIdentity): string {
const parsed = commandFingerprintSchema.safeParse(input.commandFingerprint);
if (!parsed.success) return invalidDurableInput();
return parsed.data;
}
/**
* A public start action is also its durable case identity. This makes a retry
* recoverable even when the first response was lost before the caller learned
@@ -304,11 +331,24 @@ export class ConversationalRectificationStore {
return loaded as LoadedConversationalRectificationCase;
}
async loadActionReceipt(
input: LoadConversationalRectificationActionReceiptInput,
): Promise<StoredConversationalRectificationCase | null> {
const actionKind = actionKindSchema.safeParse(input.actionKind);
if (!actionKind.success) return invalidDurableInput();
return this.callCaseRpc("replay_conversational_rectification_action", {
...mutationArgs(input),
p_action_kind: actionKind.data,
p_command_fingerprint: commandFingerprint(input),
}, true);
}
async saveTurn(
input: SaveConversationalRectificationTurnInput,
): Promise<StoredConversationalRectificationCase> {
const result = await this.callCaseRpc("save_conversational_rectification_turn", {
...mutationArgs(input),
p_command_fingerprint: commandFingerprint(input),
p_turn: requirePublicTurn(input.turn),
p_evidence: requireEvidence(input.evidence),
p_validation_receipt: requireValidationReceipt(input.validationReceipt),
@@ -336,6 +376,7 @@ export class ConversationalRectificationStore {
): Promise<StoredConversationalRectificationCase> {
const result = await this.callCaseRpc(functionName, {
...mutationArgs(input),
p_command_fingerprint: commandFingerprint(input),
p_turn: requirePublicTurn(input.turn),
p_validation_receipt: requireValidationReceipt(input.validationReceipt),
});
@@ -348,6 +389,7 @@ export class ConversationalRectificationStore {
): Promise<StoredConversationalRectificationCase> {
const result = await this.callCaseRpc("confirm_conversational_rectification_candidate", {
...mutationArgs(input),
p_command_fingerprint: commandFingerprint(input),
p_result_id: input.resultId,
p_time: input.time,
p_calculation_version: input.calculationVersion,
@@ -883,7 +883,8 @@ as $$
and public.conversational_rectification_has_only_keys(
p_value,
array[
'kind', 'userId', 'caseId', 'expectedVersion', 'actionId', 'requestFingerprint'
'kind', 'userId', 'caseId', 'expectedVersion', 'actionId',
'requestFingerprint', 'commandFingerprint'
]::text[]
)
and p_value ?& array[
@@ -903,7 +904,14 @@ as $$
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}$';
and p_value ->> 'requestFingerprint' ~ '^[0-9a-f]{64}$'
and (
not (p_value ? 'commandFingerprint')
or (
pg_catalog.jsonb_typeof(p_value -> 'commandFingerprint') = 'string'
and p_value ->> 'commandFingerprint' ~ '^[0-9a-f]{64}$'
)
);
$$;
create or replace function public.conversational_rectification_action_request(
@@ -169,6 +169,53 @@ begin
end;
$$;
create or replace function public.replay_conversational_rectification_action(
p_user_id uuid,
p_case_id uuid,
p_expected_version bigint,
p_action_id uuid,
p_action_kind text,
p_command_fingerprint text
)
returns jsonb
language plpgsql
stable
security definer
set search_path = ''
as $$
declare
v_receipt public.birth_time_rectification_action_receipts%rowtype;
begin
if p_user_id is null or p_case_id is null or p_action_id is null
or p_expected_version is null or p_expected_version < 0
or p_action_kind not in ('save_turn', 'pause', 'abandon', 'confirm')
or p_command_fingerprint !~ '^[0-9a-f]{64}$' then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
select r.* into v_receipt
from public.birth_time_rectification_action_receipts r
where r.user_id = p_user_id
and r.case_id = p_case_id
and r.action_id = p_action_id;
if not found then
return null;
end if;
-- Receipts created before command identities were introduced still use the
-- mutation RPC's complete request fingerprint for their one-step replay.
if not (v_receipt.request ? 'commandFingerprint') then
return null;
end if;
if v_receipt.action_kind is distinct from p_action_kind
or v_receipt.expected_turn_version is distinct from p_expected_version
or v_receipt.request ->> 'commandFingerprint'
is distinct from p_command_fingerprint then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
return v_receipt.response;
end;
$$;
create or replace function public.conversational_rectification_case_fits_load_limits(
p_user_id uuid,
p_case_id uuid,
@@ -494,7 +541,8 @@ create or replace function public.save_conversational_rectification_turn(
p_turn jsonb,
p_evidence jsonb,
p_validation_receipt jsonb,
p_private_candidate jsonb
p_private_candidate jsonb,
p_command_fingerprint text default null
)
returns jsonb
language plpgsql
@@ -518,7 +566,9 @@ declare
);
begin
if p_user_id is null or p_case_id is null or p_action_id is null
or p_expected_version is null or p_expected_version < 0 then
or p_expected_version is null or p_expected_version < 0
or (p_command_fingerprint is not null
and p_command_fingerprint !~ '^[0-9a-f]{64}$') then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
perform pg_catalog.pg_advisory_xact_lock(
@@ -540,6 +590,9 @@ begin
if v_receipt.user_id is distinct from p_user_id
or v_receipt.action_kind is distinct from 'save_turn'
or v_receipt.expected_turn_version is distinct from p_expected_version
or (v_receipt.request ? 'commandFingerprint'
and v_receipt.request ->> 'commandFingerprint'
is distinct from p_command_fingerprint)
or v_receipt.request_fingerprint is distinct from v_fingerprint then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
@@ -642,7 +695,10 @@ begin
public.conversational_rectification_action_request(
'save_turn', p_user_id, p_case_id, p_expected_version,
p_action_id, v_fingerprint
),
) || case when p_command_fingerprint is null then '{}'::jsonb
else pg_catalog.jsonb_build_object(
'commandFingerprint', p_command_fingerprint
) end,
v_response
);
return v_response;
@@ -655,7 +711,8 @@ create or replace function public.pause_conversational_rectification_case(
p_expected_version bigint,
p_action_id uuid,
p_turn jsonb,
p_validation_receipt jsonb
p_validation_receipt jsonb,
p_command_fingerprint text default null
)
returns jsonb
language plpgsql
@@ -676,7 +733,9 @@ declare
);
begin
if p_user_id is null or p_case_id is null or p_action_id is null
or p_expected_version is null or p_expected_version < 0 then
or p_expected_version is null or p_expected_version < 0
or (p_command_fingerprint is not null
and p_command_fingerprint !~ '^[0-9a-f]{64}$') then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
perform pg_catalog.pg_advisory_xact_lock(
@@ -697,6 +756,9 @@ begin
if v_receipt.user_id is distinct from p_user_id
or v_receipt.action_kind is distinct from 'pause'
or v_receipt.expected_turn_version is distinct from p_expected_version
or (v_receipt.request ? 'commandFingerprint'
and v_receipt.request ->> 'commandFingerprint'
is distinct from p_command_fingerprint)
or v_receipt.request_fingerprint is distinct from v_fingerprint then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
@@ -767,7 +829,10 @@ begin
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
),
) || case when p_command_fingerprint is null then '{}'::jsonb
else pg_catalog.jsonb_build_object(
'commandFingerprint', p_command_fingerprint
) end,
v_response
);
return v_response;
@@ -780,7 +845,8 @@ create or replace function public.abandon_conversational_rectification_case(
p_expected_version bigint,
p_action_id uuid,
p_turn jsonb,
p_validation_receipt jsonb
p_validation_receipt jsonb,
p_command_fingerprint text default null
)
returns jsonb
language plpgsql
@@ -801,7 +867,9 @@ declare
);
begin
if p_user_id is null or p_case_id is null or p_action_id is null
or p_expected_version is null or p_expected_version < 0 then
or p_expected_version is null or p_expected_version < 0
or (p_command_fingerprint is not null
and p_command_fingerprint !~ '^[0-9a-f]{64}$') then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
perform pg_catalog.pg_advisory_xact_lock(
@@ -822,6 +890,9 @@ begin
if v_receipt.user_id is distinct from p_user_id
or v_receipt.action_kind is distinct from 'abandon'
or v_receipt.expected_turn_version is distinct from p_expected_version
or (v_receipt.request ? 'commandFingerprint'
and v_receipt.request ->> 'commandFingerprint'
is distinct from p_command_fingerprint)
or v_receipt.request_fingerprint is distinct from v_fingerprint then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
@@ -892,7 +963,10 @@ begin
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
),
) || case when p_command_fingerprint is null then '{}'::jsonb
else pg_catalog.jsonb_build_object(
'commandFingerprint', p_command_fingerprint
) end,
v_response
);
return v_response;
@@ -908,7 +982,8 @@ create or replace function public.confirm_conversational_rectification_candidate
p_time time without time zone,
p_calculation_version text,
p_turn jsonb,
p_validation_receipt jsonb
p_validation_receipt jsonb,
p_command_fingerprint text default null
)
returns jsonb
language plpgsql
@@ -933,6 +1008,10 @@ declare
)
);
begin
if p_command_fingerprint is not null
and p_command_fingerprint !~ '^[0-9a-f]{64}$' then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
if p_user_id is null or p_case_id is null or p_action_id is null
or p_result_id is null or p_time is null
or extract(second from p_time) is distinct from 0
@@ -958,6 +1037,9 @@ begin
if v_receipt.user_id is distinct from p_user_id
or v_receipt.action_kind is distinct from 'confirm'
or v_receipt.expected_turn_version is distinct from p_expected_version
or (v_receipt.request ? 'commandFingerprint'
and v_receipt.request ->> 'commandFingerprint'
is distinct from p_command_fingerprint)
or v_receipt.request_fingerprint is distinct from v_fingerprint then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
@@ -1075,7 +1157,10 @@ begin
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
),
) || case when p_command_fingerprint is null then '{}'::jsonb
else pg_catalog.jsonb_build_object(
'commandFingerprint', p_command_fingerprint
) end,
v_response
);
return v_response;
@@ -1340,20 +1425,23 @@ revoke all on function public.conversational_rectification_case_fits_load_limits
revoke all on function public.load_conversational_rectification_case(uuid, uuid)
from public, anon, authenticated;
revoke all on function public.replay_conversational_rectification_action(
uuid, uuid, bigint, uuid, text, text
) from public, anon, authenticated;
revoke all on function public.create_conversational_rectification_case(
uuid, uuid, bigint, uuid, uuid, text, jsonb, jsonb, jsonb, jsonb
) from public, anon, authenticated;
revoke all on function public.save_conversational_rectification_turn(
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text
) from public, anon, authenticated;
revoke all on function public.pause_conversational_rectification_case(
uuid, uuid, bigint, uuid, jsonb, jsonb
uuid, uuid, bigint, uuid, jsonb, jsonb, text
) from public, anon, authenticated;
revoke all on function public.abandon_conversational_rectification_case(
uuid, uuid, bigint, uuid, jsonb, jsonb
uuid, uuid, bigint, uuid, jsonb, jsonb, text
) from public, anon, authenticated;
revoke all on function public.confirm_conversational_rectification_candidate(
uuid, uuid, bigint, uuid, uuid, time without time zone, text, jsonb, jsonb
uuid, uuid, bigint, uuid, uuid, time without time zone, text, jsonb, jsonb, text
) from public, anon, authenticated;
revoke all on function public.import_legacy_conversational_rectification_case(
uuid, uuid, uuid, bigint, uuid, integer, text, jsonb, jsonb, jsonb
@@ -1361,20 +1449,23 @@ revoke all on function public.import_legacy_conversational_rectification_case(
grant execute on function public.load_conversational_rectification_case(uuid, uuid)
to service_role;
grant execute on function public.replay_conversational_rectification_action(
uuid, uuid, bigint, uuid, text, text
) to service_role;
grant execute on function public.create_conversational_rectification_case(
uuid, uuid, bigint, uuid, uuid, text, jsonb, jsonb, jsonb, jsonb
) to service_role;
grant execute on function public.save_conversational_rectification_turn(
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text
) to service_role;
grant execute on function public.pause_conversational_rectification_case(
uuid, uuid, bigint, uuid, jsonb, jsonb
uuid, uuid, bigint, uuid, jsonb, jsonb, text
) to service_role;
grant execute on function public.abandon_conversational_rectification_case(
uuid, uuid, bigint, uuid, jsonb, jsonb
uuid, uuid, bigint, uuid, jsonb, jsonb, text
) to service_role;
grant execute on function public.confirm_conversational_rectification_candidate(
uuid, uuid, bigint, uuid, uuid, time without time zone, text, jsonb, jsonb
uuid, uuid, bigint, uuid, uuid, time without time zone, text, jsonb, jsonb, text
) to service_role;
grant execute on function public.import_legacy_conversational_rectification_case(
uuid, uuid, uuid, bigint, uuid, integer, text, jsonb, jsonb, jsonb
@@ -21,6 +21,7 @@ const resumeActionId = "00000000-0000-4000-8000-000000000705";
const confirmActionId = "00000000-0000-4000-8000-000000000706";
const priorCaseId = "00000000-0000-4000-8000-000000000707";
const resultId = "00000000-0000-4000-8000-000000000708";
const laterActionId = "00000000-0000-4000-8000-000000000710";
const declaredBirthInput = {
source: "approximate" as const,
@@ -148,7 +149,13 @@ function harness(options: {
const events: string[] = [];
const mutations: string[] = [];
const cases = new Map<string, MutableCase>();
const receipts = new Map<string, { input: unknown; response: StoredConversationalRectificationCase }>();
const receipts = new Map<string, {
input: unknown;
response: StoredConversationalRectificationCase;
actionKind?: "save_turn" | "pause" | "abandon" | "confirm";
expectedVersion?: number;
commandFingerprint?: string;
}>();
let packetBuilds = 0;
let reserveCount = 0;
let releaseCount = 0;
@@ -189,18 +196,39 @@ function harness(options: {
};
}
function replay(actionId: string, input: unknown, make: () => LoadedConversationalRectificationCase) {
function replay(
actionId: string,
input: { readonly expectedVersion?: number; readonly commandFingerprint?: string },
make: () => LoadedConversationalRectificationCase,
actionKind?: "save_turn" | "pause" | "abandon" | "confirm",
) {
const prior = receipts.get(actionId);
if (prior) {
assert.deepEqual(input, prior.input);
return prior.response;
}
const response = make();
receipts.set(actionId, { input: structuredClone(input), response });
receipts.set(actionId, {
input: structuredClone(input),
response,
actionKind,
expectedVersion: input.expectedVersion,
commandFingerprint: input.commandFingerprint,
});
return response;
}
const store: ConversationalRectificationServicePorts["store"] = {
async loadActionReceipt(input) {
const prior = receipts.get(input.actionId);
if (!prior?.actionKind) return null;
if (prior.actionKind !== input.actionKind
|| prior.expectedVersion !== input.expectedVersion
|| prior.commandFingerprint !== input.commandFingerprint) {
throw new ConversationalRectificationError("action_conflict");
}
return prior.response;
},
async loadCase(input) {
const value = input.caseId
? cases.get(input.caseId)?.row
@@ -240,7 +268,7 @@ function harness(options: {
});
cases.set(input.turn.caseId, { row });
return row;
});
}, "save_turn");
},
async pause(input) {
mutations.push("pause");
@@ -252,7 +280,7 @@ function harness(options: {
receipts: [...current.validationReceipts, input.validationReceipt] });
cases.set(input.turn.caseId, { row });
return row;
});
}, "pause");
},
async abandon(input) {
mutations.push("abandon");
@@ -263,7 +291,7 @@ function harness(options: {
receipts: [...current.validationReceipts, input.validationReceipt] });
cases.set(input.turn.caseId, { row });
return row;
});
}, "abandon");
},
async confirm(input) {
mutations.push("confirm");
@@ -274,7 +302,7 @@ function harness(options: {
receipts: [...current.validationReceipts, input.validationReceipt] });
cases.set(input.turn.caseId, { row });
return row;
});
}, "confirm");
},
};
@@ -330,6 +358,26 @@ function harness(options: {
cases,
service: createConversationalRectificationService(ports),
counts: () => ({ packetBuilds, reserveCount, releaseCount }),
forceLaterVersion(caseId: string, turnVersion: number) {
const current = cases.get(caseId)?.row;
assert.ok(current);
cases.set(caseId, {
row: {
...current,
status: "active",
turnVersion,
latestTurn: conversationalRectificationTurnSchema.parse({
...current.latestTurn,
status: "active",
turnVersion,
candidate: current.latestTurn.candidate.status === "confirmed"
? { ...current.latestTurn.candidate, status: "pending_validation" }
: current.latestTurn.candidate,
actions: ["answer", "pause", "abandon"],
}),
},
});
},
};
}
@@ -422,6 +470,25 @@ test("clear historical evidence is extracted, scored, narrated, recapped, and at
assert.ok(value.events.includes("score-packet"));
});
test("generic date uncertainty does not suppress clear historical evidence", async () => {
const value = harness();
await start(value, null);
const turn = await value.service.answer(userId, {
type: "answer",
caseId: startActionId,
actionId: answerActionId,
turnVersion: 0,
answer: "2021年7月毕业,具体日期不确定",
});
assert.equal(turn.status, "confirming");
assert.equal(value.counts().packetBuilds, 2);
assert.ok(value.events.includes("score-packet"));
assert.ok((value.cases.get(startActionId)?.row.eventEvidence ?? [])
.some((item) => item.eventSummary.includes("毕业") && item.scoreable === true));
});
test("vague, future, and unmatched answers stay conversational and never score", async () => {
for (const [answer, domain] of [
["后来换了工作", undefined],
@@ -446,20 +513,26 @@ test("vague, future, and unmatched answers stay conversational and never score",
}
});
test("pause persists, resume reads it without another charge, and stale commands are stable", async () => {
test("resume returns the latest owned turn on a stale new-device version without mutation or charge", async () => {
const value = harness();
await start(value, null);
const paused = await value.service.pause(userId, {
type: "pause", caseId: startActionId, actionId: pauseActionId, turnVersion: 0,
});
assert.equal(paused.status, "paused");
const resumed = await value.service.resume(userId, {
type: "resume", caseId: startActionId, actionId: resumeActionId, turnVersion: 1,
const latest = await value.service.answer(userId, {
type: "answer", caseId: startActionId, actionId: answerActionId,
turnVersion: 1, answer: "2021年7月毕业,并在2022年3月去外地工作",
});
assert.deepEqual(resumed, paused);
const mutationsBeforeResume = [...value.mutations];
const resumed = await value.service.resume(userId, {
type: "resume", caseId: startActionId, actionId: resumeActionId, turnVersion: 0,
});
assert.deepEqual(resumed, latest);
assert.deepEqual(value.mutations, mutationsBeforeResume);
assert.equal(value.counts().reserveCount, 1);
await assert.rejects(value.service.answer(userId, {
type: "answer", caseId: startActionId, actionId: answerActionId,
type: "answer", caseId: startActionId, actionId: laterActionId,
turnVersion: 0, answer: "2021年7月毕业",
}), (error: unknown) => error instanceof ConversationalRectificationError
&& error.code === "stale_turn");
@@ -483,6 +556,104 @@ test("a lost-response retry replays the saved answer without rescoring or regene
assert.deepEqual(value.events, before);
});
test("receipt-first delayed retries replay the original answer, pause, abandon, and confirm after later turns", async () => {
const scenarios = [
{
name: "answer",
async perform(value: ReturnType<typeof harness>) {
const command = {
type: "answer" as const,
caseId: startActionId,
actionId: answerActionId,
turnVersion: 0,
answer: "2021年7月毕业,并在2022年3月去外地工作",
};
return { command, first: await value.service.answer(userId, command) };
},
},
{
name: "pause",
async perform(value: ReturnType<typeof harness>) {
const command = {
type: "pause" as const,
caseId: startActionId,
actionId: pauseActionId,
turnVersion: 0,
};
return { command, first: await value.service.pause(userId, command) };
},
},
{
name: "abandon",
async perform(value: ReturnType<typeof harness>) {
const command = {
type: "abandon" as const,
caseId: startActionId,
actionId: laterActionId,
turnVersion: 0,
};
return { command, first: await value.service.abandon(userId, command) };
},
},
{
name: "confirm",
async perform(value: ReturnType<typeof harness>) {
const ready = await value.service.answer(userId, {
type: "answer",
caseId: startActionId,
actionId: answerActionId,
turnVersion: 0,
answer: "2021年7月毕业,并在2022年3月去外地工作",
});
const command = {
type: "confirm" as const,
caseId: startActionId,
actionId: confirmActionId,
turnVersion: ready.turnVersion,
time: "05:18",
};
return { command, first: await value.service.confirm(userId, command) };
},
},
] as const;
for (const scenario of scenarios) {
const value = harness();
await start(value, null);
const { command, first } = await scenario.perform(value);
value.forceLaterVersion(startActionId, first.turnVersion + 2);
const mutationsBeforeReplay = [...value.mutations];
const eventsBeforeReplay = [...value.events];
const replayed = await value.service[scenario.name](userId, command as never);
assert.deepEqual(replayed, first, scenario.name);
assert.deepEqual(value.mutations, mutationsBeforeReplay, scenario.name);
assert.deepEqual(value.events, eventsBeforeReplay, scenario.name);
}
});
test("receipt-first delayed replay rejects the same action id with a different command payload", async () => {
const value = harness();
await start(value, null);
const command = {
type: "answer" as const,
caseId: startActionId,
actionId: answerActionId,
turnVersion: 0,
domain: "education" as const,
answer: "2021年7月毕业",
};
const first = await value.service.answer(userId, command);
value.forceLaterVersion(startActionId, first.turnVersion + 2);
await assert.rejects(value.service.answer(userId, {
...command,
domain: "career",
}), (error: unknown) => error instanceof ConversationalRectificationError
&& error.code === "action_conflict");
});
test("confirm delegates to the atomic store call, preserves the old baseline until then, and returns the saved question", async () => {
const value = harness();
await start(value, "请继续回答原来的事业问题");
@@ -1,11 +1,13 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
buildProductionConversationalRectificationPacket,
createBirthTimeConversationPostHandler,
loadProductionConversationalRectificationProfile,
type BirthTimeConversationRouteService,
} from "../src/app/api/birth-time-conversation/route.ts";
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
import type { BirthTimeJourneyEngine } from "../src/lib/birth-time-journey-service.ts";
const userId = "00000000-0000-4000-8000-000000000711";
const actionId = "00000000-0000-4000-8000-000000000712";
@@ -181,16 +183,146 @@ test("unknown SQL, model, and browser errors are never exposed or logged", async
assert.deepEqual(logs, [{ requestId, actionId, caseId, code: "service_unavailable" }]);
});
test("production route lazily creates privileged clients only after authentication and strict parsing", () => {
const source = readFileSync(new URL("../src/app/api/birth-time-conversation/route.ts", import.meta.url), "utf8");
assert.doesNotMatch(source, /^import .*supabase\/admin/m);
const authenticateCall = source.indexOf("dependencies.authenticate(request)");
const parseCall = source.indexOf("safeParse(await requestPayload(request))");
const serviceCall = source.indexOf("dependencies.createService(authenticated)");
assert.ok(authenticateCall < parseCall);
assert.ok(parseCall < serviceCall);
assert.match(source, /import\(["'].*supabase\/admin(?:\.ts)?["']\)/);
assert.match(source, /process\.env\.RECTIFICATION_PRICE_CREDITS/);
assert.doesNotMatch(source, /BIRTH_TIME_RECTIFICATION_PRICE_CREDITS/);
assert.doesNotMatch(source, /command\.price|parsed\.data\.price/);
test("production profile conversion only links terminal v3 revisions and leaves pre-v3 baselines unlinked", async () => {
const priorId = "00000000-0000-4000-8000-000000000715";
const profile = {
birth_date: "1990-01-01",
reported_birth_time: "04:58:00",
active_birth_time: "05:21:00",
birth_time_source: "legacy_import",
birth_time_period: null,
birth_time_clue: "synthetic dawn clue",
uncertainty_before_minutes: 0,
uncertainty_after_minutes: 0,
country_code: "TW",
province_code: "TPE",
city_code: "TPE-CITY",
district_code: "DAAN",
latitude: 25.0268,
longitude: 121.5434,
timezone_offset: 8,
rectification_case_id: priorId,
};
for (const [prior, expectedRevision] of [
[{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "completed" }, priorId],
[{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "abandoned" }, priorId],
[{ id: priorId, journey_protocol: "conversational-evidence-v3", status: "active" }, null],
[{ id: priorId, journey_protocol: "dynamic-choice-v2", status: "confirmed" }, null],
[{ id: priorId, journey_protocol: "legacy-guided-v1", status: "confirmed" }, null],
] as const) {
const caseLoads: unknown[] = [];
const loaded = await loadProductionConversationalRectificationProfile({
async loadProfile(receivedUserId) {
assert.equal(receivedUserId, userId);
return profile;
},
async loadRectificationCase(receivedUserId, receivedCaseId) {
caseLoads.push([receivedUserId, receivedCaseId]);
return prior;
},
}, userId);
assert.equal(loaded.revisionOfCaseId, expectedRevision);
assert.deepEqual(caseLoads, [[userId, priorId]]);
assert.equal(loaded.declaredBirthInput.source, "legacy_import");
assert.equal("reportedTime" in loaded.declaredBirthInput
? loaded.declaredBirthInput.reportedTime
: null, "04:58");
}
});
test("production unknown-time adapter covers the declared full day with bounded deduplicated scans", async () => {
const scanCalls: Array<{ birthTime: string; uncertaintyMinutes: number }> = [];
const minute = (value: string) => {
const [hour = 0, part = 0] = value.slice(-5).split(":").map(Number);
return hour * 60 + part;
};
const clock = (value: number) => {
const normalized = ((value % 1_440) + 1_440) % 1_440;
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
};
const engine: BirthTimeJourneyEngine = {
async scan(input) {
scanCalls.push({ birthTime: input.birthTime, uncertaintyMinutes: input.uncertaintyMinutes });
const center = minute(input.birthTime);
const times = [center - input.uncertaintyMinutes, center, center + input.uncertaintyMinutes]
.map(clock);
return {
questionnaire: {
questions: [],
samples: times.map((time) => {
const value = minute(time);
return {
ascendantSign: "Cancer",
d4Sign: value < 720 ? "Aries" : "Taurus",
d9Sign: value < 720 ? "Gemini" : "Virgo",
d10Sign: value < 720 ? "Leo" : "Libra",
d24Sign: "Sagittarius",
d30Sign: "Pisces",
};
}),
raw: {
candidate_scan: {
samples: times.map((time) => ({ time: `1990-01-01 ${time}` })),
},
},
},
};
},
async score() { throw new Error("unexpected questionnaire score"); },
async scoreEvents() { throw new Error("unexpected event score"); },
async buildDifferencePacket(input) {
return {
packet: {
caseId: input.caseId,
scoringVersion: "birth-time-choice-scoring-v2",
currentRange: { startTime: input.startTime, endTime: input.endTime },
opportunities: [],
askedQuestionFingerprints: [],
candidatePartitionFingerprints: [],
recentRangeHistory: [],
},
candidateModel: { version: "birth-time-choice-scoring-v2" },
scoringPartitions: {},
};
},
async scoreChoices() { throw new Error("unexpected choice score"); },
};
const result = await buildProductionConversationalRectificationPacket(engine, {
userId,
caseId,
asOfDate: "2026-07-21",
declaredBirthInput: {
source: "unknown",
birthDate: "1990-01-01",
birthTimeClue: null,
birthplace: {
cityCode: "TPE-CITY",
latitude: 25.0268,
longitude: 121.5434,
timezoneOffset: 8,
},
},
privateCandidate: null,
evidence: [],
});
assert.equal(scanCalls.length, 4);
assert.ok(scanCalls.every((call) => call.uncertaintyMinutes >= 1
&& call.uncertaintyMinutes <= 180));
const covered = new Set<number>();
for (const call of scanCalls) {
const center = minute(call.birthTime);
for (let value = center - call.uncertaintyMinutes;
value <= center + call.uncertaintyMinutes; value += 1) {
assert.ok(value >= 0 && value <= 1_439, `scan invented minute ${value}`);
covered.add(value);
}
}
assert.equal(covered.size, 1_440);
assert.deepEqual(result.packet.candidate.range, { startTime: "00:00", endTime: "23:59" });
const sampleTimes = result.packet.sensitivityScope.sampleTimes;
assert.equal(new Set(sampleTimes).size, sampleTimes.length);
assert.deepEqual(sampleTimes, [...sampleTimes].sort((left, right) => minute(left) - minute(right)));
});
@@ -26,6 +26,7 @@ const caseId = "00000000-0000-4000-8000-000000000102";
const actionId = "00000000-0000-4000-8000-000000000103";
const resultId = "00000000-0000-4000-8000-000000000104";
const importCaseId = "00000000-0000-4000-8000-000000000107";
const commandFingerprint = "c".repeat(64);
const firstTurn = {
caseId,
@@ -206,6 +207,40 @@ test("loads the latest unfinished case by account without a chat identifier", as
}]]);
});
test("loads an exact owner-scoped historical mutation receipt before current case state", async () => {
const calls: unknown[] = [];
const publicReceiptRow: Partial<typeof storedRow> = { ...storedRow };
delete publicReceiptRow.declared_birth_input;
delete publicReceiptRow.private_candidate;
delete publicReceiptRow.event_evidence;
delete publicReceiptRow.validation_receipts;
const store = new ConversationalRectificationStore(rpcClient((name, args) => {
calls.push([name, args]);
return publicReceiptRow;
}));
const replayed = await store.loadActionReceipt({
userId,
caseId,
actionId,
actionKind: "save_turn",
expectedVersion: 0,
commandFingerprint,
});
assert.equal(replayed?.caseId, caseId);
assert.deepEqual(replayed?.latestTurn, firstTurn);
assert.equal(replayed && "privateCandidate" in replayed, false);
assert.deepEqual(calls, [["replay_conversational_rectification_action", {
p_user_id: userId,
p_case_id: caseId,
p_action_id: actionId,
p_action_kind: "save_turn",
p_expected_version: 0,
p_command_fingerprint: commandFingerprint,
}]]);
});
test("binds every paid start to the public action as its recoverable case id", async () => {
let calls = 0;
const client = rpcClient(() => {
@@ -245,7 +280,7 @@ test("save, pause, abandon, confirm, and import carry owner/version/action guard
calls.push([name, args]);
return storedRow;
}));
const common = { userId, caseId, actionId, expectedVersion: 0 };
const common = { userId, caseId, actionId, expectedVersion: 0, commandFingerprint };
const evidence = [{
id: "00000000-0000-4000-8000-000000000105",
rawText: "2019 年 7 月换工作",
@@ -313,6 +348,7 @@ test("save, pause, abandon, confirm, and import carry owner/version/action guard
assert.equal(args.p_case_id, caseId);
assert.equal(args.p_action_id, actionId);
assert.equal(args.p_expected_version, 0);
assert.equal(args.p_command_fingerprint, commandFingerprint);
assert.deepEqual(args.p_validation_receipt, validationReceipt);
}
assert.equal(calls[4]?.[1].p_case_id, importCaseId);
@@ -756,6 +792,7 @@ test("durable private and receipt schemas accept boundaries and reject oversize
expectedVersion: 0,
actionId,
requestFingerprint: "a".repeat(64),
commandFingerprint,
};
assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse(request).success, true);
assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse({
@@ -766,6 +803,10 @@ test("durable private and receipt schemas accept boundaries and reject oversize
...request,
requestFingerprint: "a".repeat(65),
}).success, false);
assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse({
...request,
commandFingerprint: "c".repeat(63),
}).success, false);
assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({
success: false,
credits: 7,
@@ -246,6 +246,58 @@ def test_account_resume_projection_contains_private_working_state_only_for_servi
assert "private_candidate" not in public_projection
def test_historical_action_replay_is_owner_scoped_exact_bounded_and_read_only() -> None:
schema = _normalized(SCHEMA)
transitions = _normalized(TRANSITIONS)
request = _function(schema, "conversational_rectification_valid_action_request")
replay = _function(
transitions, "replay_conversational_rectification_action"
)
assert "'commandfingerprint'" in request
assert "requestfingerprint" in request
assert "^[0-9a-f]{64}$" in request
for invariant in (
"r.user_id = p_user_id",
"r.case_id = p_case_id",
"r.action_id = p_action_id",
"v_receipt.action_kind is distinct from p_action_kind",
"v_receipt.expected_turn_version is distinct from p_expected_version",
"not (v_receipt.request ? 'commandfingerprint')",
"v_receipt.request ->> 'commandfingerprint' is distinct from p_command_fingerprint",
"return v_receipt.response",
"raise exception 'conversational_action_conflict'",
):
assert invariant in replay
assert "insert into" not in replay
assert "update public." not in replay
assert "delete from" not in replay
assert (
"revoke all on function public.replay_conversational_rectification_action"
in transitions
)
assert (
"grant execute on function public.replay_conversational_rectification_action"
in transitions
)
assert not re.search(
r"grant execute on function public\.replay_conversational_rectification_action"
r".+?to (?:anon|authenticated)",
transitions,
)
for name in (
"save_conversational_rectification_turn",
"pause_conversational_rectification_case",
"abandon_conversational_rectification_case",
"confirm_conversational_rectification_candidate",
):
body = _function(transitions, name)
assert "p_command_fingerprint text" in body
assert "p_command_fingerprint !~ '^[0-9a-f]{64}$'" in body
assert "'commandfingerprint', p_command_fingerprint" in body
def test_start_identity_and_account_concurrency_are_server_guarded() -> None:
schema = _normalized(SCHEMA)
transitions = _normalized(TRANSITIONS)
@@ -328,6 +328,7 @@ def _save_statement(
turn: dict[str, object] | None = None,
validation_receipt: dict[str, object] | None = None,
private_candidate: dict[str, object] | None = None,
command_fingerprint: str | None = None,
) -> str:
next_turn = turn or {
**_valid_turn(case_id),
@@ -342,7 +343,8 @@ def _save_statement(
{_jsonb(next_turn)},
{_jsonb(evidence)},
{_jsonb(validation_receipt or {"modelId": "synthetic-model", "schemaValidated": True})},
{_jsonb(private_candidate or _valid_private_candidate())}
{_jsonb(private_candidate or _valid_private_candidate())},
{"null" if command_fingerprint is None else _text(command_fingerprint)}
)::text;
"""
@@ -483,6 +485,84 @@ def test_valid_declared_birth_input_round_trips_across_account_load(pg14_databas
assert loaded["declared_birth_input"] == declared
def test_historical_receipt_replays_exact_public_response_after_later_turns(
pg14_database: PgDatabase,
) -> None:
user_id = "00000000-0000-4000-8000-000000000943"
case_id = "00000000-0000-4000-8000-000000000944"
first_action = "00000000-0000-4000-8000-000000000945"
later_actions = (
"00000000-0000-4000-8000-000000000946",
"00000000-0000-4000-8000-000000000947",
)
first_fingerprint = "a" * 64
_create_user(pg14_database, user_id)
_reserve(pg14_database, user_id, case_id)
_create_case(pg14_database, user_id, case_id, _valid_declared_birth_input())
_complete(pg14_database, user_id, case_id)
original = json.loads(pg14_database.sql(_save_statement(
user_id,
case_id,
0,
first_action,
[],
command_fingerprint=first_fingerprint,
)))
for version, action_id in enumerate(later_actions, start=1):
pg14_database.sql(_save_statement(
user_id,
case_id,
version,
action_id,
[],
command_fingerprint=str(version) * 64,
))
replayed = json.loads(pg14_database.sql(
f"""
select public.replay_conversational_rectification_action(
'{user_id}'::uuid, '{case_id}'::uuid, 0, '{first_action}'::uuid,
'save_turn', '{first_fingerprint}'
)::text;
"""
))
assert replayed == original
assert replayed["turn_version"] == 1
assert replayed["latest_turn"]["turnVersion"] == 1
assert pg14_database.rejects(
f"""
select public.replay_conversational_rectification_action(
'{user_id}'::uuid, '{case_id}'::uuid, 0, '{first_action}'::uuid,
'save_turn', '{'b' * 64}'
);
"""
)
privileges = json.loads(pg14_database.sql(
"""
select pg_catalog.jsonb_build_object(
'anon', pg_catalog.has_function_privilege(
'anon',
'public.replay_conversational_rectification_action(uuid,uuid,bigint,uuid,text,text)',
'EXECUTE'
),
'authenticated', pg_catalog.has_function_privilege(
'authenticated',
'public.replay_conversational_rectification_action(uuid,uuid,bigint,uuid,text,text)',
'EXECUTE'
),
'serviceRole', pg_catalog.has_function_privilege(
'service_role',
'public.replay_conversational_rectification_action(uuid,uuid,bigint,uuid,text,text)',
'EXECUTE'
)
)::text;
"""
))
assert privileges == {"anon": False, "authenticated": False, "serviceRole": True}
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"