fix(rectification): address task 7 review findings
This commit is contained in:
@@ -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,
|
||||
|
||||
+10
-2
@@ -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(
|
||||
|
||||
+110
-19
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user