fix: isolate dynamic rectification persistence

This commit is contained in:
Jesse_Chen
2026-07-19 07:20:40 +08:00
parent cba3ba0094
commit 9bd93f33cf
28 changed files with 483 additions and 74 deletions
@@ -117,6 +117,9 @@ export async function POST(request: Request) {
case "poll_scoring":
{
const before = await service.resume(user.id, parsed.data.caseId);
if (before.journeyProtocol === "dynamic-choice-v2") {
throw new GuidedJourneyLegacyMutationError(parsed.data.caseId);
}
const response = await service.pollScoringJob(user.id, parsed.data.caseId, parsed.data.jobId);
recordScoringJourneyMetric(before, response);
return NextResponse.json(response);
@@ -54,7 +54,10 @@ export function createSupabaseGuidedCandidateStore(
const { error } = await supabase.rpc(functionName, args);
if (error) {
const current = await loadCase(value.userId, value.id);
if (current?.processedActionIds?.includes(command.actionId.toLowerCase())) return current;
if (
current?.journeyProtocol === "legacy-guided-v1"
&& current.processedActionIds?.includes(command.actionId.toLowerCase())
) return current;
if (isDomainError(error, "stale_guided_candidate_turn")) {
throw new StaleJourneyTurnError(
value.id,
@@ -68,7 +71,9 @@ export function createSupabaseGuidedCandidateStore(
throw new BirthTimeJourneyStoreError("update_case");
}
const current = await loadCase(value.userId, value.id);
if (!current) throw new BirthTimeJourneyStoreError("load_case");
if (!current || current.journeyProtocol !== "legacy-guided-v1") {
throw new BirthTimeJourneyStoreError("load_case");
}
return current;
},
};
@@ -162,6 +162,10 @@ export async function loadStoredRectificationCase(
: null;
if (dynamicTurn && !dynamicTurn.success) throw new BirthTimeJourneyStoreError("load_case");
const dynamicTurnState = dynamicTurn?.data ?? null;
if (
dynamicTurnState !== null
&& dynamicTurnState.turnVersion !== parsed.turn_version
) throw new BirthTimeJourneyStoreError("load_case");
const evidenceDraft = parseEvidenceDraft(parsed.evidence_draft);
let dynamicPrivate = null;
if (parsed.journey_protocol === "dynamic-choice-v2") {
@@ -53,8 +53,6 @@ export async function saveDynamicAssessment(
p_private_state: initial.privateState,
p_profile: {
reportedBirthTime: details.reportedTime,
activeBirthTime: value.snapshot.activeTime,
birthTime: value.snapshot.activeTime,
birthTimeSource: value.assessment.source,
birthTimePeriod: details.period,
birthTimeClue: details.clue,
@@ -6,6 +6,7 @@ import {
import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts";
import type {
DynamicStoredRectificationCase,
LegacyStoredRectificationCase,
StoredRectificationCase,
} from "./birth-time-journey-service.ts";
import {
@@ -119,9 +120,8 @@ export function createDynamicTurnPersistence(
},
async upgradeLegacyActiveCase(
value: StoredRectificationCase,
value: LegacyStoredRectificationCase,
): Promise<StoredRectificationCase> {
if (value.journeyProtocol === "dynamic-choice-v2") return value;
if (isTerminalLegacyCase(value)) return value;
const upgraded = prepareLegacyDynamicUpgrade(value, asOfDate());
const result = await client.rpc("upgrade_birth_time_legacy_case", {
@@ -221,6 +221,7 @@ export function prepareLegacyDynamicUpgrade(
journeyProtocol: "dynamic-choice-v2",
turnVersion: value.turnVersion ?? 0,
processedActionIds: value.processedActionIds ?? [],
persistedProgress: value.persistedProgress ?? { adaptiveRound: 0, askedDomains: [] },
turnState: null,
evidenceDraft: null,
dynamicTurnState,
@@ -4,6 +4,8 @@ import {
} from "./birth-time-journey-turn.ts";
import type { PersistedJourneyProgress } from "./birth-time-journey-turn-persistence.ts";
import type {
DynamicStoredRectificationCase,
DynamicVersionedJourneyResponse,
JourneyResponseBase,
StoredRectificationCase,
VersionedJourneyResponse,
@@ -132,6 +134,22 @@ export function storedJourneyResponse(
};
}
export function storedDynamicJourneyResponse(
stored: DynamicStoredRectificationCase,
): DynamicVersionedJourneyResponse {
return {
caseId: stored.id,
snapshot: stored.snapshot,
questionnaire: stored.questionnaire,
scoring: stored.scoring ?? null,
answers: stored.answers,
lifeEvents: stored.lifeEvents ?? [],
candidateResult: stored.candidateResult ?? null,
evidenceDraft: null,
...stored.dynamicTurnState,
};
}
export function persistedJourneyResponse(
stored: StoredRectificationCase,
): VersionedJourneyResponse {
+27 -32
View File
@@ -1,33 +1,21 @@
import { assessBirthTime, withRectificationScoring, type BirthTimeAssessment, type JourneySnapshot, type RectificationScoring } from "./birth-time-journey.ts";
import { createJourneyTurnActions } from "./birth-time-journey-actions.ts";
import {
assertLegacyJourneyMutation,
createBirthTimeEvidenceActions,
} from "./birth-time-evidence-service.ts";
import { assertLegacyJourneyMutation, createBirthTimeEvidenceActions } from "./birth-time-evidence-service.ts";
import type { CandidateResult, LifeEvent } from "./birth-time-evidence.ts";
import type { CandidateVargaSample } from "./birth-time-question-planner.ts";
import { projectJourneyResponse, storedJourneyResponse } from "./birth-time-journey-response.ts";
import { projectJourneyResponse, storedDynamicJourneyResponse, storedJourneyResponse } from "./birth-time-journey-response.ts";
import type { JourneyTurnState } from "./birth-time-journey-turn.ts";
import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts";
import type { ScoringJobClaim, ScoringJobIdentity, ScoringJobSpec } from "./birth-time-scoring-job.ts";
import { createBirthTimeScoringService } from "./birth-time-scoring-service.ts";
import { scanAssessment } from "./birth-time-journey-assessment.ts";
import type { GuidedCandidateCommit } from "./birth-time-guided-candidate.ts";
import { createGuidedCandidateActions } from "./birth-time-guided-candidate.ts";
import { createGuidedDraftRevisionActions } from "./birth-time-guided-draft-revision.ts";
import type {
CandidateDifferenceBuild,
DynamicChoiceScoringResult,
ServerChoiceEvidence,
} from "./birth-time-dynamic-choice-internal.ts";
import type { CandidateDifferenceBuild, DynamicChoiceScoringResult, ServerChoiceEvidence } from "./birth-time-dynamic-choice-internal.ts";
import type { TimeRange } from "./birth-time-dynamic-choice.ts";
import type {
DynamicStoredFields,
LegacyStoredFields,
} from "./birth-time-journey-stored-protocol.ts";
import {
RectificationCaseNotFoundError,
RectificationQuestionsUnavailableError,
} from "./birth-time-journey-errors.ts";
import type { DynamicStoredFields, LegacyStoredFields } from "./birth-time-journey-stored-protocol.ts";
import { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError } from "./birth-time-journey-errors.ts";
export { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError };
@@ -152,18 +140,18 @@ export type StoredRectificationCase =
export interface BirthTimeJourneyStore {
saveAssessment(value: PersistedJourneyAssessment): Promise<string>;
loadCase(userId: string, caseId: string): Promise<StoredRectificationCase | null>;
saveScoring(value: StoredRectificationCase): Promise<void>;
saveTurn(value: StoredRectificationCase, expectedVersion: number, actionId: string): Promise<StoredRectificationCase>;
saveScoring(value: LegacyStoredRectificationCase): Promise<void>;
saveTurn(value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string): Promise<StoredRectificationCase>;
saveDynamicTurn(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string): Promise<DynamicStoredRectificationCase>;
upgradeLegacyActiveCase(value: StoredRectificationCase): Promise<StoredRectificationCase>;
createScoringJob(value: StoredRectificationCase, expectedVersion: number, actionId: string, job: ScoringJobSpec): Promise<StoredRectificationCase>;
upgradeLegacyActiveCase(value: LegacyStoredRectificationCase): Promise<StoredRectificationCase>;
createScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string, job: ScoringJobSpec): Promise<StoredRectificationCase>;
claimScoringJob(identity: ScoringJobIdentity): Promise<ScoringJobClaim>;
completeScoringJob(value: StoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string): Promise<StoredRectificationCase>;
failScoringJob(value: StoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string, failureCode: string): Promise<StoredRectificationCase>;
saveCandidateResult(value: StoredRectificationCase): Promise<void>;
saveCandidate(value: StoredRectificationCase): Promise<void>;
confirmCandidate(value: StoredRectificationCase): Promise<void>;
commitGuidedCandidate(value: StoredRectificationCase, command: GuidedCandidateCommit): Promise<StoredRectificationCase>;
completeScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string): Promise<StoredRectificationCase>;
failScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string, failureCode: string): Promise<StoredRectificationCase>;
saveCandidateResult(value: LegacyStoredRectificationCase): Promise<void>;
saveCandidate(value: LegacyStoredRectificationCase): Promise<void>;
confirmCandidate(value: LegacyStoredRectificationCase): Promise<void>;
commitGuidedCandidate(value: LegacyStoredRectificationCase, command: GuidedCandidateCommit): Promise<StoredRectificationCase>;
}
export type BirthTimeJourneyPorts = {
@@ -182,11 +170,15 @@ export type JourneyResponseBase = {
readonly candidateResult: CandidateResult | null;
};
export type VersionedJourneyResponse = JourneyResponseBase & JourneyTurnState;
export type VersionedJourneyResponse = JourneyResponseBase & JourneyTurnState
& { readonly journeyProtocol?: undefined };
export type DynamicVersionedJourneyResponse = JourneyResponseBase & DynamicJourneyTurnState
& { readonly evidenceDraft: null };
export type LegacyJourneyResponse = JourneyResponseBase & { readonly turnVersion?: undefined; readonly nextAction?: undefined; readonly progress?: undefined; readonly permissions?: undefined; readonly evidenceDraft?: undefined; };
export type JourneyResponse = LegacyJourneyResponse | VersionedJourneyResponse;
export type JourneyResponse = LegacyJourneyResponse | VersionedJourneyResponse | DynamicVersionedJourneyResponse;
export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
const evidenceActions = createBirthTimeEvidenceActions(ports);
@@ -217,9 +209,12 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
}, 0);
},
async resume(userId: string, caseId: string): Promise<VersionedJourneyResponse> {
async resume(userId: string, caseId: string): Promise<VersionedJourneyResponse | DynamicVersionedJourneyResponse> {
const stored = await ports.store.loadCase(userId, caseId);
if (!stored) throw new RectificationCaseNotFoundError(caseId);
if (stored.journeyProtocol === "dynamic-choice-v2") {
return storedDynamicJourneyResponse(stored);
}
const completedLegacyQuestionnaire = stored.snapshot.input === "rectification_questions"
&& stored.scoring?.nextRound === null
&& stored.scoring.nextRoundQuestions.length === 0
@@ -245,7 +240,7 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
const answers = { ...stored.answers, [questionId]: answer };
const scoring = await ports.engine.score({ questionnaire: stored.questionnaire, answers });
const snapshot = withRectificationScoring(stored.snapshot, scoring);
const updated = { ...stored, answers, scoring, snapshot } satisfies StoredRectificationCase;
const updated = { ...stored, answers, scoring, snapshot } satisfies LegacyStoredRectificationCase;
await ports.store.saveScoring(updated);
return projectJourneyResponse({
caseId,
+18 -9
View File
@@ -54,7 +54,7 @@ export function createSupabaseBirthTimeJourneyStore(
...guidedCandidates,
async saveScoring(value) {
const { error } = await supabase
const { data, error } = await supabase
.from("birth_time_rectification_cases")
.update({
status: caseStatus(value.snapshot),
@@ -64,8 +64,11 @@ export function createSupabaseBirthTimeJourneyStore(
updated_at: new Date().toISOString(),
})
.eq("id", value.id)
.eq("user_id", value.userId);
if (error) throw new BirthTimeJourneyStoreError("update_case");
.eq("user_id", value.userId)
.eq("journey_protocol", "legacy-guided-v1")
.select("id")
.maybeSingle();
if (error || !data) throw new BirthTimeJourneyStoreError("update_case");
const { error: profileError } = await supabase
.from("profiles")
@@ -77,7 +80,7 @@ export function createSupabaseBirthTimeJourneyStore(
async saveCandidateResult(value) {
const winner = value.candidateResult?.winningSegment ?? null;
const { error } = await supabase
const { data, error } = await supabase
.from("birth_time_rectification_cases")
.update({
status: caseStatus(value.snapshot),
@@ -91,8 +94,11 @@ export function createSupabaseBirthTimeJourneyStore(
updated_at: new Date().toISOString(),
})
.eq("id", value.id)
.eq("user_id", value.userId);
if (error) throw new BirthTimeJourneyStoreError("update_case");
.eq("user_id", value.userId)
.eq("journey_protocol", "legacy-guided-v1")
.select("id")
.maybeSingle();
if (error || !data) throw new BirthTimeJourneyStoreError("update_case");
const { error: profileError } = await supabase
.from("profiles")
@@ -106,13 +112,16 @@ export function createSupabaseBirthTimeJourneyStore(
},
async saveCandidate(value) {
const { error } = await supabase
const { data, error } = await supabase
.from("birth_time_rectification_cases")
.update({ candidate_saved_at: new Date().toISOString(), updated_at: new Date().toISOString() })
.eq("id", value.id)
.eq("user_id", value.userId)
.eq("candidate_result_id", value.candidateResult?.resultId ?? null);
if (error) throw new BirthTimeJourneyStoreError("update_case");
.eq("journey_protocol", "legacy-guided-v1")
.eq("candidate_result_id", value.candidateResult?.resultId ?? null)
.select("id")
.maybeSingle();
if (error || !data) throw new BirthTimeJourneyStoreError("update_case");
},
async confirmCandidate(value) {
@@ -14,7 +14,7 @@ type LegacyProgress = {
};
export type LegacyStoredFields = {
readonly journeyProtocol?: "legacy-guided-v1";
readonly journeyProtocol: "legacy-guided-v1";
readonly turnVersion?: number;
readonly turnState?: JourneyTurnState | null;
readonly evidenceDraft?: EvidenceDraft | null;
@@ -32,11 +32,11 @@ export type LegacyStoredFields = {
export type DynamicStoredFields = {
readonly journeyProtocol: "dynamic-choice-v2";
readonly turnVersion: number;
readonly turnState?: null;
readonly turnState: null;
readonly dynamicTurnState: DynamicJourneyTurnState;
readonly evidenceDraft?: null;
readonly evidenceDraft: null;
readonly processedActionIds: readonly string[];
readonly persistedProgress?: LegacyProgress;
readonly persistedProgress: LegacyProgress;
readonly candidateModel: Readonly<Record<string, unknown>> | null;
readonly currentChoiceQuestion: PersistedDynamicChoiceQuestion | null;
readonly choiceAnswers: readonly StoredChoiceAnswer[];
@@ -1,7 +1,7 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import type { JourneySnapshot } from "./birth-time-journey.ts";
import type { StoredRectificationCase } from "./birth-time-journey-service.ts";
import type { LegacyStoredRectificationCase, StoredRectificationCase } from "./birth-time-journey-service.ts";
import { evidenceDomains } from "./birth-time-question-planner.ts";
import type { EvidenceDomain } from "./birth-time-question-planner.ts";
import type { EvidenceDraft, JourneyTurnState } from "./birth-time-journey-turn.ts";
@@ -61,7 +61,7 @@ export function createJourneyTurnPersistence(
) {
return {
async saveTurn(
value: StoredRectificationCase,
value: LegacyStoredRectificationCase,
expectedVersion: number,
actionId: string,
): Promise<StoredRectificationCase> {
@@ -87,6 +87,7 @@ export function createJourneyTurnPersistence(
})
.eq("id", value.id)
.eq("user_id", value.userId)
.eq("journey_protocol", "legacy-guided-v1")
.eq("turn_version", expectedVersion)
.not("processed_action_ids", "cs", `{${parsedActionId}}`)
.select("id")
@@ -104,7 +105,10 @@ export function createJourneyTurnPersistence(
} satisfies StoredRectificationCase;
}
const current = await loadCase(value.userId, value.id);
if (current?.processedActionIds?.includes(parsedActionId)) return current;
if (
current?.journeyProtocol === "legacy-guided-v1"
&& current.processedActionIds?.includes(parsedActionId)
) return current;
throw new StaleJourneyTurnError(value.id, expectedVersion, current?.turnVersion ?? 0);
},
};
@@ -1,6 +1,7 @@
import { createScoringJobSpec } from "./birth-time-scoring-job.ts";
import type {
BirthTimeJourneyStore,
LegacyStoredRectificationCase,
StoredRectificationCase,
} from "./birth-time-journey-service.ts";
@@ -11,7 +12,7 @@ export type JourneyTurnPersistencePorts = {
export async function persistGuidedJourneyTurn(
ports: JourneyTurnPersistencePorts,
value: StoredRectificationCase,
value: LegacyStoredRectificationCase,
expectedVersion: number,
actionId: string,
): Promise<StoredRectificationCase> {
@@ -3,6 +3,7 @@ import { z } from "zod";
import { BirthTimeScoringJobError } from "./birth-time-scoring-job.ts";
import type {
BirthTimeJourneyStore,
LegacyStoredRectificationCase,
StoredRectificationCase,
} from "./birth-time-journey-service.ts";
import { BirthTimeJourneyStoreError } from "./birth-time-journey-turn-persistence.ts";
@@ -48,9 +49,11 @@ async function requireReloaded(
loadCase: (userId: string, caseId: string) => Promise<StoredRectificationCase | null>,
userId: string,
caseId: string,
): Promise<StoredRectificationCase> {
): Promise<LegacyStoredRectificationCase> {
const stored = await loadCase(userId, caseId);
if (!stored) throw new BirthTimeJourneyStoreError("load_case");
if (!stored || stored.journeyProtocol !== "legacy-guided-v1") {
throw new BirthTimeJourneyStoreError("load_case");
}
return stored;
}
@@ -78,7 +81,10 @@ export function createSupabaseScoringJobStore(
});
if (error) {
const current = await loadCase(value.userId, value.id);
if (current?.processedActionIds?.includes(actionId.toLowerCase())) return current;
if (
current?.journeyProtocol === "legacy-guided-v1"
&& current.processedActionIds?.includes(actionId.toLowerCase())
) return current;
throw mapScoringRpcError(error.message, error.code);
}
return requireReloaded(loadCase, value.userId, value.id);
@@ -142,8 +142,6 @@ begin
update public.profiles
set reported_birth_time = nullif(p_profile ->> 'reportedBirthTime', '')::time,
active_birth_time = nullif(p_profile ->> 'activeBirthTime', '')::time,
birth_time = nullif(p_profile ->> 'birthTime', '')::time,
birth_time_source = p_profile ->> 'birthTimeSource',
birth_time_period = nullif(p_profile ->> 'birthTimePeriod', ''),
birth_time_clue = nullif(p_profile ->> 'birthTimeClue', ''),
@@ -0,0 +1,107 @@
begin;
alter function public.create_birth_time_scoring_job(
uuid, uuid, uuid, bigint, uuid, text, text, timestamptz,
jsonb, jsonb, jsonb, integer, text[]
) rename to create_birth_time_scoring_job_without_protocol_guard;
alter function public.claim_birth_time_scoring_job(
uuid, uuid, uuid, text, text, timestamptz
) rename to claim_birth_time_scoring_job_without_protocol_guard;
alter function public.complete_birth_time_scoring_job(
uuid, uuid, uuid, bigint, text, jsonb, jsonb, jsonb,
time without time zone, time without time zone, integer, text[]
) rename to complete_birth_time_scoring_job_without_protocol_guard;
alter function public.fail_birth_time_scoring_job(
uuid, uuid, uuid, bigint, text, text, jsonb
) rename to fail_birth_time_scoring_job_without_protocol_guard;
create function public.create_birth_time_scoring_job(
p_user_id uuid, p_case_id uuid, p_job_id uuid,
p_expected_version bigint, p_action_id uuid,
p_evidence_fingerprint text, p_algorithm_version text,
p_expires_at timestamptz, p_snapshot jsonb, p_turn_state jsonb,
p_life_events jsonb, p_adaptive_round integer, p_asked_domains text[]
) returns uuid language plpgsql security definer set search_path = '' as $$
begin
perform 1 from public.birth_time_rectification_cases
where id = p_case_id and user_id = p_user_id
and journey_protocol = 'legacy-guided-v1' for update;
if not found then raise exception 'birth_time_legacy_protocol_required'; end if;
return public.create_birth_time_scoring_job_without_protocol_guard(
p_user_id, p_case_id, p_job_id, p_expected_version, p_action_id,
p_evidence_fingerprint, p_algorithm_version, p_expires_at,
p_snapshot, p_turn_state, p_life_events, p_adaptive_round, p_asked_domains
);
end;
$$;
create function public.claim_birth_time_scoring_job(
p_user_id uuid, p_case_id uuid, p_job_id uuid,
p_evidence_fingerprint text, p_algorithm_version text, p_now timestamptz
) returns table (claim_state text, algorithm_version text)
language plpgsql security definer set search_path = '' as $$
begin
perform 1 from public.birth_time_rectification_cases
where id = p_case_id and user_id = p_user_id
and journey_protocol = 'legacy-guided-v1' for update;
if not found then raise exception 'birth_time_legacy_protocol_required'; end if;
return query select *
from public.claim_birth_time_scoring_job_without_protocol_guard(
p_user_id, p_case_id, p_job_id, p_evidence_fingerprint,
p_algorithm_version, p_now
);
end;
$$;
create function public.complete_birth_time_scoring_job(
p_user_id uuid, p_case_id uuid, p_job_id uuid,
p_expected_version bigint, p_evidence_fingerprint text,
p_snapshot jsonb, p_turn_state jsonb, p_candidate_result jsonb,
p_candidate_start time without time zone,
p_candidate_end time without time zone,
p_adaptive_round integer, p_asked_domains text[]
) returns void language plpgsql security definer set search_path = '' as $$
begin
perform 1 from public.birth_time_rectification_cases
where id = p_case_id and user_id = p_user_id
and journey_protocol = 'legacy-guided-v1' for update;
if not found then raise exception 'birth_time_legacy_protocol_required'; end if;
perform public.complete_birth_time_scoring_job_without_protocol_guard(
p_user_id, p_case_id, p_job_id, p_expected_version,
p_evidence_fingerprint, p_snapshot, p_turn_state, p_candidate_result,
p_candidate_start, p_candidate_end, p_adaptive_round, p_asked_domains
);
end;
$$;
create function public.fail_birth_time_scoring_job(
p_user_id uuid, p_case_id uuid, p_job_id uuid,
p_expected_version bigint, p_evidence_fingerprint text,
p_failure_code text, p_turn_state jsonb
) returns void language plpgsql security definer set search_path = '' as $$
begin
perform 1 from public.birth_time_rectification_cases
where id = p_case_id and user_id = p_user_id
and journey_protocol = 'legacy-guided-v1' for update;
if not found then raise exception 'birth_time_legacy_protocol_required'; end if;
perform public.fail_birth_time_scoring_job_without_protocol_guard(
p_user_id, p_case_id, p_job_id, p_expected_version,
p_evidence_fingerprint, p_failure_code, p_turn_state
);
end;
$$;
revoke all on function public.create_birth_time_scoring_job_without_protocol_guard(uuid, uuid, uuid, bigint, uuid, text, text, timestamptz, jsonb, jsonb, jsonb, integer, text[]) from public, anon, authenticated, service_role;
revoke all on function public.claim_birth_time_scoring_job_without_protocol_guard(uuid, uuid, uuid, text, text, timestamptz) from public, anon, authenticated, service_role;
revoke all on function public.complete_birth_time_scoring_job_without_protocol_guard(uuid, uuid, uuid, bigint, text, jsonb, jsonb, jsonb, time without time zone, time without time zone, integer, text[]) from public, anon, authenticated, service_role;
revoke all on function public.fail_birth_time_scoring_job_without_protocol_guard(uuid, uuid, uuid, bigint, text, text, jsonb) from public, anon, authenticated, service_role;
revoke all on function public.create_birth_time_scoring_job(uuid, uuid, uuid, bigint, uuid, text, text, timestamptz, jsonb, jsonb, jsonb, integer, text[]) from public, anon, authenticated;
revoke all on function public.claim_birth_time_scoring_job(uuid, uuid, uuid, text, text, timestamptz) from public, anon, authenticated;
revoke all on function public.complete_birth_time_scoring_job(uuid, uuid, uuid, bigint, text, jsonb, jsonb, jsonb, time without time zone, time without time zone, integer, text[]) from public, anon, authenticated;
revoke all on function public.fail_birth_time_scoring_job(uuid, uuid, uuid, bigint, text, text, jsonb) from public, anon, authenticated;
grant execute on function public.create_birth_time_scoring_job(uuid, uuid, uuid, bigint, uuid, text, text, timestamptz, jsonb, jsonb, jsonb, integer, text[]) to service_role;
grant execute on function public.claim_birth_time_scoring_job(uuid, uuid, uuid, text, text, timestamptz) to service_role;
grant execute on function public.complete_birth_time_scoring_job(uuid, uuid, uuid, bigint, text, jsonb, jsonb, jsonb, time without time zone, time without time zone, integer, text[]) to service_role;
grant execute on function public.fail_birth_time_scoring_job(uuid, uuid, uuid, bigint, text, text, jsonb) to service_role;
commit;
@@ -0,0 +1,71 @@
begin;
alter function public.confirm_birth_time_candidate(
uuid, uuid, uuid, time without time zone, jsonb
) rename to confirm_birth_time_candidate_without_protocol_guard;
alter function public.save_guided_birth_time_candidate(
uuid, uuid, uuid, uuid, integer, jsonb
) rename to save_guided_birth_time_candidate_without_protocol_guard;
alter function public.confirm_guided_birth_time_candidate(
uuid, uuid, uuid, time without time zone, uuid, integer, jsonb, jsonb
) rename to confirm_guided_birth_time_candidate_without_protocol_guard;
create function public.confirm_birth_time_candidate(
p_user_id uuid, p_case_id uuid, p_result_id uuid,
p_time time without time zone, p_snapshot jsonb
) returns void language plpgsql security definer set search_path = '' as $$
begin
perform 1 from public.birth_time_rectification_cases
where id = p_case_id and user_id = p_user_id
and journey_protocol = 'legacy-guided-v1' for update;
if not found then raise exception 'birth_time_legacy_protocol_required'; end if;
perform public.confirm_birth_time_candidate_without_protocol_guard(
p_user_id, p_case_id, p_result_id, p_time, p_snapshot
);
end;
$$;
create function public.save_guided_birth_time_candidate(
p_user_id uuid, p_case_id uuid, p_result_id uuid,
p_action_id uuid, p_expected_version integer, p_turn_state jsonb
) returns void language plpgsql security definer set search_path = '' as $$
begin
perform 1 from public.birth_time_rectification_cases
where id = p_case_id and user_id = p_user_id
and journey_protocol = 'legacy-guided-v1' for update;
if not found then raise exception 'birth_time_legacy_protocol_required'; end if;
perform public.save_guided_birth_time_candidate_without_protocol_guard(
p_user_id, p_case_id, p_result_id, p_action_id,
p_expected_version, p_turn_state
);
end;
$$;
create function public.confirm_guided_birth_time_candidate(
p_user_id uuid, p_case_id uuid, p_result_id uuid,
p_time time without time zone, p_action_id uuid,
p_expected_version integer, p_snapshot jsonb, p_turn_state jsonb
) returns void language plpgsql security definer set search_path = '' as $$
begin
perform 1 from public.birth_time_rectification_cases
where id = p_case_id and user_id = p_user_id
and journey_protocol = 'legacy-guided-v1' for update;
if not found then raise exception 'birth_time_legacy_protocol_required'; end if;
perform public.confirm_guided_birth_time_candidate_without_protocol_guard(
p_user_id, p_case_id, p_result_id, p_time, p_action_id,
p_expected_version, p_snapshot, p_turn_state
);
end;
$$;
revoke all on function public.confirm_birth_time_candidate_without_protocol_guard(uuid, uuid, uuid, time without time zone, jsonb) from public, anon, authenticated, service_role;
revoke all on function public.save_guided_birth_time_candidate_without_protocol_guard(uuid, uuid, uuid, uuid, integer, jsonb) from public, anon, authenticated, service_role;
revoke all on function public.confirm_guided_birth_time_candidate_without_protocol_guard(uuid, uuid, uuid, time without time zone, uuid, integer, jsonb, jsonb) from public, anon, authenticated, service_role;
revoke all on function public.confirm_birth_time_candidate(uuid, uuid, uuid, time without time zone, jsonb) from public, anon, authenticated;
revoke all on function public.save_guided_birth_time_candidate(uuid, uuid, uuid, uuid, integer, jsonb) from public, anon, authenticated;
revoke all on function public.confirm_guided_birth_time_candidate(uuid, uuid, uuid, time without time zone, uuid, integer, jsonb, jsonb) from public, anon, authenticated;
grant execute on function public.confirm_birth_time_candidate(uuid, uuid, uuid, time without time zone, jsonb) to service_role;
grant execute on function public.save_guided_birth_time_candidate(uuid, uuid, uuid, uuid, integer, jsonb) to service_role;
grant execute on function public.confirm_guided_birth_time_candidate(uuid, uuid, uuid, time without time zone, uuid, integer, jsonb, jsonb) to service_role;
commit;
@@ -7,6 +7,7 @@ import { createGuidedCandidateActions } from "../src/lib/birth-time-guided-candi
import {
createBirthTimeJourneyService,
type LegacyBirthTimeJourneyEngine,
type DynamicVersionedJourneyResponse,
type StoredRectificationCase,
type VersionedJourneyResponse,
} from "../src/lib/birth-time-journey-service.ts";
@@ -107,7 +108,12 @@ export function createHarness(input: {
};
}
export function assertLegalTurn(turn: VersionedJourneyResponse): void {
export function assertLegalTurn(
turn: VersionedJourneyResponse | DynamicVersionedJourneyResponse,
): asserts turn is VersionedJourneyResponse {
if (turn.journeyProtocol === "dynamic-choice-v2") {
assert.fail("legacy flow unexpectedly resumed a dynamic journey");
}
assert.ok(turn.nextAction, "every legal journey response has nextAction");
assert.ok(turn.turnVersion >= 0);
switch (turn.nextAction.kind) {
@@ -142,13 +142,16 @@ export const privateRow = {
export function loadClient(
privateState: typeof privateRow | null,
omitProcessedActions = false,
publicTurnVersion = publicRow.turn_version,
) {
const { processed_action_ids: ignoredReceipts, ...rowWithoutReceipts } = publicRow;
void ignoredReceipts;
return {
from(table: string) {
const row = table === "birth_time_rectification_cases"
? omitProcessedActions ? rowWithoutReceipts : publicRow
? omitProcessedActions
? rowWithoutReceipts
: { ...publicRow, turn_version: publicTurnVersion }
: table === "birth_time_rectification_dynamic_state"
? privateState
: { latitude: 31.2304, longitude: 121.4737, timezone_offset: 8 };
@@ -173,8 +176,11 @@ export function dynamicCase(): DynamicStoredRectificationCase {
lifeEvents: [],
candidateResult: null,
turnVersion: 7,
turnState: null,
dynamicTurnState,
evidenceDraft: null,
processedActionIds: [],
persistedProgress: { adaptiveRound: 0, askedDomains: [] },
candidateModel: privateRow.candidate_model,
currentChoiceQuestion: persistedQuestion,
choiceAnswers: [],
@@ -215,6 +221,7 @@ export function legacyCase(active: boolean): LegacyStoredRectificationCase {
return {
id: caseId,
userId: ownerId,
journeyProtocol: "legacy-guided-v1",
snapshot,
questionnaire: null,
answers: { q1: "A" },
@@ -75,6 +75,19 @@ test("unknown birth time initializes the full-day dynamic range", () => {
]);
});
test("mixed-null reported ranges fail instead of inventing one boundary", () => {
const mixedRange = {
...snapshot,
reportedRange: {
label: "04:00—未知",
startTime: "04:00",
endTime: null,
},
};
assert.throws(() => createInitialDynamicState(mixedRange, "2026-07-18"));
});
test("new v2 case creation crosses one atomic RPC boundary", async () => {
const calls: { readonly name: string; readonly args: Readonly<Record<string, unknown>> }[] = [];
const savedId = await saveDynamicAssessment({
@@ -100,6 +113,15 @@ test("new v2 case creation crosses one atomic RPC boundary", async () => {
assert.equal(calls[0]?.name, "create_birth_time_dynamic_case");
assert.equal(calls[0]?.args.p_user_id, ownerId);
assert.equal(JSON.stringify(calls[0]?.args.p_public_case).includes("candidateModel"), false);
assert.deepEqual(calls[0]?.args.p_profile, {
reportedBirthTime: null,
birthTimeSource: "period_only",
birthTimePeriod: "early_morning",
birthTimeClue: null,
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
birthTimeStatus: "rectifying",
});
assert.deepEqual(calls[0]?.args.p_private_state, createInitialDynamicState(
snapshot,
"2026-07-18",
@@ -120,6 +142,13 @@ test("v2 load rejects missing required public persistence fields", async () => {
);
});
test("v2 load rejects incoherent public row and JSON turn versions", async () => {
await assert.rejects(
loadStoredRectificationCase(loadClient(privateRow, false, 8), ownerId, caseId),
BirthTimeJourneyStoreError,
);
});
test("v2 cases cannot fall through to legacy mutation paths", () => {
assert.throws(() => assertLegacyJourneyMutation(dynamicCase()), {
name: "GuidedJourneyLegacyMutationError",
@@ -166,11 +195,12 @@ test("memory replay returns the stored advanced dynamic turn", async () => {
const changed = { ...initial, agentContext: ["persisted context"] };
const saved = await memory.store.saveDynamicTurn(changed, 7, actionId);
const replay = await memory.store.saveDynamicTurn(initial, 7, actionId);
const replay = await memory.store.saveDynamicTurn(initial, 7, actionId.toUpperCase());
assert.deepEqual(replay, saved);
assert.equal(replay.turnVersion, 8);
assert.deepEqual(replay.agentContext, ["persisted context"]);
assert.deepEqual(replay.processedActionIds, [actionId]);
assert.equal(memory.committedTurnWrites(), 1);
});
@@ -203,6 +233,7 @@ test("legacy active upgrade preserves evidence and starts v2 without legacy fing
journeyProtocol: "dynamic-choice-v2",
turnVersion: loaded.turnVersion ?? 0,
processedActionIds: loaded.processedActionIds ?? [],
persistedProgress: loaded.persistedProgress ?? { adaptiveRound: 0, askedDomains: [] },
dynamicTurnState: parsedTurn,
turnState: null,
evidenceDraft: null,
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
import {
caseId,
dynamicCase,
ownerId,
} from "./birth-time-dynamic-persistence-fixture.ts";
import { memoryStore } from "./birth-time-journey-memory-store.ts";
import { unusedJourneyEngine } from "./birth-time-journey-test-support.ts";
test("v2 resume returns the stored dynamic turn without legacy scoring writes", async () => {
const stored = {
...dynamicCase(),
scoring: {
answeredCount: 5,
candidateClusterRankings: [],
nextRound: null,
nextRoundQuestions: [],
raw: {},
},
};
const memory = memoryStore(stored);
const service = createBirthTimeJourneyService({ store: memory.store, engine: unusedJourneyEngine });
const resumed = await service.resume(ownerId, caseId);
assert.equal(resumed.journeyProtocol, "dynamic-choice-v2");
assert.deepEqual({
journeyProtocol: resumed.journeyProtocol,
turnVersion: resumed.turnVersion,
nextAction: resumed.nextAction,
progress: resumed.progress,
permissions: resumed.permissions,
}, stored.dynamicTurnState);
assert.equal(memory.legacyWrites(), 0);
});
@@ -44,6 +44,7 @@ function storedCase(): LegacyStoredRectificationCase {
return {
id: caseId,
userId: "owner-1",
journeyProtocol: "legacy-guided-v1",
snapshot: {
state: "rectifying",
assistantIntent: "continue_rectification_questions",
@@ -53,6 +53,9 @@ export function memoryStore(
if (!savedCase) {
throw new MissingTestCaseError();
}
if (savedCase.journeyProtocol !== "legacy-guided-v1") {
throw new StaleJourneyTurnError(savedCase.id, expectedVersion, savedCase.turnVersion);
}
const processedActionIds = savedCase.processedActionIds ?? [];
if (processedActionIds.includes(actionId)) {
return savedCase;
@@ -70,8 +73,9 @@ export function memoryStore(
},
async saveDynamicTurn(value, expectedVersion, actionId) {
if (!savedCase) throw new MissingTestCaseError();
const receipt = actionId.toLowerCase();
const receipts = savedCase.processedActionIds ?? [];
if (receipts.includes(actionId)) {
if (receipts.includes(receipt)) {
if (!savedDynamicCase) throw new MissingTestCaseError();
return savedDynamicCase;
}
@@ -82,7 +86,7 @@ export function memoryStore(
...value,
turnVersion: expectedVersion + 1,
dynamicTurnState: { ...value.dynamicTurnState, turnVersion: expectedVersion + 1 },
processedActionIds: [...receipts, actionId],
processedActionIds: [...receipts, receipt],
};
savedCase = savedDynamic;
savedDynamicCase = savedDynamic;
@@ -90,7 +94,7 @@ export function memoryStore(
return savedDynamic;
},
async upgradeLegacyActiveCase(value) {
if (value.journeyProtocol === "dynamic-choice-v2" || isTerminalLegacyCase(value)) {
if (isTerminalLegacyCase(value)) {
return value;
}
const upgraded = prepareLegacyDynamicUpgrade(value, asOfDate);
@@ -115,6 +119,13 @@ export function memoryStore(
if (!savedCase) {
throw new MissingTestCaseError();
}
if (savedCase.journeyProtocol !== "legacy-guided-v1") {
throw new StaleJourneyTurnError(
savedCase.id,
command.expectedVersion,
savedCase.turnVersion,
);
}
const receipt = command.actionId.toLowerCase();
const receipts = savedCase.processedActionIds ?? [];
if (receipts.includes(receipt)) {
@@ -48,6 +48,7 @@ test("legacy low-score resume displays adaptive round one exactly once", async (
const first = await flow.service.resume("user-1", journeyCaseId);
const second = await flow.service.resume("user-1", journeyCaseId);
if (first.journeyProtocol === "dynamic-choice-v2") assert.fail("expected legacy response");
assert.equal(first.nextAction.kind, "ask_adaptive_evidence");
assert.equal(first.progress.adaptiveRound, 1);
assert.deepEqual(second.nextAction, first.nextAction);
@@ -78,6 +78,7 @@ test("journey service accumulates legacy answers while preserving the applicatio
const storedCase: StoredRectificationCase = {
id: journeyCaseId,
userId: "user-1",
journeyProtocol: "legacy-guided-v1",
snapshot: assessed.snapshot,
questionnaire,
answers: { education_environment_shift: "A" },
@@ -121,6 +122,7 @@ test("journey service resumes an owner-scoped unfinished legacy case", async ()
const storedCase: StoredRectificationCase = {
id: journeyCaseId,
userId: "user-1",
journeyProtocol: "legacy-guided-v1",
snapshot: {
state: "rectifying",
assistantIntent: "continue_rectification_questions",
@@ -150,6 +152,7 @@ test("journey service heals a completed legacy questionnaire into life-event col
const storedCase: StoredRectificationCase = {
id: journeyCaseId,
userId: "user-1",
journeyProtocol: "legacy-guided-v1",
snapshot: {
state: "candidate",
assistantIntent: "present_saved_candidate_range",
@@ -184,6 +187,7 @@ test("journey service resumes a fail-closed case without a questionnaire", async
const storedCase: StoredRectificationCase = {
id: journeyCaseId,
userId: "user-1",
journeyProtocol: "legacy-guided-v1",
snapshot: {
state: "rectifying",
assistantIntent: "explain_assessment_unavailable",
@@ -128,6 +128,7 @@ export function guidedCase(input: GuidedCaseInput = {}): LegacyStoredRectificati
return {
id: journeyCaseId,
userId: "user-1",
journeyProtocol: "legacy-guided-v1",
snapshot: {
state: "rectifying",
assistantIntent: candidateResult?.confidence === "low"
@@ -11,6 +11,7 @@ const actionId = "45857b75-4718-4590-aaf5-7113a03ea765";
const storedCase = {
id: "case-1",
userId: "user-1",
journeyProtocol: "legacy-guided-v1",
snapshot: { state: "rectifying" },
answers: {},
turnState: { nextAction: { kind: "paused" } },
@@ -79,6 +80,7 @@ test("saveTurn uses one owner-and-version-constrained update", async () => {
}],
["id", "case-1"],
["user_id", "user-1"],
["journey_protocol", "legacy-guided-v1"],
["turn_version", 4],
["not", "processed_action_ids", "cs", `{${actionId}}`],
["select", "id"],
@@ -127,7 +129,25 @@ test("saveTurn treats an uppercase UUID replay as the stored lowercase receipt",
assert.equal(saved, current);
assert.equal(fake.calls[0][1].processed_action_ids[0], actionId);
assert.deepEqual(fake.calls[4], ["not", "processed_action_ids", "cs", `{${actionId}}`]);
assert.deepEqual(fake.calls[5], ["not", "processed_action_ids", "cs", `{${actionId}}`]);
});
test("saveTurn cannot overwrite a case upgraded between load and write", async () => {
const fake = updateClient({ data: null, error: null });
const upgraded = {
...storedCase,
journeyProtocol: "dynamic-choice-v2",
turnVersion: 4,
processedActionIds: [],
};
const persistence = createJourneyTurnPersistence(fake.client, async () => upgraded);
await assert.rejects(
persistence.saveTurn(storedCase, 4, actionId),
StaleJourneyTurnError,
);
assert.deepEqual(fake.calls[3], ["journey_protocol", "legacy-guided-v1"]);
});
test("saveTurn rejects a non-UUID action receipt before writing", async () => {
@@ -181,6 +201,7 @@ test("load accepts only the exact empty legacy turn state", async () => {
const value = await loadStoredRectificationCase(loadClient(storedRow()), "12dc56f0-1f17-4a2f-86bf-1056ab78def9", "45857b75-4718-4590-aaf5-7113a03ea765");
assert.equal(value?.turnState, null);
assert.equal(value?.journeyProtocol, "legacy-guided-v1");
});
test("load rejects a malformed nonempty persisted turn state", async () => {