feat: persist dynamic rectification turns

This commit is contained in:
Jesse_Chen
2026-07-19 06:42:17 +08:00
parent 25d5552a05
commit 723d05c623
30 changed files with 2082 additions and 243 deletions
+86
View File
@@ -0,0 +1,86 @@
# Task 5 Report: Durable v2 Persistence and Legacy Isolation
## Outcome
Implemented durable `dynamic-choice-v2` persistence with a public/private data split. Public case rows contain only the public dynamic turn projection; the exact candidate model, persisted question binding, answers, server evidence, control state, and bounded Agent context are kept in a service-role-only companion row. Versioned turn and scoring-job writes are transactionally coordinated by service-role-only RPCs and never update `active_birth_time`.
New assessments initialize the public case, private state, and profile pointer in one database RPC transaction. Owner-scoped resume parsing requires complete v2 public/private rows and returns a strict legacy/v2 union; only absent protocol data from old legacy rows retains compatibility defaults. Active legacy cases can be upgraded without losing evidence or audit data, terminal legacy cases remain unchanged, and every legacy guided mutation entry point rejects v2 cases before writing.
## Files
- `frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql`
- `frontend/supabase/migrations/20260718091000_dynamic_choice_birth_time_transitions.sql`
- `frontend/src/lib/birth-time-evidence-service.ts`
- `frontend/src/lib/birth-time-guided-candidate.ts`
- `frontend/src/lib/birth-time-guided-draft-revision.ts`
- `frontend/src/lib/birth-time-journey-actions.ts`
- `frontend/src/lib/birth-time-journey-case-loader.ts`
- `frontend/src/lib/birth-time-journey-dynamic-case.ts`
- `frontend/src/lib/birth-time-journey-dynamic-persistence.ts`
- `frontend/src/lib/birth-time-journey-dynamic-state.ts`
- `frontend/src/lib/birth-time-journey-errors.ts`
- `frontend/src/lib/birth-time-journey-service.ts`
- `frontend/src/lib/birth-time-journey-store-errors.ts`
- `frontend/src/lib/birth-time-journey-stored-protocol.ts`
- `frontend/src/lib/birth-time-journey-store.ts`
- `frontend/src/lib/birth-time-journey-turn-persistence.ts`
- `frontend/src/lib/birth-time-scoring-service.ts`
- `frontend/tests/birth-time-dynamic-persistence-fixture.ts`
- `frontend/tests/birth-time-dynamic-persistence.test.ts`
- `frontend/tests/birth-time-journey-memory-store.ts`
- `frontend/tests/birth-time-journey-legacy-isolation.test.ts`
- `tests/test_birth_time_dynamic_persistence_contract.py`
- `tests/test_birth_time_journey_contract.py`
## TDD Evidence
- Migration RED: `.omo/evidence/task-5-python-red.log`
- Persistence RED: `.omo/evidence/task-5-ts-red.log`
- SQL null-state regression RED: `.omo/evidence/task-5-null-state-red.log`
- Unknown-time initialization RED: `.omo/evidence/task-5-unknown-range-red.log`
- Atomic creation RED: `.omo/evidence/task-5-atomic-create-red.log`, `.omo/evidence/task-5-atomic-create-ts-red.log`
- Legacy isolation RED: `.omo/evidence/task-5-guided-isolation-red.log`, `.omo/evidence/task-5-all-legacy-isolation-red.log`
- Memory replay RED: `.omo/evidence/task-5-memory-replay-red.log`
- Migration GREEN: `.omo/evidence/task-5-python-green.log`
- Persistence GREEN: `.omo/evidence/task-5-ts-green.log`
- SQL null-state regression GREEN: `.omo/evidence/task-5-null-state-green.log`
- Unknown-time initialization GREEN: `.omo/evidence/task-5-unknown-range-green.log`
- Atomic creation GREEN: `.omo/evidence/task-5-atomic-create-green.log`
- Legacy isolation and replay GREEN: `.omo/evidence/task-5-memory-and-isolation-green.log`
The additional regressions prove that a missing current scoring action cannot pass a SQL `NOT IN` guard through three-valued `NULL` logic, and that the supported unknown-time assessment initializes a valid full-day dynamic range instead of failing on its intentional null reported bounds.
## Verification
- Focused persistence and legacy-isolation TypeScript: 30/30 passed.
- Relevant Python contracts and engine/API regressions: 36/36 passed.
- Full birth-time frontend suite: 255/255 passed.
- Full frontend suite: 330/330 passed.
- Changed-file ESLint: passed.
- Changed-file Ruff: passed.
- `git diff --check`: passed.
- All changed TypeScript, test, and ordered migration modules are at most 250 pure LOC; maximum is 247.
- Full TypeScript check reports only the known unrelated baseline at `frontend/tests/profile-persistence.test.ts:7` (`TS1501`, ES2018 regex under the existing target).
Evidence:
- `.omo/evidence/task-5-python-relevant.log`
- `.omo/evidence/task-5-birth-time-suite.log`
- `.omo/evidence/task-5-frontend-full.log`
- `.omo/evidence/task-5-eslint.log`
- `.omo/evidence/task-5-ruff.log`
- `.omo/evidence/task-5-diff-check.log`
- `.omo/evidence/task-5-loc-final.log`
- `.omo/evidence/task-5-tsc.log`
## Review
Fresh review: **CLEAR / APPROVE**, with no blockers. Artifact: `.omo/evidence/task-5-code-review.md`.
Live PostgreSQL execution was not available: Docker CLI is installed, but the daemon socket does not exist. `.omo/evidence/task-5-live-postgres-unavailable.log` records the exact failure. SQL checks are therefore described only as static migration contracts; TypeScript fakes execute the RPC boundary and failure/replay semantics without claiming database execution.
Task 6 remains responsible for routing public `assess`/`resume` responses and dynamic actions through the v2 transition service. Task 5 establishes and verifies the durable store boundary those transitions use.
## Commit
Commit message: `feat: persist dynamic rectification turns`
@@ -6,6 +6,7 @@ import type { CandidateResult, LifeEvent } from "./birth-time-evidence.ts";
import type {
BirthTimeJourneyPorts,
JourneyResponse,
LegacyStoredRectificationCase,
StoredRectificationCase,
} from "./birth-time-journey-service.ts";
@@ -40,10 +41,21 @@ export class GuidedJourneyLegacyMutationError extends Error {
}
}
export function assertNotDynamicJourneyMutation(
stored: StoredRectificationCase,
): asserts stored is LegacyStoredRectificationCase {
if (stored.journeyProtocol === "dynamic-choice-v2") {
throw new GuidedJourneyLegacyMutationError(stored.id);
}
}
export function assertLegacyJourneyMutation(
stored: StoredRectificationCase,
): void {
if (stored.turnState) throw new GuidedJourneyLegacyMutationError(stored.id);
): asserts stored is LegacyStoredRectificationCase {
assertNotDynamicJourneyMutation(stored);
if (stored.turnState) {
throw new GuidedJourneyLegacyMutationError(stored.id);
}
}
function response(
@@ -1,8 +1,9 @@
import { withConfirmedCandidate } from "./birth-time-evidence.ts";
import { assertNotDynamicJourneyMutation } from "./birth-time-evidence-service.ts";
import { currentJourneyTurn, storedJourneyResponse } from "./birth-time-journey-response.ts";
import type {
BirthTimeJourneyStore,
StoredRectificationCase,
LegacyStoredRectificationCase as StoredRectificationCase,
} from "./birth-time-journey-service.ts";
import type { JourneyTurnState } from "./birth-time-journey-turn.ts";
import { StaleJourneyTurnError } from "./birth-time-journey-turn-persistence.ts";
@@ -35,6 +36,7 @@ type GuidedCandidatePorts = { readonly store: BirthTimeJourneyStore };
async function ownedCase(ports: GuidedCandidatePorts, userId: string, caseId: string) {
const stored = await ports.store.loadCase(userId, caseId);
if (!stored) throw new GuidedCandidateActionError("case_not_found");
assertNotDynamicJourneyMutation(stored);
return stored;
}
@@ -1,4 +1,5 @@
import { lifeEventSchema } from "./birth-time-evidence.ts";
import { assertNotDynamicJourneyMutation } from "./birth-time-evidence-service.ts";
import { currentJourneyTurn, storedJourneyResponse } from "./birth-time-journey-response.ts";
import type { VersionedJourneyResponse } from "./birth-time-journey-service.ts";
import type { JourneyTurnPersistencePorts } from "./birth-time-scoring-job-persistence.ts";
@@ -30,6 +31,7 @@ export function createGuidedDraftRevisionActions(
async revise(input: DraftRevision) {
const stored = await ports.store.loadCase(input.userId, input.caseId);
if (!stored) throw new BirthTimeJourneyActionError("case_not_found", input.caseId);
assertNotDynamicJourneyMutation(stored);
if (stored.processedActionIds?.includes(input.actionId.toLowerCase())) {
return storedJourneyResponse(stored);
}
@@ -3,6 +3,7 @@ import {
lifeEventSchema,
type EvidenceDraftProposal,
} from "./birth-time-evidence.ts";
import { assertNotDynamicJourneyMutation } from "./birth-time-evidence-service.ts";
import {
currentJourneyTurn,
persistedJourneyResponse,
@@ -22,6 +23,7 @@ import type { EvidenceDraft } from "./birth-time-journey-turn.ts";
import { persistGuidedJourneyTurn } from "./birth-time-scoring-job-persistence.ts";
import type { JourneyTurnPersistencePorts } from "./birth-time-scoring-job-persistence.ts";
import type {
LegacyStoredRectificationCase,
StoredRectificationCase,
VersionedJourneyResponse,
} from "./birth-time-journey-service.ts";
@@ -42,7 +44,7 @@ export class BirthTimeJourneyActionError extends Error {
type MutationContext = {
readonly ports: JourneyTurnPersistencePorts;
readonly stored: StoredRectificationCase;
readonly stored: LegacyStoredRectificationCase;
readonly expectedVersion: number;
readonly actionId: string;
};
@@ -56,6 +58,7 @@ async function actionContext(input: {
}): Promise<MutationContext> {
const stored = await input.ports.store.loadCase(input.userId, input.caseId);
if (!stored) throw new BirthTimeJourneyActionError("case_not_found", input.caseId);
assertNotDynamicJourneyMutation(stored);
return {
ports: input.ports,
stored,
@@ -134,7 +137,7 @@ export function createJourneyTurnActions(ports: JourneyTurnPersistencePorts) {
caseId: string,
actionId: string,
expectedVersion: number,
transition: (stored: StoredRectificationCase) => StoredRectificationCase,
transition: (stored: LegacyStoredRectificationCase) => LegacyStoredRectificationCase,
response: (stored: StoredRectificationCase) => VersionedJourneyResponse = storedJourneyResponse,
) {
const mutation = await context(userId, caseId, actionId, expectedVersion);
@@ -0,0 +1,222 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import {
parseRectificationQuestionnaire,
parseRectificationScoring,
} from "./birth-time-journey-adapters.ts";
import {
candidateResultSchema,
journeySnapshotSchema,
lifeEventSchema,
} from "./birth-time-journey.ts";
import type { StoredRectificationCase } from "./birth-time-journey-service.ts";
import { evidenceDomains } from "./birth-time-question-planner.ts";
import {
dynamicJourneyTurnStateSchema,
evidenceDraftSchema,
journeyTurnStateSchema,
} from "./birth-time-journey-turn.ts";
import type { EvidenceDraft, JourneyTurnState } from "./birth-time-journey-turn.ts";
import {
dynamicTurnMatchesPrivateQuestion,
parseDynamicPrivateRow,
} from "./birth-time-journey-dynamic-state.ts";
import { BirthTimeJourneyStoreError } from "./birth-time-journey-store-errors.ts";
type JourneyLoadResult = { readonly data: unknown; readonly error: unknown };
type JourneyLoadQuery = {
eq(column: string, value: string): JourneyLoadQuery;
maybeSingle(): PromiseLike<JourneyLoadResult>;
};
export type JourneyLoadClient = {
from(table: string): { select(columns: string): JourneyLoadQuery };
};
export function createJourneyLoadClient(supabase: SupabaseClient): JourneyLoadClient {
return {
from(table) {
return {
select(columns) {
const query = supabase.from(table).select(columns);
const loadQuery: JourneyLoadQuery = {
eq(column, value) {
query.eq(column, value);
return loadQuery;
},
async maybeSingle() {
const { data, error } = await query.maybeSingle();
return { data, error };
},
};
return loadQuery;
},
};
},
};
}
const storedCaseSchema = z.object({
id: z.string().uuid(),
user_id: z.string().uuid(),
journey_protocol: z.enum(["legacy-guided-v1", "dynamic-choice-v2"]).default("legacy-guided-v1"),
journey_snapshot: journeySnapshotSchema,
questionnaire: z.record(z.unknown()),
answers: z.record(z.enum(["A", "B", "C", "D"])),
scoring_result: z.record(z.unknown()),
reported_date: z.string(),
life_events: z.array(lifeEventSchema).default([]),
candidate_result: z.record(z.unknown()).default({}),
turn_version: z.number().int().nonnegative().default(0),
turn_state: z.unknown().default({}),
evidence_draft: z.unknown().nullable().default(null),
processed_action_ids: z.array(z.string().uuid()).default([]),
adaptive_round: z.number().int().min(0).max(3).default(0),
asked_domains: z.array(z.enum(evidenceDomains)).default([]),
});
const eventLocationSchema = z.object({
latitude: z.number(),
longitude: z.number(),
timezone_offset: z.number(),
});
const requiredDynamicPublicFields = [
"journey_protocol",
"life_events",
"candidate_result",
"turn_version",
"turn_state",
"evidence_draft",
"processed_action_ids",
"adaptive_round",
"asked_domains",
] as const;
function hasRequiredDynamicPublicFields(value: unknown): boolean {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
return requiredDynamicPublicFields.every((field) => Object.prototype.hasOwnProperty.call(value, field));
}
function exactEmptyObject(value: unknown): boolean {
return typeof value === "object"
&& value !== null
&& !Array.isArray(value)
&& Object.keys(value).length === 0;
}
function parseLegacyTurn(value: unknown): JourneyTurnState | null {
if (exactEmptyObject(value)) return null;
const parsed = journeyTurnStateSchema.safeParse(value);
if (parsed.success) return parsed.data;
throw new BirthTimeJourneyStoreError("load_case");
}
function parseEvidenceDraft(value: unknown): EvidenceDraft | null {
if (value === null) return null;
const parsed = evidenceDraftSchema.safeParse(value);
if (parsed.success) return parsed.data;
throw new BirthTimeJourneyStoreError("load_case");
}
export async function loadStoredRectificationCase(
client: JourneyLoadClient,
userId: string,
caseId: string,
): Promise<StoredRectificationCase | null> {
const { data, error } = await client
.from("birth_time_rectification_cases")
.select("id,user_id,journey_protocol,journey_snapshot,questionnaire,answers,scoring_result,reported_date,life_events,candidate_result,turn_version,turn_state,evidence_draft,processed_action_ids,adaptive_round,asked_domains")
.eq("id", caseId)
.eq("user_id", userId)
.maybeSingle();
if (error) throw new BirthTimeJourneyStoreError("load_case");
if (!data) return null;
if (
typeof data === "object"
&& data !== null
&& "journey_protocol" in data
&& data.journey_protocol === "dynamic-choice-v2"
&& !hasRequiredDynamicPublicFields(data)
) throw new BirthTimeJourneyStoreError("load_case");
const parsed = storedCaseSchema.parse(data);
const { data: profile, error: profileError } = await client
.from("profiles")
.select("latitude,longitude,timezone_offset")
.eq("id", userId)
.maybeSingle();
if (profileError || !profile) throw new BirthTimeJourneyStoreError("load_case");
const location = eventLocationSchema.parse(profile);
const scoring = Object.keys(parsed.scoring_result).length > 0
? parseRectificationScoring(parsed.scoring_result)
: undefined;
const questionnaire = Object.keys(parsed.questionnaire).length > 0
? parseRectificationQuestionnaire(parsed.questionnaire)
: null;
const candidateResult = Object.keys(parsed.candidate_result).length > 0
? candidateResultSchema.parse(parsed.candidate_result)
: null;
const turnState = parsed.journey_protocol === "legacy-guided-v1"
? parseLegacyTurn(parsed.turn_state)
: null;
const dynamicTurn = parsed.journey_protocol === "dynamic-choice-v2"
? dynamicJourneyTurnStateSchema.safeParse(parsed.turn_state)
: null;
if (dynamicTurn && !dynamicTurn.success) throw new BirthTimeJourneyStoreError("load_case");
const dynamicTurnState = dynamicTurn?.data ?? null;
const evidenceDraft = parseEvidenceDraft(parsed.evidence_draft);
let dynamicPrivate = null;
if (parsed.journey_protocol === "dynamic-choice-v2") {
const { data: privateRow, error: privateError } = await client
.from("birth_time_rectification_dynamic_state")
.select("case_id,user_id,candidate_model,current_choice_question,choice_answers,choice_evidence,dynamic_control,agent_context")
.eq("case_id", caseId)
.eq("user_id", userId)
.maybeSingle();
if (privateError) throw new BirthTimeJourneyStoreError("load_case");
dynamicPrivate = parseDynamicPrivateRow(privateRow, userId, caseId);
if (
dynamicTurnState === null
|| !dynamicTurnMatchesPrivateQuestion(dynamicTurnState, dynamicPrivate)
) throw new BirthTimeJourneyStoreError("load_case");
}
const common = {
id: parsed.id,
userId: parsed.user_id,
snapshot: parsed.journey_snapshot,
questionnaire,
answers: parsed.answers,
eventContext: {
birthDate: parsed.reported_date,
lat: location.latitude,
lon: location.longitude,
tz: location.timezone_offset,
},
lifeEvents: parsed.life_events,
candidateResult,
turnVersion: parsed.turn_version,
processedActionIds: parsed.processed_action_ids,
persistedProgress: {
adaptiveRound: parsed.adaptive_round,
askedDomains: parsed.asked_domains,
},
...(scoring ? { scoring } : {}),
};
if (parsed.journey_protocol === "legacy-guided-v1") {
return {
...common,
journeyProtocol: "legacy-guided-v1",
turnState,
evidenceDraft,
} satisfies StoredRectificationCase;
}
if (dynamicTurnState === null || dynamicPrivate === null || evidenceDraft !== null) {
throw new BirthTimeJourneyStoreError("load_case");
}
return {
...common,
journeyProtocol: "dynamic-choice-v2",
turnState: null,
dynamicTurnState,
evidenceDraft: null,
...dynamicPrivate,
} satisfies StoredRectificationCase;
}
@@ -0,0 +1,68 @@
import { z } from "zod";
import type { PersistedJourneyAssessment } from "./birth-time-journey-service.ts";
import type { DynamicRpcClient } from "./birth-time-journey-dynamic-persistence.ts";
import { createInitialDynamicState } from "./birth-time-journey-dynamic-state.ts";
import { BirthTimeJourneyStoreError } from "./birth-time-journey-store-errors.ts";
import { caseStatus, profileStatus } from "./birth-time-journey-turn-persistence.ts";
function assessmentDetails(value: PersistedJourneyAssessment) {
const assessment = value.assessment;
return {
reportedTime: "reportedTime" in assessment ? assessment.reportedTime : null,
period: assessment.source === "period_only" ? assessment.period : null,
clue: assessment.source === "unknown" ? assessment.clue : null,
before: "uncertaintyBeforeMinutes" in assessment
? assessment.uncertaintyBeforeMinutes
: null,
after: "uncertaintyAfterMinutes" in assessment
? assessment.uncertaintyAfterMinutes
: null,
};
}
export async function saveDynamicAssessment(
client: DynamicRpcClient,
value: PersistedJourneyAssessment,
at: Date,
): Promise<string> {
const details = assessmentDetails(value);
const initial = createInitialDynamicState(
value.snapshot,
at.toISOString().slice(0, 10),
);
const result = await client.rpc("create_birth_time_dynamic_case", {
p_user_id: value.userId,
p_public_case: {
journeyProtocol: "dynamic-choice-v2",
status: caseStatus(value.snapshot),
reportedDate: value.assessment.date,
reportedTime: details.reportedTime,
reportedPeriod: details.period,
source: value.assessment.source,
uncertaintyBeforeMinutes: details.before,
uncertaintyAfterMinutes: details.after,
questionnaire: value.questionnaire?.raw ?? {},
journeySnapshot: value.snapshot,
candidateScan: value.candidateScan?.raw ?? {},
turnState: initial.turn,
candidateStart: value.snapshot.reportedRange.startTime,
candidateEnd: value.snapshot.reportedRange.endTime,
confirmedTime: value.snapshot.activeTime,
confirmedAt: value.snapshot.state === "ready" ? at.toISOString() : null,
},
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,
uncertaintyBeforeMinutes: details.before,
uncertaintyAfterMinutes: details.after,
birthTimeStatus: profileStatus(value.snapshot),
},
});
if (result.error) throw new BirthTimeJourneyStoreError("insert_case");
return z.string().uuid().parse(result.data);
}
@@ -0,0 +1,149 @@
import { z } from "zod";
import { toPublicDynamicChoiceQuestion } from "./birth-time-dynamic-choice-internal.ts";
import {
dynamicJourneyTurnStateSchema,
} from "./birth-time-journey-turn-protocol.ts";
import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts";
import type {
DynamicStoredRectificationCase,
StoredRectificationCase,
} from "./birth-time-journey-service.ts";
import {
dynamicPrivateStateSchema,
isTerminalLegacyCase,
prepareLegacyDynamicUpgrade,
} from "./birth-time-journey-dynamic-state.ts";
import type { DynamicPrivateJourneyState } from "./birth-time-journey-dynamic-state.ts";
import {
BirthTimeJourneyStoreError,
StaleJourneyTurnError,
} from "./birth-time-journey-store-errors.ts";
export { BirthTimeDynamicStateMissingError } from "./birth-time-journey-dynamic-state.ts";
const actionIdSchema = z.string().uuid();
const rpcVersionSchema = z.number().int().nonnegative();
type RpcError = { readonly message: string };
type RpcResult = { readonly data: unknown; readonly error: RpcError | null };
export type DynamicRpcClient = {
readonly rpc: (
name: string,
args: Readonly<Record<string, unknown>>,
) => PromiseLike<RpcResult>;
};
function publicTurn(
value: DynamicStoredRectificationCase,
turnVersion: number,
): DynamicJourneyTurnState {
const currentQuestion = value.currentChoiceQuestion;
if (value.dynamicTurnState.nextAction.kind === "ask_dynamic_choice") {
if (currentQuestion === null) throw new BirthTimeJourneyStoreError("update_case");
return dynamicJourneyTurnStateSchema.parse({
...value.dynamicTurnState,
turnVersion,
nextAction: {
kind: "ask_dynamic_choice",
question: toPublicDynamicChoiceQuestion(currentQuestion),
},
});
}
return dynamicJourneyTurnStateSchema.parse({
...value.dynamicTurnState,
turnVersion,
});
}
function privateState(value: DynamicStoredRectificationCase): DynamicPrivateJourneyState {
return dynamicPrivateStateSchema.parse({
candidateModel: value.candidateModel,
currentChoiceQuestion: value.currentChoiceQuestion,
choiceAnswers: value.choiceAnswers,
choiceEvidence: value.choiceEvidence,
dynamicControl: value.dynamicControl,
agentContext: value.agentContext,
});
}
function isStaleRpc(error: RpcError): boolean {
return error.message.includes("stale_birth_time_dynamic_turn")
|| error.message.includes("stale_birth_time_legacy_upgrade");
}
export function createDynamicTurnPersistence(
client: DynamicRpcClient,
loadCase: (userId: string, caseId: string) => Promise<StoredRectificationCase | null>,
asOfDate: () => string,
) {
async function loadedDynamic(userId: string, caseId: string): Promise<DynamicStoredRectificationCase> {
const loaded = await loadCase(userId, caseId);
if (!loaded || loaded.journeyProtocol !== "dynamic-choice-v2") {
throw new BirthTimeJourneyStoreError("load_case");
}
return loaded;
}
return {
async saveDynamicTurn(
value: DynamicStoredRectificationCase,
expectedVersion: number,
actionId: string,
): Promise<DynamicStoredRectificationCase> {
const receipt = actionIdSchema.parse(actionId).toLowerCase();
const result = await client.rpc("save_birth_time_dynamic_turn", {
p_user_id: value.userId,
p_case_id: value.id,
p_expected_version: expectedVersion,
p_action_id: receipt,
p_public_turn_state: publicTurn(value, expectedVersion + 1),
p_snapshot: value.snapshot,
p_candidate_result: value.candidateResult ?? {},
p_private_state: privateState(value),
});
if (result.error) {
const current = await loadCase(value.userId, value.id);
if (isStaleRpc(result.error) && current?.processedActionIds?.includes(receipt)) {
return loadedDynamic(value.userId, value.id);
}
if (isStaleRpc(result.error)) {
throw new StaleJourneyTurnError(
value.id,
expectedVersion,
current?.turnVersion ?? 0,
);
}
throw new BirthTimeJourneyStoreError("update_case");
}
rpcVersionSchema.parse(result.data);
return loadedDynamic(value.userId, value.id);
},
async upgradeLegacyActiveCase(
value: StoredRectificationCase,
): 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", {
p_user_id: value.userId,
p_case_id: value.id,
p_expected_version: value.turnVersion ?? 0,
p_public_turn_state: upgraded.dynamicTurnState,
p_private_state: privateState(upgraded),
});
if (result.error) {
if (isStaleRpc(result.error)) {
const current = await loadCase(value.userId, value.id);
throw new StaleJourneyTurnError(
value.id,
value.turnVersion ?? 0,
current?.turnVersion ?? 0,
);
}
throw new BirthTimeJourneyStoreError("update_case");
}
rpcVersionSchema.parse(result.data);
return loadedDynamic(value.userId, value.id);
},
};
}
@@ -0,0 +1,234 @@
import { z } from "zod";
import {
dynamicControlStateSchema,
persistedDynamicChoiceQuestionSchema,
serverChoiceEvidenceSchema,
storedChoiceAnswerSchema,
toPublicDynamicChoiceQuestion,
} from "./birth-time-dynamic-choice-internal.ts";
import type {
DynamicControlState,
PersistedDynamicChoiceQuestion,
ServerChoiceEvidence,
StoredChoiceAnswer,
} from "./birth-time-dynamic-choice-internal.ts";
import { dynamicJourneyTurnStateSchema } from "./birth-time-journey-turn-protocol.ts";
import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts";
import { timeRangeSchema } from "./birth-time-dynamic-choice.ts";
import type {
DynamicStoredRectificationCase,
LegacyStoredRectificationCase,
StoredRectificationCase,
} from "./birth-time-journey-service.ts";
import type { JourneySnapshot } from "./birth-time-journey.ts";
const agentContextSchema = z.array(
z.string().min(1).max(240).refine((value) => value.trim().length > 0),
).max(10).readonly();
export const dynamicPrivateStateSchema = z.object({
candidateModel: z.record(z.unknown()).nullable(),
currentChoiceQuestion: persistedDynamicChoiceQuestionSchema.nullable(),
choiceAnswers: z.array(storedChoiceAnswerSchema).max(50).readonly(),
choiceEvidence: z.array(serverChoiceEvidenceSchema).max(10).readonly(),
dynamicControl: dynamicControlStateSchema,
agentContext: agentContextSchema,
}).strict().readonly();
const dynamicPrivateRowSchema = z.object({
case_id: z.string().uuid(),
user_id: z.string().uuid(),
candidate_model: z.record(z.unknown()).nullable(),
current_choice_question: persistedDynamicChoiceQuestionSchema.nullable(),
choice_answers: z.array(storedChoiceAnswerSchema).max(50).readonly(),
choice_evidence: z.array(serverChoiceEvidenceSchema).max(10).readonly(),
dynamic_control: dynamicControlStateSchema,
agent_context: agentContextSchema,
}).strict().readonly();
export type DynamicPrivateJourneyState = {
readonly candidateModel: Readonly<Record<string, unknown>> | null;
readonly currentChoiceQuestion: PersistedDynamicChoiceQuestion | null;
readonly choiceAnswers: readonly StoredChoiceAnswer[];
readonly choiceEvidence: readonly ServerChoiceEvidence[];
readonly dynamicControl: DynamicControlState;
readonly agentContext: readonly string[];
};
export class BirthTimeDynamicStateMissingError extends Error {
readonly name = "BirthTimeDynamicStateMissingError";
readonly caseId: string;
constructor(caseId: string) {
super(`Dynamic birth-time state for ${caseId} is missing`);
this.caseId = caseId;
}
}
export class BirthTimeDynamicStateInvalidError extends Error {
readonly name = "BirthTimeDynamicStateInvalidError";
readonly caseId: string;
constructor(caseId: string) {
super(`Dynamic birth-time state for ${caseId} is invalid`);
this.caseId = caseId;
}
}
export function parseDynamicPrivateRow(
value: unknown,
userId: string,
caseId: string,
): DynamicPrivateJourneyState {
if (value === null) throw new BirthTimeDynamicStateMissingError(caseId);
const parsed = dynamicPrivateRowSchema.safeParse(value);
if (!parsed.success || parsed.data.case_id !== caseId || parsed.data.user_id !== userId) {
throw new BirthTimeDynamicStateInvalidError(caseId);
}
return dynamicPrivateStateSchema.parse({
candidateModel: parsed.data.candidate_model,
currentChoiceQuestion: parsed.data.current_choice_question,
choiceAnswers: parsed.data.choice_answers,
choiceEvidence: parsed.data.choice_evidence,
dynamicControl: parsed.data.dynamic_control,
agentContext: parsed.data.agent_context,
});
}
export function dynamicTurnMatchesPrivateQuestion(
turn: DynamicJourneyTurnState,
state: DynamicPrivateJourneyState,
): boolean {
const action = turn.nextAction;
if (action.kind !== "ask_dynamic_choice") return true;
if (state.currentChoiceQuestion === null) return false;
const projected = toPublicDynamicChoiceQuestion(state.currentChoiceQuestion);
return projected.questionId === action.question.questionId
&& projected.prompt === action.question.prompt
&& projected.options.length === action.question.options.length
&& projected.options.every((option, index) => {
const persisted = action.question.options[index];
return persisted !== undefined
&& option.optionId === persisted.optionId
&& option.label === persisted.label
&& option.kind === persisted.kind;
});
}
function rangeFrom(snapshot: JourneySnapshot) {
const { startTime, endTime } = snapshot.reportedRange;
return timeRangeSchema.parse(startTime === null && endTime === null
? { startTime: "00:00", endTime: "23:59" }
: { startTime, endTime });
}
export function createInitialDynamicState(
snapshot: JourneySnapshot,
asOfDate: string,
): {
readonly turn: DynamicJourneyTurnState;
readonly privateState: DynamicPrivateJourneyState;
} {
const currentRange = rangeFrom(snapshot);
const dynamicControl = dynamicControlStateSchema.parse({
asOfDate,
answeredCount: 0,
effectiveAnswerCount: 0,
plateauCount: 0,
questionFingerprints: [],
partitionFingerprints: [],
dismissedOpportunityIds: [],
recentRanges: [currentRange],
pausedAction: null,
});
const ready = snapshot.state === "ready";
return {
turn: dynamicJourneyTurnStateSchema.parse({
journeyProtocol: "dynamic-choice-v2",
turnVersion: 0,
nextAction: ready
? { kind: "ready", activeTime: snapshot.activeTime }
: { kind: "generate_dynamic_question" },
progress: {
phase: ready ? "ready" : "question",
answeredCount: 0,
effectiveAnswerCount: 0,
currentRange,
previousRange: null,
plateauCount: 0,
},
permissions: { canConfirmCandidate: false },
}),
privateState: {
candidateModel: null,
currentChoiceQuestion: null,
choiceAnswers: [],
choiceEvidence: [],
dynamicControl,
agentContext: [],
},
};
}
const terminalLegacyActions = new Set([
"present_low_result",
"present_medium_result",
"candidate_saved",
"request_candidate_confirmation",
"ready",
]);
export function isTerminalLegacyCase(value: StoredRectificationCase): boolean {
return value.snapshot.state === "candidate"
|| value.snapshot.state === "confirming"
|| value.snapshot.state === "ready"
|| terminalLegacyActions.has(value.turnState?.nextAction.kind ?? "");
}
export function prepareLegacyDynamicUpgrade(
value: LegacyStoredRectificationCase,
asOfDate: string,
): DynamicStoredRectificationCase {
const currentRange = rangeFrom(value.snapshot);
const confirmedCount = value.lifeEvents?.length ?? 0;
const effectiveAnswerCount = Math.min(confirmedCount, 10);
const answeredCount = Math.min(confirmedCount, 50);
const dynamicControl = dynamicControlStateSchema.parse({
asOfDate,
answeredCount,
effectiveAnswerCount,
plateauCount: 0,
questionFingerprints: [],
partitionFingerprints: [],
dismissedOpportunityIds: [],
recentRanges: [currentRange],
pausedAction: null,
});
const dynamicTurnState = dynamicJourneyTurnStateSchema.parse({
journeyProtocol: "dynamic-choice-v2",
turnVersion: value.turnVersion ?? 0,
nextAction: { kind: "generate_dynamic_question" },
progress: {
phase: "question",
answeredCount,
effectiveAnswerCount,
currentRange,
previousRange: null,
plateauCount: 0,
},
permissions: { canConfirmCandidate: false },
});
return {
...value,
journeyProtocol: "dynamic-choice-v2",
turnVersion: value.turnVersion ?? 0,
processedActionIds: value.processedActionIds ?? [],
turnState: null,
evidenceDraft: null,
dynamicTurnState,
candidateModel: null,
currentChoiceQuestion: null,
choiceAnswers: [],
choiceEvidence: [],
dynamicControl,
agentContext: [],
};
}
@@ -0,0 +1,13 @@
export class RectificationCaseNotFoundError extends Error {
readonly name = "RectificationCaseNotFoundError";
readonly caseId: string;
constructor(caseId: string) {
super(`Rectification case ${caseId} was not found`);
this.caseId = caseId;
}
}
export class RectificationQuestionsUnavailableError extends Error {
readonly name = "RectificationQuestionsUnavailableError";
}
@@ -7,7 +7,7 @@ import {
projectJourneyTurn,
type JourneyTurnState,
} from "./birth-time-journey-turn.ts";
import type { StoredRectificationCase } from "./birth-time-journey-service.ts";
import type { LegacyStoredRectificationCase as StoredRectificationCase } from "./birth-time-journey-service.ts";
type CompleteScoreTransitionInput = {
readonly stored: StoredRectificationCase;
+24 -15
View File
@@ -8,7 +8,6 @@ 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 type { JourneyTurnState } from "./birth-time-journey-turn.ts";
import type { PersistedJourneyTurn } from "./birth-time-journey-turn-persistence.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";
@@ -21,6 +20,16 @@ import type {
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";
export { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError };
export type RectificationAnswer = "A" | "B" | "C" | "D";
@@ -113,7 +122,7 @@ export type PersistedJourneyAssessment = {
readonly candidateScan: RectificationQuestionnaire | null;
};
export type StoredRectificationCase = {
type StoredRectificationCaseBase = {
readonly id: string;
readonly userId: string;
readonly snapshot: JourneySnapshot;
@@ -128,13 +137,25 @@ export type StoredRectificationCase = {
};
readonly lifeEvents?: readonly LifeEvent[];
readonly candidateResult?: CandidateResult | null;
} & Partial<PersistedJourneyTurn>;
};
export type LegacyStoredRectificationCase = StoredRectificationCaseBase
& LegacyStoredFields;
export type DynamicStoredRectificationCase = StoredRectificationCaseBase
& DynamicStoredFields;
export type StoredRectificationCase =
| LegacyStoredRectificationCase
| DynamicStoredRectificationCase;
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>;
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>;
claimScoringJob(identity: ScoringJobIdentity): Promise<ScoringJobClaim>;
completeScoringJob(value: StoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string): Promise<StoredRectificationCase>;
@@ -167,18 +188,6 @@ export type LegacyJourneyResponse = JourneyResponseBase & { readonly turnVersion
export type JourneyResponse = LegacyJourneyResponse | VersionedJourneyResponse;
export class RectificationCaseNotFoundError extends Error {
readonly name = "RectificationCaseNotFoundError";
readonly caseId: string;
constructor(caseId: string) {
super(`Rectification case ${caseId} was not found`);
this.caseId = caseId;
}
}
export class RectificationQuestionsUnavailableError extends Error { readonly name = "RectificationQuestionsUnavailableError"; }
export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
const evidenceActions = createBirthTimeEvidenceActions(ports);
const turnActions = createJourneyTurnActions(ports);
@@ -0,0 +1,29 @@
export type JourneyStoreOperation =
| "insert_case"
| "update_profile"
| "load_case"
| "update_case";
export class BirthTimeJourneyStoreError extends Error {
readonly name = "BirthTimeJourneyStoreError";
readonly operation: JourneyStoreOperation;
constructor(operation: JourneyStoreOperation) {
super(`Birth-time journey persistence failed during ${operation}`);
this.operation = operation;
}
}
export class StaleJourneyTurnError extends Error {
readonly name = "StaleJourneyTurnError";
readonly caseId: string;
readonly expectedVersion: number;
readonly currentVersion: number;
constructor(caseId: string, expectedVersion: number, currentVersion: number) {
super(`Journey turn ${caseId} is stale at version ${expectedVersion}`);
this.caseId = caseId;
this.expectedVersion = expectedVersion;
this.currentVersion = currentVersion;
}
}
+22 -69
View File
@@ -1,102 +1,55 @@
import "server-only";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import type {
BirthTimeJourneyStore,
PersistedJourneyAssessment,
} from "./birth-time-journey-service.ts";
import { projectJourneyTurn } from "./birth-time-journey-turn.ts";
import {
BirthTimeJourneyStoreError,
caseStatus,
createJourneyLoadClient,
createJourneyTurnPersistence,
loadStoredRectificationCase,
profileStatus,
} from "./birth-time-journey-turn-persistence.ts";
import { createSupabaseScoringJobStore } from "./birth-time-scoring-job-store.ts";
import { createSupabaseGuidedCandidateStore } from "./birth-time-guided-candidate-store.ts";
import {
createDynamicTurnPersistence,
type DynamicRpcClient,
} from "./birth-time-journey-dynamic-persistence.ts";
import { saveDynamicAssessment } from "./birth-time-journey-dynamic-case.ts";
export { BirthTimeJourneyStoreError } from "./birth-time-journey-turn-persistence.ts";
function assessmentValues(value: PersistedJourneyAssessment) {
const assessment = value.assessment;
return {
reportedTime: "reportedTime" in assessment ? assessment.reportedTime : null,
period: assessment.source === "period_only" ? assessment.period : null,
clue: assessment.source === "unknown" ? assessment.clue : null,
before: "uncertaintyBeforeMinutes" in assessment
? assessment.uncertaintyBeforeMinutes
: null,
after: "uncertaintyAfterMinutes" in assessment
? assessment.uncertaintyAfterMinutes
: null,
};
}
export function createSupabaseBirthTimeJourneyStore(
supabase: SupabaseClient,
now: () => Date = () => new Date(),
): BirthTimeJourneyStore {
const loadCase = (userId: string, caseId: string) => loadStoredRectificationCase(supabase, userId, caseId);
const loadClient = createJourneyLoadClient(supabase);
const loadCase = (userId: string, caseId: string) => loadStoredRectificationCase(loadClient, userId, caseId);
const turns = createJourneyTurnPersistence(supabase, loadCase);
const dynamicRpc: DynamicRpcClient = {
async rpc(name, args) {
const { data, error } = await supabase.rpc(name, args);
return { data, error: error ? { message: error.message } : null };
},
};
const dynamicTurns = createDynamicTurnPersistence(
dynamicRpc,
loadCase,
() => now().toISOString().slice(0, 10),
);
const scoringJobs = createSupabaseScoringJobStore(supabase, loadCase);
const guidedCandidates = createSupabaseGuidedCandidateStore(supabase, loadCase);
return {
async saveAssessment(value) {
const details = assessmentValues(value);
const { data, error } = await supabase
.from("birth_time_rectification_cases")
.insert({
user_id: value.userId,
status: caseStatus(value.snapshot),
reported_date: value.assessment.date,
reported_time: details.reportedTime,
reported_period: details.period,
source: value.assessment.source,
uncertainty_before_minutes: details.before,
uncertainty_after_minutes: details.after,
questionnaire: value.questionnaire?.raw ?? {},
journey_snapshot: value.snapshot,
candidate_scan: value.candidateScan?.raw ?? {},
turn_state: projectJourneyTurn({
turnVersion: 0,
snapshot: value.snapshot,
questionnaire: value.questionnaire,
candidateResult: null,
lifeEvents: [],
}),
candidate_start: value.snapshot.reportedRange.startTime,
candidate_end: value.snapshot.reportedRange.endTime,
confirmed_time: value.snapshot.activeTime,
confirmed_at: value.snapshot.state === "ready" ? new Date().toISOString() : null,
})
.select("id")
.single();
if (error) throw new BirthTimeJourneyStoreError("insert_case");
const caseId = z.string().uuid().parse(data.id);
const { error: profileError } = await supabase
.from("profiles")
.update({
reported_birth_time: details.reportedTime,
active_birth_time: value.snapshot.activeTime,
birth_time: value.snapshot.activeTime,
birth_time_source: value.assessment.source,
birth_time_period: details.period,
birth_time_clue: details.clue,
uncertainty_before_minutes: details.before,
uncertainty_after_minutes: details.after,
birth_time_status: profileStatus(value.snapshot),
rectification_confidence: null,
rectification_case_id: caseId,
})
.eq("id", value.userId);
if (profileError) throw new BirthTimeJourneyStoreError("update_profile");
return caseId;
return saveDynamicAssessment(dynamicRpc, value, now());
},
loadCase,
saveTurn: turns.saveTurn,
...dynamicTurns,
...scoringJobs,
...guidedCandidates,
@@ -0,0 +1,46 @@
import type {
DynamicControlState,
PersistedDynamicChoiceQuestion,
ServerChoiceEvidence,
StoredChoiceAnswer,
} from "./birth-time-dynamic-choice-internal.ts";
import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts";
import type { EvidenceDraft, JourneyTurnState } from "./birth-time-journey-turn.ts";
import type { EvidenceDomain } from "./birth-time-question-planner.ts";
type LegacyProgress = {
readonly adaptiveRound: number;
readonly askedDomains: readonly EvidenceDomain[];
};
export type LegacyStoredFields = {
readonly journeyProtocol?: "legacy-guided-v1";
readonly turnVersion?: number;
readonly turnState?: JourneyTurnState | null;
readonly evidenceDraft?: EvidenceDraft | null;
readonly processedActionIds?: readonly string[];
readonly persistedProgress?: LegacyProgress;
readonly dynamicTurnState?: never;
readonly candidateModel?: never;
readonly currentChoiceQuestion?: never;
readonly choiceAnswers?: never;
readonly choiceEvidence?: never;
readonly dynamicControl?: never;
readonly agentContext?: never;
};
export type DynamicStoredFields = {
readonly journeyProtocol: "dynamic-choice-v2";
readonly turnVersion: number;
readonly turnState?: null;
readonly dynamicTurnState: DynamicJourneyTurnState;
readonly evidenceDraft?: null;
readonly processedActionIds: readonly string[];
readonly persistedProgress?: LegacyProgress;
readonly candidateModel: Readonly<Record<string, unknown>> | null;
readonly currentChoiceQuestion: PersistedDynamicChoiceQuestion | null;
readonly choiceAnswers: readonly StoredChoiceAnswer[];
readonly choiceEvidence: readonly ServerChoiceEvidence[];
readonly dynamicControl: DynamicControlState;
readonly agentContext: readonly string[];
};
@@ -17,7 +17,7 @@ import {
questionJourneySnapshot,
terminalJourneySnapshot,
} from "./birth-time-journey-response.ts";
import type { StoredRectificationCase } from "./birth-time-journey-service.ts";
import type { LegacyStoredRectificationCase as StoredRectificationCase } from "./birth-time-journey-service.ts";
type PlannedTurnInput = {
readonly stored: StoredRectificationCase;
@@ -1,58 +1,19 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import {
parseRectificationQuestionnaire,
parseRectificationScoring,
} from "./birth-time-journey-adapters.ts";
import {
candidateResultSchema,
journeySnapshotSchema,
lifeEventSchema,
} from "./birth-time-journey.ts";
import type { JourneySnapshot } from "./birth-time-journey.ts";
import type { StoredRectificationCase } from "./birth-time-journey-service.ts";
import { evidenceDomains } from "./birth-time-question-planner.ts";
import {
evidenceDraftSchema,
journeyTurnStateSchema,
} from "./birth-time-journey-turn.ts";
import type { EvidenceDomain } from "./birth-time-question-planner.ts";
import type { EvidenceDraft, JourneyTurnState } from "./birth-time-journey-turn.ts";
import {
BirthTimeJourneyStoreError,
StaleJourneyTurnError,
} from "./birth-time-journey-store-errors.ts";
const answerSchema = z.enum(["A", "B", "C", "D"]);
const actionIdSchema = z.string().uuid();
const storedCaseSchema = z.object({
id: z.string().uuid(),
user_id: z.string().uuid(),
journey_snapshot: journeySnapshotSchema,
questionnaire: z.record(z.unknown()),
answers: z.record(answerSchema),
scoring_result: z.record(z.unknown()),
reported_date: z.string(),
life_events: z.array(lifeEventSchema).default([]),
candidate_result: z.record(z.unknown()).default({}),
turn_version: z.number().int().nonnegative().default(0),
turn_state: z.unknown().default({}),
evidence_draft: z.unknown().nullable().default(null),
processed_action_ids: z.array(z.string().uuid()).default([]),
adaptive_round: z.number().int().min(0).max(3).default(0),
asked_domains: z.array(z.enum(evidenceDomains)).default([]),
});
const eventLocationSchema = z.object({
latitude: z.number(),
longitude: z.number(),
timezone_offset: z.number(),
});
export class BirthTimeJourneyStoreError extends Error {
readonly name = "BirthTimeJourneyStoreError";
readonly operation: "insert_case" | "update_profile" | "load_case" | "update_case";
constructor(operation: "insert_case" | "update_profile" | "load_case" | "update_case") {
super(`Birth-time journey persistence failed during ${operation}`);
this.operation = operation;
}
}
export { createJourneyLoadClient, loadStoredRectificationCase } from "./birth-time-journey-case-loader.ts";
export { BirthTimeJourneyStoreError, StaleJourneyTurnError } from "./birth-time-journey-store-errors.ts";
export type PersistedJourneyProgress = {
readonly adaptiveRound: number;
@@ -67,24 +28,6 @@ export type PersistedJourneyTurn = {
readonly persistedProgress: PersistedJourneyProgress;
};
export class StaleJourneyTurnError extends Error {
readonly name = "StaleJourneyTurnError";
readonly caseId: string;
readonly expectedVersion: number;
readonly currentVersion: number;
constructor(
caseId: string,
expectedVersion: number,
currentVersion: number,
) {
super(`Journey turn ${caseId} is stale at version ${expectedVersion}`);
this.caseId = caseId;
this.expectedVersion = expectedVersion;
this.currentVersion = currentVersion;
}
}
export function caseStatus(snapshot: JourneySnapshot) {
switch (snapshot.state) {
case "ready":
@@ -108,89 +51,10 @@ export function profileStatus(snapshot: JourneySnapshot) {
return caseStatus(snapshot);
}
function isExactEmptyLegacyTurn(value: unknown): boolean {
return typeof value === "object"
&& value !== null
&& !Array.isArray(value)
&& Object.keys(value).length === 0;
}
function parsePersistedTurnState(value: unknown): JourneyTurnState | null {
if (isExactEmptyLegacyTurn(value)) return null;
const parsed = journeyTurnStateSchema.safeParse(value);
if (parsed.success) return parsed.data;
throw new BirthTimeJourneyStoreError("load_case");
}
function parsePersistedEvidenceDraft(value: unknown): EvidenceDraft | null {
if (value === null) return null;
const parsed = evidenceDraftSchema.safeParse(value);
if (parsed.success) return parsed.data;
throw new BirthTimeJourneyStoreError("load_case");
}
function canonicalAskedDomains(input: readonly EvidenceDomain[]): readonly EvidenceDomain[] {
return evidenceDomains.filter((domain) => input.includes(domain));
}
export async function loadStoredRectificationCase(
supabase: SupabaseClient,
userId: string,
caseId: string,
): Promise<StoredRectificationCase | null> {
const { data, error } = await supabase
.from("birth_time_rectification_cases")
.select("id,user_id,journey_snapshot,questionnaire,answers,scoring_result,reported_date,life_events,candidate_result,turn_version,turn_state,evidence_draft,processed_action_ids,adaptive_round,asked_domains")
.eq("id", caseId)
.eq("user_id", userId)
.maybeSingle();
if (error) throw new BirthTimeJourneyStoreError("load_case");
if (!data) return null;
const parsed = storedCaseSchema.parse(data);
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("latitude,longitude,timezone_offset")
.eq("id", userId)
.maybeSingle();
if (profileError || !profile) throw new BirthTimeJourneyStoreError("load_case");
const location = eventLocationSchema.parse(profile);
const scoring = Object.keys(parsed.scoring_result).length > 0
? parseRectificationScoring(parsed.scoring_result)
: undefined;
const questionnaire = Object.keys(parsed.questionnaire).length > 0
? parseRectificationQuestionnaire(parsed.questionnaire)
: null;
const candidateResult = Object.keys(parsed.candidate_result).length > 0
? candidateResultSchema.parse(parsed.candidate_result)
: null;
const turnState = parsePersistedTurnState(parsed.turn_state);
const evidenceDraft = parsePersistedEvidenceDraft(parsed.evidence_draft);
return {
id: parsed.id,
userId: parsed.user_id,
snapshot: parsed.journey_snapshot,
questionnaire,
answers: parsed.answers,
eventContext: {
birthDate: parsed.reported_date,
lat: location.latitude,
lon: location.longitude,
tz: location.timezone_offset,
},
lifeEvents: parsed.life_events,
candidateResult,
turnVersion: parsed.turn_version,
turnState,
evidenceDraft,
processedActionIds: parsed.processed_action_ids,
persistedProgress: {
adaptiveRound: parsed.adaptive_round,
askedDomains: parsed.asked_domains,
},
...(scoring ? { scoring } : {}),
} satisfies StoredRectificationCase;
}
export function createJourneyTurnPersistence(
supabase: SupabaseClient,
loadCase: (userId: string, caseId: string) => Promise<StoredRectificationCase | null>,
@@ -1,4 +1,5 @@
import { completeScoreTransition } from "./birth-time-journey-score-transition.ts";
import { assertNotDynamicJourneyMutation } from "./birth-time-evidence-service.ts";
import { currentJourneyTurn, storedJourneyResponse } from "./birth-time-journey-response.ts";
import {
birthTimeScoringAlgorithmVersion,
@@ -7,7 +8,7 @@ import {
} from "./birth-time-scoring-job.ts";
import type {
BirthTimeJourneyPorts,
StoredRectificationCase,
LegacyStoredRectificationCase as StoredRectificationCase,
} from "./birth-time-journey-service.ts";
import type { CandidateResult } from "./birth-time-evidence.ts";
@@ -60,6 +61,7 @@ export function createBirthTimeScoringService(ports: BirthTimeJourneyPorts) {
async pollScoringJob(userId: string, caseId: string, jobId: string) {
const stored = await ports.store.loadCase(userId, caseId);
if (!stored) throw new BirthTimeScoringJobError("unavailable");
assertNotDynamicJourneyMutation(stored);
const fingerprint = evidenceFingerprint(stored.lifeEvents ?? []);
const claim = await ports.store.claimScoringJob({
userId,
@@ -0,0 +1,254 @@
alter table public.birth_time_rectification_cases
add column if not exists journey_protocol text not null default 'legacy-guided-v1';
alter table public.birth_time_rectification_cases
drop constraint if exists birth_time_rectification_cases_journey_protocol_check,
add constraint birth_time_rectification_cases_journey_protocol_check
check (journey_protocol in ('legacy-guided-v1', 'dynamic-choice-v2'));
create or replace function public.birth_time_dynamic_agent_context_valid(value jsonb)
returns boolean
language sql
immutable
strict
set search_path = ''
as $$
select pg_catalog.jsonb_typeof(value) = 'array'
and not pg_catalog.jsonb_path_exists(value, '$[*] ? (@.type() != "string")')
and not exists (
select 1
from pg_catalog.jsonb_array_elements_text(value) as item(note)
where pg_catalog.length(note) > 240
);
$$;
create table if not exists public.birth_time_rectification_dynamic_state (
case_id uuid primary key references public.birth_time_rectification_cases(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
candidate_model jsonb,
current_choice_question jsonb,
choice_answers jsonb not null default '[]'::jsonb,
choice_evidence jsonb not null default '[]'::jsonb,
dynamic_control jsonb not null,
agent_context jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
check (candidate_model is null or jsonb_typeof(candidate_model) = 'object'),
check (current_choice_question is null or jsonb_typeof(current_choice_question) = 'object'),
check (jsonb_typeof(choice_answers) = 'array' and jsonb_array_length(choice_answers) <= 50),
check (jsonb_typeof(choice_evidence) = 'array' and jsonb_array_length(choice_evidence) <= 10),
check (jsonb_typeof(dynamic_control) = 'object'),
check (jsonb_typeof(agent_context) = 'array' and jsonb_array_length(agent_context) <= 10),
check (public.birth_time_dynamic_agent_context_valid(agent_context))
);
alter table public.birth_time_rectification_dynamic_state enable row level security;
revoke all on table public.birth_time_rectification_dynamic_state from anon, authenticated;
revoke all on table public.birth_time_rectification_dynamic_state from service_role;
grant all on table public.birth_time_rectification_dynamic_state to service_role;
revoke all on function public.birth_time_dynamic_agent_context_valid(jsonb) from public, anon, authenticated;
grant execute on function public.birth_time_dynamic_agent_context_valid(jsonb) to service_role;
create or replace function public.persist_birth_time_dynamic_private_state(
p_case_id uuid,
p_user_id uuid,
p_private_state jsonb
)
returns void
language plpgsql
set search_path = ''
as $$
begin
if pg_catalog.jsonb_typeof(p_private_state) is distinct from 'object' then
raise exception 'birth_time_dynamic_private_state_invalid';
end if;
insert into public.birth_time_rectification_dynamic_state (
case_id, user_id, candidate_model, current_choice_question,
choice_answers, choice_evidence, dynamic_control, agent_context, updated_at
) values (
p_case_id, p_user_id,
nullif(p_private_state -> 'candidateModel', 'null'::jsonb),
nullif(p_private_state -> 'currentChoiceQuestion', 'null'::jsonb),
coalesce(p_private_state -> 'choiceAnswers', '[]'::jsonb),
coalesce(p_private_state -> 'choiceEvidence', '[]'::jsonb),
p_private_state -> 'dynamicControl',
coalesce(p_private_state -> 'agentContext', '[]'::jsonb), now()
) on conflict (case_id) do update set
user_id = excluded.user_id,
candidate_model = excluded.candidate_model,
current_choice_question = excluded.current_choice_question,
choice_answers = excluded.choice_answers,
choice_evidence = excluded.choice_evidence,
dynamic_control = excluded.dynamic_control,
agent_context = excluded.agent_context,
updated_at = excluded.updated_at;
end;
$$;
create or replace function public.create_birth_time_dynamic_case(
p_user_id uuid,
p_public_case jsonb,
p_private_state jsonb,
p_profile jsonb
)
returns uuid
language plpgsql
security definer
set search_path = ''
as $$
declare
v_case_id uuid;
begin
if jsonb_typeof(p_public_case) is distinct from 'object'
or jsonb_typeof(p_private_state) is distinct from 'object'
or jsonb_typeof(p_profile) is distinct from 'object'
or p_public_case ->> 'journeyProtocol' is distinct from 'dynamic-choice-v2'
or p_public_case #>> '{turnState,journeyProtocol}' is distinct from 'dynamic-choice-v2'
or (p_public_case #>> '{turnState,turnVersion}')::bigint is distinct from 0
or jsonb_path_exists(p_public_case, '$.**.partitionId')
or jsonb_path_exists(p_public_case, '$.**.candidateScores')
or jsonb_path_exists(p_public_case, '$.**.agentContext') then
raise exception 'birth_time_dynamic_case_invalid';
end if;
insert into public.birth_time_rectification_cases (
user_id, journey_protocol, status, reported_date, reported_time,
reported_period, source, uncertainty_before_minutes,
uncertainty_after_minutes, questionnaire, journey_snapshot,
candidate_scan, turn_state, candidate_start, candidate_end,
confirmed_time, confirmed_at
) values (
p_user_id, 'dynamic-choice-v2', p_public_case ->> 'status',
(p_public_case ->> 'reportedDate')::date,
nullif(p_public_case ->> 'reportedTime', '')::time,
nullif(p_public_case ->> 'reportedPeriod', ''),
p_public_case ->> 'source',
nullif(p_public_case ->> 'uncertaintyBeforeMinutes', '')::integer,
nullif(p_public_case ->> 'uncertaintyAfterMinutes', '')::integer,
coalesce(p_public_case -> 'questionnaire', '{}'::jsonb),
p_public_case -> 'journeySnapshot',
coalesce(p_public_case -> 'candidateScan', '{}'::jsonb),
p_public_case -> 'turnState',
nullif(p_public_case ->> 'candidateStart', '')::time,
nullif(p_public_case ->> 'candidateEnd', '')::time,
nullif(p_public_case ->> 'confirmedTime', '')::time,
nullif(p_public_case ->> 'confirmedAt', '')::timestamptz
) returning id into v_case_id;
perform public.persist_birth_time_dynamic_private_state(
v_case_id, p_user_id, p_private_state
);
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', ''),
uncertainty_before_minutes = nullif(p_profile ->> 'uncertaintyBeforeMinutes', '')::integer,
uncertainty_after_minutes = nullif(p_profile ->> 'uncertaintyAfterMinutes', '')::integer,
birth_time_status = p_profile ->> 'birthTimeStatus',
rectification_confidence = null,
rectification_case_id = v_case_id
where id = p_user_id;
if not found then
raise exception 'birth_time_dynamic_profile_not_found';
end if;
return v_case_id;
end;
$$;
create or replace function public.save_birth_time_dynamic_turn(
p_user_id uuid,
p_case_id uuid,
p_expected_version bigint,
p_action_id uuid,
p_public_turn_state jsonb,
p_snapshot jsonb,
p_candidate_result jsonb,
p_private_state jsonb
)
returns bigint
language plpgsql
security definer
set search_path = ''
as $$
declare
v_case public.birth_time_rectification_cases%rowtype;
v_new_version bigint;
begin
select c.* into v_case
from public.birth_time_rectification_cases c
where c.id = p_case_id and c.user_id = p_user_id
for update;
if not found or v_case.journey_protocol is distinct from 'dynamic-choice-v2' then
raise exception 'birth_time_dynamic_case_not_found';
end if;
if p_action_id = any(v_case.processed_action_ids) then
return v_case.turn_version;
end if;
if v_case.turn_version is distinct from p_expected_version then
raise exception 'stale_birth_time_dynamic_turn';
end if;
if p_public_turn_state ->> 'journeyProtocol' is distinct from 'dynamic-choice-v2'
or (p_public_turn_state ->> 'turnVersion')::bigint is distinct from p_expected_version + 1
or jsonb_typeof(p_private_state) is distinct from 'object'
or jsonb_path_exists(p_public_turn_state, '$.**.partitionId')
or jsonb_path_exists(p_public_turn_state, '$.**.candidateScores')
or jsonb_path_exists(p_public_turn_state, '$.**.agentContext') then
raise exception 'birth_time_dynamic_turn_invalid';
end if;
update public.birth_time_rectification_cases
set status = case p_snapshot ->> 'state'
when 'ready' then 'confirmed'
when 'confirming' then 'confirming'
when 'candidate' then 'candidate'
else 'rectifying'
end,
journey_snapshot = p_snapshot,
candidate_result = coalesce(p_candidate_result, '{}'::jsonb),
event_scoring_version = p_candidate_result ->> 'algorithmVersion',
candidate_result_id = case
when p_candidate_result ? 'resultId' then (p_candidate_result ->> 'resultId')::uuid
else null
end,
candidate_start = case
when p_candidate_result #>> '{winningSegment,startTime}' is null then null
else (p_candidate_result #>> '{winningSegment,startTime}')::time
end,
candidate_end = case
when p_candidate_result #>> '{winningSegment,endTime}' is null then null
else (p_candidate_result #>> '{winningSegment,endTime}')::time
end,
turn_version = p_expected_version + 1,
turn_state = p_public_turn_state,
evidence_draft = null,
processed_action_ids = case
when cardinality(processed_action_ids) >= 100
then processed_action_ids[2:100] || p_action_id
else processed_action_ids || p_action_id
end,
updated_at = now()
where id = p_case_id and user_id = p_user_id and turn_version = p_expected_version
returning turn_version into v_new_version;
if v_new_version is null then
raise exception 'stale_birth_time_dynamic_turn';
end if;
perform public.persist_birth_time_dynamic_private_state(
p_case_id, p_user_id, p_private_state
);
return v_new_version;
end;
$$;
revoke all on function public.create_birth_time_dynamic_case(uuid, jsonb, jsonb, jsonb) from public, anon, authenticated;
revoke all on function public.persist_birth_time_dynamic_private_state(uuid, uuid, jsonb) from public, anon, authenticated, service_role;
revoke all on function public.save_birth_time_dynamic_turn(uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb) from public, anon, authenticated;
grant execute on function public.create_birth_time_dynamic_case(uuid, jsonb, jsonb, jsonb) to service_role;
grant execute on function public.save_birth_time_dynamic_turn(uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb) to service_role;
@@ -0,0 +1,165 @@
create or replace function public.upgrade_birth_time_legacy_case(
p_user_id uuid, p_case_id uuid, p_expected_version bigint,
p_public_turn_state jsonb, p_private_state jsonb
)
returns bigint language plpgsql security definer set search_path = '' as $$
declare
v_case public.birth_time_rectification_cases%rowtype;
v_action text;
begin
select c.* into v_case from public.birth_time_rectification_cases c
where c.id = p_case_id and c.user_id = p_user_id for update;
if not found then raise exception 'birth_time_legacy_case_not_found'; end if;
if v_case.journey_protocol = 'dynamic-choice-v2' then return v_case.turn_version; end if;
v_action = v_case.turn_state #>> '{nextAction,kind}';
if v_case.journey_snapshot ->> 'state' in ('candidate', 'confirming', 'ready')
or v_action in ('present_low_result', 'present_medium_result', 'candidate_saved',
'request_candidate_confirmation', 'ready') then
raise exception 'birth_time_legacy_case_terminal';
end if;
if v_case.turn_version is distinct from p_expected_version
or p_public_turn_state ->> 'journeyProtocol' is distinct from 'dynamic-choice-v2'
or (p_public_turn_state ->> 'turnVersion')::bigint is distinct from p_expected_version
or jsonb_typeof(p_private_state) is distinct from 'object' then
raise exception 'stale_birth_time_legacy_upgrade';
end if;
update public.birth_time_rectification_cases
set journey_protocol = 'dynamic-choice-v2', turn_state = p_public_turn_state,
evidence_draft = null
where id = p_case_id and user_id = p_user_id
and journey_protocol = 'legacy-guided-v1' and turn_version = p_expected_version;
if not found then raise exception 'stale_birth_time_legacy_upgrade'; end if;
perform public.persist_birth_time_dynamic_private_state(p_case_id, p_user_id, p_private_state);
return p_expected_version;
end;
$$;
create or replace function public.complete_birth_time_dynamic_scoring_job(
p_user_id uuid, p_case_id uuid, p_job_id uuid, p_expected_version bigint,
p_evidence_fingerprint text, p_algorithm_version text,
p_public_turn_state jsonb, p_snapshot jsonb, p_candidate_result jsonb,
p_private_state jsonb
)
returns bigint language plpgsql security definer set search_path = '' as $$
declare
v_case public.birth_time_rectification_cases%rowtype;
v_job public.birth_time_rectification_scoring_jobs%rowtype;
begin
select j.* into v_job from public.birth_time_rectification_scoring_jobs j
where j.id = p_job_id and j.case_id = p_case_id and j.user_id = p_user_id for update;
if not found then raise exception 'birth_time_dynamic_scoring_job_not_found'; end if;
select c.* into v_case from public.birth_time_rectification_cases c
where c.id = p_case_id and c.user_id = p_user_id
and c.journey_protocol = 'dynamic-choice-v2' for update;
if not found then raise exception 'birth_time_dynamic_case_not_found'; end if;
if v_job.evidence_fingerprint is distinct from p_evidence_fingerprint then
raise exception 'birth_time_dynamic_scoring_fingerprint_mismatch';
end if;
if v_job.algorithm_version is distinct from p_algorithm_version then
raise exception 'birth_time_dynamic_scoring_algorithm_mismatch';
end if;
if v_job.status = 'completed' then
if v_job.result is distinct from p_candidate_result
or v_case.turn_version is distinct from p_expected_version + 1 then
raise exception 'stale_birth_time_dynamic_scoring_job';
end if;
return v_case.turn_version;
end if;
if v_job.status is distinct from 'processing'
or v_case.turn_version is distinct from p_expected_version
or v_case.turn_state #>> '{nextAction,jobId}' is distinct from p_job_id::text
or coalesce(v_case.turn_state #>> '{nextAction,kind}', '') not in ('score_pending', 'retry_scoring')
or p_candidate_result ->> 'algorithmVersion' is distinct from p_algorithm_version
or p_public_turn_state ->> 'journeyProtocol' is distinct from 'dynamic-choice-v2'
or (p_public_turn_state ->> 'turnVersion')::bigint is distinct from p_expected_version + 1
or jsonb_path_exists(p_public_turn_state, '$.**.partitionId')
or jsonb_path_exists(p_public_turn_state, '$.**.candidateScores')
or jsonb_path_exists(p_public_turn_state, '$.**.agentContext')
or jsonb_typeof(p_private_state) is distinct from 'object' then
raise exception 'stale_birth_time_dynamic_scoring_job';
end if;
update public.birth_time_rectification_scoring_jobs
set status = 'completed', result = p_candidate_result, failure_code = null,
completed_at = now(), updated_at = now()
where id = p_job_id and status = 'processing';
update public.birth_time_rectification_cases
set status = case p_snapshot ->> 'state'
when 'ready' then 'confirmed' when 'confirming' then 'confirming'
when 'candidate' then 'candidate' else 'rectifying' end,
journey_snapshot = p_snapshot, candidate_result = p_candidate_result,
event_scoring_version = p_algorithm_version,
candidate_result_id = (p_candidate_result ->> 'resultId')::uuid,
candidate_start = (p_candidate_result #>> '{winningSegment,startTime}')::time,
candidate_end = (p_candidate_result #>> '{winningSegment,endTime}')::time,
turn_version = p_expected_version + 1, turn_state = p_public_turn_state,
evidence_draft = null, updated_at = now()
where id = p_case_id and user_id = p_user_id and turn_version = p_expected_version;
if not found then raise exception 'stale_birth_time_dynamic_scoring_job'; end if;
perform public.persist_birth_time_dynamic_private_state(p_case_id, p_user_id, p_private_state);
return p_expected_version + 1;
end;
$$;
create or replace function public.fail_birth_time_dynamic_scoring_job(
p_user_id uuid, p_case_id uuid, p_job_id uuid, p_expected_version bigint,
p_evidence_fingerprint text, p_algorithm_version text, p_failure_code text,
p_public_turn_state jsonb, p_private_state jsonb
)
returns bigint language plpgsql security definer set search_path = '' as $$
declare
v_case public.birth_time_rectification_cases%rowtype;
v_job public.birth_time_rectification_scoring_jobs%rowtype;
begin
select j.* into v_job from public.birth_time_rectification_scoring_jobs j
where j.id = p_job_id and j.case_id = p_case_id and j.user_id = p_user_id for update;
if not found then raise exception 'birth_time_dynamic_scoring_job_not_found'; end if;
select c.* into v_case from public.birth_time_rectification_cases c
where c.id = p_case_id and c.user_id = p_user_id
and c.journey_protocol = 'dynamic-choice-v2' for update;
if not found then raise exception 'birth_time_dynamic_case_not_found'; end if;
if v_job.evidence_fingerprint is distinct from p_evidence_fingerprint then
raise exception 'birth_time_dynamic_scoring_fingerprint_mismatch';
end if;
if v_job.algorithm_version is distinct from p_algorithm_version then
raise exception 'birth_time_dynamic_scoring_algorithm_mismatch';
end if;
if v_job.status = 'failed' then
if v_job.failure_code is distinct from p_failure_code
or v_case.turn_version is distinct from p_expected_version + 1 then
raise exception 'stale_birth_time_dynamic_scoring_job';
end if;
return v_case.turn_version;
end if;
if v_job.status is distinct from 'processing'
or v_case.turn_version is distinct from p_expected_version
or v_case.turn_state #>> '{nextAction,jobId}' is distinct from p_job_id::text
or coalesce(v_case.turn_state #>> '{nextAction,kind}', '') not in ('score_pending', 'retry_scoring')
or p_public_turn_state #>> '{nextAction,kind}' is distinct from 'retry_scoring'
or p_public_turn_state #>> '{nextAction,jobId}' is distinct from p_job_id::text
or p_public_turn_state ->> 'journeyProtocol' is distinct from 'dynamic-choice-v2'
or (p_public_turn_state ->> 'turnVersion')::bigint is distinct from p_expected_version + 1
or jsonb_path_exists(p_public_turn_state, '$.**.partitionId')
or jsonb_path_exists(p_public_turn_state, '$.**.candidateScores')
or jsonb_path_exists(p_public_turn_state, '$.**.agentContext')
or jsonb_typeof(p_private_state) is distinct from 'object' then
raise exception 'stale_birth_time_dynamic_scoring_job';
end if;
update public.birth_time_rectification_scoring_jobs
set status = 'failed', failure_code = p_failure_code, updated_at = now()
where id = p_job_id and status = 'processing';
update public.birth_time_rectification_cases
set turn_version = p_expected_version + 1, turn_state = p_public_turn_state,
evidence_draft = null, updated_at = now()
where id = p_case_id and user_id = p_user_id and turn_version = p_expected_version;
if not found then raise exception 'stale_birth_time_dynamic_scoring_job'; end if;
perform public.persist_birth_time_dynamic_private_state(p_case_id, p_user_id, p_private_state);
return p_expected_version + 1;
end;
$$;
revoke all on function public.upgrade_birth_time_legacy_case(uuid, uuid, bigint, jsonb, jsonb) from public, anon, authenticated;
revoke all on function public.complete_birth_time_dynamic_scoring_job(uuid, uuid, uuid, bigint, text, text, jsonb, jsonb, jsonb, jsonb) from public, anon, authenticated;
revoke all on function public.fail_birth_time_dynamic_scoring_job(uuid, uuid, uuid, bigint, text, text, text, jsonb, jsonb) from public, anon, authenticated;
grant execute on function public.upgrade_birth_time_legacy_case(uuid, uuid, bigint, jsonb, jsonb) to service_role;
grant execute on function public.complete_birth_time_dynamic_scoring_job(uuid, uuid, uuid, bigint, text, text, jsonb, jsonb, jsonb, jsonb) to service_role;
grant execute on function public.fail_birth_time_dynamic_scoring_job(uuid, uuid, uuid, bigint, text, text, text, jsonb, jsonb) to service_role;
@@ -0,0 +1,254 @@
import { createDynamicTurnPersistence } from "../src/lib/birth-time-journey-dynamic-persistence.ts";
import type {
DynamicStoredRectificationCase,
LegacyStoredRectificationCase,
} from "../src/lib/birth-time-journey-service.ts";
export const caseId = "45857b75-4718-4590-aaf5-7113a03ea765";
export const ownerId = "12dc56f0-1f17-4a2f-86bf-1056ab78def9";
export const actionId = "a9890e09-d535-46f0-9a36-86017515a5a1";
export const currentRange = { startTime: "05:00", endTime: "06:00" } as const;
export const snapshot = {
state: "rectifying",
assistantIntent: "continue_rectification_questions",
input: "rectification_questions",
route: "rectification",
confidence: null,
canApply: false,
activeTime: null,
reportedRange: { label: "05:00—06:00", startTime: "05:00", endTime: "06:00" },
} as const;
export const persistedQuestion = {
questionId: "11111111-1111-4111-8111-111111111111",
opportunityId: "career-window",
dimensionCode: "career_change",
estimatedInformationGain: 0.7,
scoringVersion: "birth-time-choice-scoring-v2",
source: "fallback",
questionFingerprint: "question-fingerprint",
candidatePartitionFingerprint: "partition-fingerprint",
prompt: "哪一个时间段更接近这次工作变化?",
options: [
{
optionId: "22222222-2222-4222-8222-222222222222",
label: "较早阶段",
kind: "primary",
partitionId: "window-a",
candidateScores: { "05:10": 0.8 },
},
{
optionId: "33333333-3333-4333-8333-333333333333",
label: "较晚阶段",
kind: "primary",
partitionId: "window-b",
candidateScores: { "05:50": 0.7 },
},
{
optionId: "44444444-4444-4444-8444-444444444444",
label: "不确定 / 不记得",
kind: "unknown",
partitionId: null,
candidateScores: null,
},
{
optionId: "55555555-5555-4555-8555-555555555555",
label: "都不符合",
kind: "unmatched",
partitionId: null,
candidateScores: null,
},
],
} as const;
export const dynamicTurnState = {
journeyProtocol: "dynamic-choice-v2",
turnVersion: 7,
nextAction: {
kind: "ask_dynamic_choice",
question: {
questionId: persistedQuestion.questionId,
prompt: persistedQuestion.prompt,
options: persistedQuestion.options.map(({ optionId, label, kind }) => ({ optionId, label, kind })),
},
},
progress: {
phase: "question",
answeredCount: 1,
effectiveAnswerCount: 1,
currentRange,
previousRange: null,
plateauCount: 0,
},
permissions: { canConfirmCandidate: false },
} as const;
export const dynamicControl = {
asOfDate: "2026-07-18",
answeredCount: 1,
effectiveAnswerCount: 1,
plateauCount: 0,
questionFingerprints: [persistedQuestion.questionFingerprint],
partitionFingerprints: [persistedQuestion.candidatePartitionFingerprint],
dismissedOpportunityIds: [],
recentRanges: [currentRange],
pausedAction: null,
} as const;
const publicRow = {
id: caseId,
user_id: ownerId,
journey_protocol: "dynamic-choice-v2",
journey_snapshot: snapshot,
questionnaire: {},
answers: { legacy: "A" },
scoring_result: {},
reported_date: "1993-04-17",
life_events: [],
candidate_result: {},
turn_version: 7,
turn_state: dynamicTurnState,
evidence_draft: null,
processed_action_ids: [],
adaptive_round: 0,
asked_domains: [],
};
export const privateRow = {
case_id: caseId,
user_id: ownerId,
candidate_model: { candidates: ["05:10", "05:50"] },
current_choice_question: persistedQuestion,
choice_answers: [{
questionId: "prior-question",
optionId: "prior-option",
kind: "primary",
opportunityId: "prior-opportunity",
answeredAt: "2026-07-18T08:00:00.000Z",
}],
choice_evidence: [{
questionId: "prior-question",
opportunityId: "prior-opportunity",
partitionId: "prior-partition",
dimensionCode: "relocation_change",
candidateScores: { "05:10": 0.6, "05:50": 0.2 },
informationGain: 0.4,
}],
dynamic_control: dynamicControl,
agent_context: ["用户只记得大概阶段"],
};
export function loadClient(
privateState: typeof privateRow | null,
omitProcessedActions = false,
) {
const { processed_action_ids: ignoredReceipts, ...rowWithoutReceipts } = publicRow;
void ignoredReceipts;
return {
from(table: string) {
const row = table === "birth_time_rectification_cases"
? omitProcessedActions ? rowWithoutReceipts : publicRow
: table === "birth_time_rectification_dynamic_state"
? privateState
: { latitude: 31.2304, longitude: 121.4737, timezone_offset: 8 };
const query = {
select() { return query; },
eq() { return query; },
async maybeSingle() { return { data: row, error: null }; },
};
return query;
},
};
}
export function dynamicCase(): DynamicStoredRectificationCase {
return {
id: caseId,
userId: ownerId,
journeyProtocol: "dynamic-choice-v2",
snapshot,
questionnaire: null,
answers: { legacy: "A" },
lifeEvents: [],
candidateResult: null,
turnVersion: 7,
dynamicTurnState,
processedActionIds: [],
candidateModel: privateRow.candidate_model,
currentChoiceQuestion: persistedQuestion,
choiceAnswers: [],
choiceEvidence: [],
dynamicControl,
agentContext: privateRow.agent_context,
};
}
export function rpcPersistence() {
let saved = dynamicCase();
const calls: { readonly name: string; readonly args: Readonly<Record<string, unknown>> }[] = [];
return {
calls,
persistence: createDynamicTurnPersistence({
async rpc(name: string, args: Readonly<Record<string, unknown>>) {
calls.push({ name, args });
const receivedAction = String(args.p_action_id ?? "");
if (saved.processedActionIds.includes(receivedAction)) {
return { data: saved.turnVersion, error: null };
}
if (args.p_expected_version !== saved.turnVersion) {
return { data: null, error: { message: "stale_birth_time_dynamic_turn" } };
}
saved = {
...saved,
turnVersion: saved.turnVersion + 1,
dynamicTurnState: { ...saved.dynamicTurnState, turnVersion: saved.turnVersion + 1 },
processedActionIds: [...saved.processedActionIds, receivedAction],
};
return { data: saved.turnVersion, error: null };
},
}, async () => saved, () => "2026-07-18"),
};
}
export function legacyCase(active: boolean): LegacyStoredRectificationCase {
return {
id: caseId,
userId: ownerId,
snapshot,
questionnaire: null,
answers: { q1: "A" },
lifeEvents: [{ id: "event-1", domain: "career", precision: "year", date: "2019" }],
candidateResult: null,
turnVersion: 4,
turnState: {
turnVersion: 4,
nextAction: active
? {
kind: "ask_baseline_evidence",
question: {
questionId: "legacy-question",
phase: "baseline",
domain: "career",
requestedPrecision: ["year"],
allowUnknown: true,
purposeCode: "legacy",
plannerVersion: "legacy-v1",
},
}
: { kind: "present_low_result", resultId: null },
progress: {
phase: active ? "baseline" : "result",
baselineDomainCount: 1,
confirmedEvidenceCount: 1,
adaptiveRound: 0,
maxAdaptiveRounds: 3,
},
permissions: { canConfirmCandidate: false },
evidenceDraft: null,
},
evidenceDraft: null,
processedActionIds: [],
persistedProgress: { adaptiveRound: 0, askedDomains: ["career"] },
};
}
@@ -0,0 +1,250 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
BirthTimeDynamicStateMissingError,
createDynamicTurnPersistence,
} from "../src/lib/birth-time-journey-dynamic-persistence.ts";
import { saveDynamicAssessment } from "../src/lib/birth-time-journey-dynamic-case.ts";
import {
BirthTimeJourneyStoreError,
loadStoredRectificationCase,
StaleJourneyTurnError,
} from "../src/lib/birth-time-journey-turn-persistence.ts";
import type { StoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
import { assessBirthTime } from "../src/lib/birth-time-journey.ts";
import { dynamicJourneyTurnStateSchema } from "../src/lib/birth-time-journey-turn-protocol.ts";
import { assertLegacyJourneyMutation } from "../src/lib/birth-time-evidence-service.ts";
import {
createInitialDynamicState,
dynamicPrivateStateSchema,
} from "../src/lib/birth-time-journey-dynamic-state.ts";
import {
actionId,
caseId,
dynamicCase,
legacyCase,
loadClient,
ownerId,
persistedQuestion,
privateRow,
rpcPersistence,
snapshot,
} from "./birth-time-dynamic-persistence-fixture.ts";
import { memoryStore } from "./birth-time-journey-memory-store.ts";
test("v2 load restores the exact private question and candidate model", async () => {
const loaded = await loadStoredRectificationCase(loadClient(privateRow), ownerId, caseId);
assert.equal(loaded?.journeyProtocol, "dynamic-choice-v2");
assert.deepEqual(loaded?.currentChoiceQuestion, persistedQuestion);
assert.deepEqual(loaded?.candidateModel, privateRow.candidate_model);
assert.deepEqual(loaded?.dynamicControl?.questionFingerprints, [persistedQuestion.questionFingerprint]);
assert.deepEqual(loaded?.agentContext, privateRow.agent_context);
assert.deepEqual(loaded?.choiceAnswers, privateRow.choice_answers);
assert.deepEqual(loaded?.choiceEvidence, privateRow.choice_evidence);
});
test("new v2 cases initialize a dated private control state and public generation turn", () => {
const initial = createInitialDynamicState(snapshot, "2026-07-18");
assert.equal(initial.turn.journeyProtocol, "dynamic-choice-v2");
assert.equal(initial.turn.nextAction.kind, "generate_dynamic_question");
assert.equal(initial.privateState.dynamicControl.asOfDate, "2026-07-18");
assert.deepEqual(initial.privateState.choiceAnswers, []);
assert.deepEqual(initial.privateState.choiceEvidence, []);
assert.equal(JSON.stringify(initial.turn).includes("candidateModel"), false);
assert.equal(JSON.stringify(initial.turn).includes("agentContext"), false);
});
test("unknown birth time initializes the full-day dynamic range", () => {
const unknown = assessBirthTime({
date: "1990-01-01",
source: "unknown",
clue: "",
location: { lat: 31.23, lon: 121.47, tz: 8 },
}, { kind: "not_required" });
const initial = createInitialDynamicState(unknown, "2026-07-18");
assert.deepEqual(initial.turn.progress.currentRange, {
startTime: "00:00",
endTime: "23:59",
});
assert.deepEqual(initial.privateState.dynamicControl.recentRanges, [
{ startTime: "00:00", endTime: "23:59" },
]);
});
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({
async rpc(name, args) {
calls.push({ name, args });
return { data: caseId, error: null };
},
}, {
userId: ownerId,
assessment: {
date: "1993-04-17",
source: "period_only",
period: "early_morning",
location: { lat: 31.23, lon: 121.47, tz: 8 },
},
snapshot,
questionnaire: null,
candidateScan: null,
}, new Date("2026-07-18T08:00:00.000Z"));
assert.equal(savedId, caseId);
assert.equal(calls.length, 1);
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_private_state, createInitialDynamicState(
snapshot,
"2026-07-18",
).privateState);
});
test("v2 load rejects a missing private row instead of regenerating", async () => {
await assert.rejects(
loadStoredRectificationCase(loadClient(null), ownerId, caseId),
BirthTimeDynamicStateMissingError,
);
});
test("v2 load rejects missing required public persistence fields", async () => {
await assert.rejects(
loadStoredRectificationCase(loadClient(privateRow, true), ownerId, caseId),
BirthTimeJourneyStoreError,
);
});
test("v2 cases cannot fall through to legacy mutation paths", () => {
assert.throws(() => assertLegacyJourneyMutation(dynamicCase()), {
name: "GuidedJourneyLegacyMutationError",
});
});
test("saveDynamicTurn persists a private snapshot and a privacy-safe public turn once", async () => {
const fake = rpcPersistence();
const updated = dynamicCase();
const first = await fake.persistence.saveDynamicTurn(updated, 7, actionId);
const replay = await fake.persistence.saveDynamicTurn(updated, 7, actionId);
assert.equal(first.turnVersion, 8);
assert.equal(replay.turnVersion, 8);
assert.equal(replay.processedActionIds.filter((value) => value === actionId).length, 1);
assert.equal(fake.calls[0]?.name, "save_birth_time_dynamic_turn");
const publicTurn = JSON.stringify(fake.calls[0]?.args.p_public_turn_state);
assert.equal(publicTurn.includes("partitionId"), false);
assert.equal(publicTurn.includes("candidateScores"), false);
assert.equal(publicTurn.includes("agentContext"), false);
assert.deepEqual(fake.calls[0]?.args.p_private_state, {
candidateModel: updated.candidateModel,
currentChoiceQuestion: updated.currentChoiceQuestion,
choiceAnswers: updated.choiceAnswers,
choiceEvidence: updated.choiceEvidence,
dynamicControl: updated.dynamicControl,
agentContext: updated.agentContext,
});
});
test("saveDynamicTurn reports a stale version for an unprocessed action", async () => {
const fake = rpcPersistence();
await assert.rejects(
fake.persistence.saveDynamicTurn(dynamicCase(), 6, actionId),
StaleJourneyTurnError,
);
});
test("memory replay returns the stored advanced dynamic turn", async () => {
const initial = dynamicCase();
const memory = memoryStore(initial);
const changed = { ...initial, agentContext: ["persisted context"] };
const saved = await memory.store.saveDynamicTurn(changed, 7, actionId);
const replay = await memory.store.saveDynamicTurn(initial, 7, actionId);
assert.deepEqual(replay, saved);
assert.equal(replay.turnVersion, 8);
assert.deepEqual(replay.agentContext, ["persisted context"]);
assert.equal(memory.committedTurnWrites(), 1);
});
test("memory store replays seeded v2 receipts and executes legacy upgrades", async () => {
const seeded = { ...dynamicCase(), processedActionIds: [actionId] };
const dynamicMemory = memoryStore(seeded);
const replay = await dynamicMemory.store.saveDynamicTurn(dynamicCase(), 7, actionId);
assert.equal(replay, seeded);
const legacyMemory = memoryStore(legacyCase(true));
const upgraded = await legacyMemory.store.upgradeLegacyActiveCase(legacyCase(true));
assert.equal(upgraded.journeyProtocol, "dynamic-choice-v2");
assert.equal(upgraded.dynamicTurnState.nextAction.kind, "generate_dynamic_question");
assert.deepEqual(upgraded.answers, { q1: "A" });
});
test("legacy active upgrade preserves evidence and starts v2 without legacy fingerprints", async () => {
let loaded: StoredRectificationCase = legacyCase(true);
const rpcCalls: string[] = [];
const persistence = createDynamicTurnPersistence({
async rpc(name: string, args: Readonly<Record<string, unknown>>) {
rpcCalls.push(name);
const parsedPrivate = dynamicPrivateStateSchema.parse(args.p_private_state);
const parsedTurn = dynamicJourneyTurnStateSchema.parse(args.p_public_turn_state);
if (loaded.journeyProtocol === "dynamic-choice-v2") {
throw new Error("legacy upgrade replayed through the RPC fake");
}
loaded = {
...loaded,
journeyProtocol: "dynamic-choice-v2",
turnVersion: loaded.turnVersion ?? 0,
processedActionIds: loaded.processedActionIds ?? [],
dynamicTurnState: parsedTurn,
turnState: null,
evidenceDraft: null,
...parsedPrivate,
};
return { data: loaded.turnVersion, error: null };
},
}, async () => loaded, () => "2026-07-18");
const upgraded = await persistence.upgradeLegacyActiveCase(loaded);
assert.equal(upgraded.journeyProtocol, "dynamic-choice-v2");
assert.deepEqual(upgraded.answers, { q1: "A" });
assert.equal(upgraded.dynamicTurnState?.nextAction.kind, "generate_dynamic_question");
assert.deepEqual(upgraded.dynamicControl?.questionFingerprints, []);
assert.equal(upgraded.dynamicControl?.effectiveAnswerCount, 1);
assert.deepEqual(rpcCalls, ["upgrade_birth_time_legacy_case"]);
});
test("terminal legacy cases remain byte-for-byte unchanged", async () => {
const terminal = legacyCase(false);
let rpcCalled = false;
const persistence = createDynamicTurnPersistence({
async rpc() {
rpcCalled = true;
return { data: null, error: null };
},
}, async () => terminal, () => "2026-07-18");
const result = await persistence.upgradeLegacyActiveCase(terminal);
assert.equal(result, terminal);
assert.equal(rpcCalled, false);
});
test("dynamic persistence maps unknown RPC errors to a typed store error", async () => {
const persistence = createDynamicTurnPersistence({
async rpc() { return { data: null, error: { message: "database unavailable" } }; },
}, async () => dynamicCase(), () => "2026-07-18");
await assert.rejects(
persistence.saveDynamicTurn(dynamicCase(), 7, actionId),
BirthTimeJourneyStoreError,
);
});
@@ -10,7 +10,7 @@ import {
createBirthTimeGuideService,
} from "../src/lib/birth-time-guide-service.ts";
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
import type { StoredRectificationCase, VersionedJourneyResponse } from "../src/lib/birth-time-journey-service.ts";
import type { LegacyStoredRectificationCase, StoredRectificationCase, VersionedJourneyResponse } from "../src/lib/birth-time-journey-service.ts";
import { storedJourneyResponse } from "../src/lib/birth-time-journey-response.ts";
import { createInitialJourneyTurn } from "../src/lib/birth-time-journey-turn.ts";
import type { QuestionSpec } from "../src/lib/birth-time-question-planner.ts";
@@ -40,7 +40,7 @@ function careerQuestion(): QuestionSpec {
};
}
function storedCase(): StoredRectificationCase {
function storedCase(): LegacyStoredRectificationCase {
return {
id: caseId,
userId: "owner-1",
@@ -9,6 +9,12 @@ import {
memoryStore,
unusedJourneyEngine,
} from "./birth-time-journey-test-support.ts";
import {
actionId,
caseId as dynamicCaseId,
dynamicCase,
ownerId,
} from "./birth-time-dynamic-persistence-fixture.ts";
const highCandidate = candidateResultSchema.parse({
resultId: "f8eb3bc5-80eb-40fc-b937-e62ea37c3236",
@@ -126,3 +132,45 @@ test("guided turns reject legacy candidate confirmation without writing", async
assert.equal(memory.legacyWrites(), 0);
});
test("v2 turns reject legacy guided actions before writing", async () => {
const initial = dynamicCase();
const memory = memoryStore(initial);
const service = createBirthTimeJourneyService({
store: memory.store,
engine: unusedJourneyEngine,
});
await assert.rejects(
service.finishWithCurrentRange(ownerId, dynamicCaseId, actionId, 7),
rejectsLegacyMutation,
);
assert.equal(memory.committedTurnWrites(), 0);
assert.equal(memory.savedCase(), initial);
});
test("v2 turns reject guided candidate and scoring-job mutations", async () => {
const initial = { ...dynamicCase(), candidateResult: highCandidate };
const memory = memoryStore(initial);
const service = createBirthTimeJourneyService({
store: memory.store,
engine: unusedJourneyEngine,
});
await assert.rejects(service.saveGuidedCandidate({
userId: ownerId,
caseId: dynamicCaseId,
actionId,
expectedVersion: 7,
resultId: highCandidate.resultId,
}), rejectsLegacyMutation);
await assert.rejects(service.pollScoringJob(
ownerId,
dynamicCaseId,
"ea0fd8ef-4ccc-4330-8f25-428256ca52a8",
), rejectsLegacyMutation);
assert.equal(memory.committedTurnWrites(), 0);
assert.equal(memory.savedCase(), initial);
});
@@ -1,9 +1,14 @@
import type {
BirthTimeJourneyStore,
DynamicStoredRectificationCase,
PersistedJourneyAssessment,
StoredRectificationCase,
} from "../src/lib/birth-time-journey-service.ts";
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
import {
isTerminalLegacyCase,
prepareLegacyDynamicUpgrade,
} from "../src/lib/birth-time-journey-dynamic-state.ts";
import { createMemoryScoringJobs } from "./birth-time-scoring-memory-store.ts";
class MissingTestCaseError extends Error {
@@ -12,9 +17,14 @@ class MissingTestCaseError extends Error {
export const journeyCaseId = "7299894c-10a8-4b45-91d1-339007282c50";
export function memoryStore(initialCase?: StoredRectificationCase) {
export function memoryStore(
initialCase?: StoredRectificationCase,
asOfDate = "2026-07-18",
) {
let savedAssessment: PersistedJourneyAssessment | null = null;
let savedCase = initialCase ?? null;
let savedDynamicCase: DynamicStoredRectificationCase | null =
initialCase?.journeyProtocol === "dynamic-choice-v2" ? initialCase : null;
let committedTurnWrites = 0;
let legacyWrites = 0;
let guidedCandidateWrites = 0;
@@ -58,6 +68,36 @@ export function memoryStore(initialCase?: StoredRectificationCase) {
committedTurnWrites += 1;
return savedCase;
},
async saveDynamicTurn(value, expectedVersion, actionId) {
if (!savedCase) throw new MissingTestCaseError();
const receipts = savedCase.processedActionIds ?? [];
if (receipts.includes(actionId)) {
if (!savedDynamicCase) throw new MissingTestCaseError();
return savedDynamicCase;
}
if (savedCase.turnVersion !== expectedVersion) {
throw new StaleJourneyTurnError(savedCase.id, expectedVersion, savedCase.turnVersion ?? 0);
}
const savedDynamic = {
...value,
turnVersion: expectedVersion + 1,
dynamicTurnState: { ...value.dynamicTurnState, turnVersion: expectedVersion + 1 },
processedActionIds: [...receipts, actionId],
};
savedCase = savedDynamic;
savedDynamicCase = savedDynamic;
committedTurnWrites += 1;
return savedDynamic;
},
async upgradeLegacyActiveCase(value) {
if (value.journeyProtocol === "dynamic-choice-v2" || isTerminalLegacyCase(value)) {
return value;
}
const upgraded = prepareLegacyDynamicUpgrade(value, asOfDate);
savedCase = upgraded;
savedDynamicCase = upgraded;
return upgraded;
},
...scoringJobs.methods,
async saveCandidateResult(value) {
legacyWrites += 1;
@@ -109,6 +149,7 @@ export function memoryStore(initialCase?: StoredRectificationCase) {
createdScoringCase: scoringJobs.createdCase,
replaceCase: (value: StoredRectificationCase) => {
savedCase = value;
savedDynamicCase = value.journeyProtocol === "dynamic-choice-v2" ? value : null;
},
};
}
@@ -3,7 +3,7 @@ import test from "node:test";
import { candidateResultSchema } from "../src/lib/birth-time-journey.ts";
import { completeScoreTransition } from "../src/lib/birth-time-journey-score-transition.ts";
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
import type { StoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
import type { LegacyStoredRectificationCase as StoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
import {
existingCareerEvent,
existingEducationEvent,
@@ -2,7 +2,7 @@ import { birthTimeAssessmentSchema, candidateResultSchema, lifeEventSchema } fro
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
import type {
LegacyBirthTimeJourneyEngine,
StoredRectificationCase,
LegacyStoredRectificationCase,
} from "../src/lib/birth-time-journey-service.ts";
import type { EvidenceDomain } from "../src/lib/birth-time-question-planner.ts";
import {
@@ -117,7 +117,7 @@ type GuidedCaseInput = {
readonly candidateResult?: ReturnType<typeof candidateResultSchema.parse> | null;
};
export function guidedCase(input: GuidedCaseInput = {}): StoredRectificationCase {
export function guidedCase(input: GuidedCaseInput = {}): LegacyStoredRectificationCase {
const version = input.version ?? 0;
const phase = input.phase ?? "baseline";
const domain = input.domain ?? "career";
@@ -167,7 +167,7 @@ export function guidedCase(input: GuidedCaseInput = {}): StoredRectificationCase
};
}
export function progressionService(storedCase: StoredRectificationCase) {
export function progressionService(storedCase: LegacyStoredRectificationCase) {
let scoreEventsCalls = 0;
const memory = memoryStore(storedCase);
const service = createBirthTimeJourneyService({
@@ -58,7 +58,7 @@ test("journey store keeps scored candidates server-owned until confirmation", ()
assert.match(storeSource, /life_events: value\.lifeEvents/);
assert.match(storeSource, /candidate_result: value\.candidateResult/);
assert.match(storeSource, /async confirmCandidate\(value\)/);
assert.match(storeSource, /active_birth_time: value\.snapshot\.activeTime/);
assert.match(storeSource, /confirm_birth_time_candidate/);
});
test("journey route exposes only structured evidence and guarded candidate actions", () => {
@@ -0,0 +1,134 @@
import re
from pathlib import Path
MIGRATION = (
Path(__file__).resolve().parents[1]
/ "frontend"
/ "supabase"
/ "migrations"
/ "20260718090000_dynamic_choice_birth_time_rectification.sql"
)
TRANSITIONS_MIGRATION = MIGRATION.with_name(
"20260718091000_dynamic_choice_birth_time_transitions.sql"
)
def _sql() -> str:
source = "\n".join(
path.read_text(encoding="utf-8") for path in (MIGRATION, TRANSITIONS_MIGRATION)
)
return re.sub(r"\s+", " ", source.lower()).strip()
def _function(sql: str, name: str, next_name: str | None = None) -> str:
body = sql.split(f"create or replace function public.{name}", 1)[1]
return body.split(
f"create or replace function public.{next_name}" if next_name else "$$;",
1,
)[0]
def test_private_dynamic_state_is_service_role_only_and_bounded() -> None:
sql = _sql()
assert "journey_protocol text not null default 'legacy-guided-v1'" in sql
assert "check (journey_protocol in ('legacy-guided-v1', 'dynamic-choice-v2'))" in sql
assert "create table if not exists public.birth_time_rectification_dynamic_state" in sql
for definition in (
"candidate_model jsonb",
"current_choice_question jsonb",
"choice_answers jsonb not null default '[]'::jsonb",
"choice_evidence jsonb not null default '[]'::jsonb",
"dynamic_control jsonb not null",
"agent_context jsonb not null default '[]'::jsonb",
):
assert definition in sql
assert "jsonb_array_length(choice_answers) <= 50" in sql
assert "jsonb_array_length(choice_evidence) <= 10" in sql
assert "jsonb_array_length(agent_context) <= 10" in sql
assert "birth_time_dynamic_agent_context_valid(agent_context)" in sql
assert "pg_catalog.length(note) > 240" in sql
assert "alter table public.birth_time_rectification_dynamic_state enable row level security" in sql
assert "revoke all on table public.birth_time_rectification_dynamic_state from anon, authenticated" in sql
assert "grant all on table public.birth_time_rectification_dynamic_state to service_role" in sql
def test_dynamic_case_creation_is_one_service_role_transaction() -> None:
sql = _sql()
body = _function(sql, "create_birth_time_dynamic_case", "save_birth_time_dynamic_turn")
assert "security definer" in body
assert "set search_path = ''" in body
assert "insert into public.birth_time_rectification_cases" in body
assert "perform public.persist_birth_time_dynamic_private_state" in body
assert "update public.profiles" in body
assert "raise exception 'birth_time_dynamic_profile_not_found'" in body
assert "revoke all on function public.create_birth_time_dynamic_case" in sql
assert "grant execute on function public.create_birth_time_dynamic_case" in sql
def test_dynamic_turn_rpc_is_versioned_private_and_replay_safe() -> None:
sql = _sql()
body = _function(sql, "save_birth_time_dynamic_turn", "upgrade_birth_time_legacy_case")
for invariant in (
"security definer",
"set search_path = ''",
"c.user_id = p_user_id",
"v_case.journey_protocol is distinct from 'dynamic-choice-v2'",
"p_action_id = any(v_case.processed_action_ids)",
"v_case.turn_version is distinct from p_expected_version",
"raise exception 'stale_birth_time_dynamic_turn'",
"update public.birth_time_rectification_cases",
"perform public.persist_birth_time_dynamic_private_state",
):
assert invariant in body
assert "revoke all on function public.save_birth_time_dynamic_turn" in sql
assert "grant execute on function public.save_birth_time_dynamic_turn" in sql
def test_dynamic_scoring_rpcs_bind_job_identity_and_replay_state() -> None:
sql = _sql()
for function_name in (
"complete_birth_time_dynamic_scoring_job",
"fail_birth_time_dynamic_scoring_job",
):
body = _function(sql, function_name)
for invariant in (
"security definer",
"set search_path = ''",
"j.case_id = p_case_id",
"j.user_id = p_user_id",
"v_job.evidence_fingerprint is distinct from p_evidence_fingerprint",
"v_job.algorithm_version is distinct from p_algorithm_version",
"v_case.turn_version is distinct from p_expected_version",
"coalesce(v_case.turn_state #>> '{nextaction,kind}', '') not in",
"p_job_id::text",
"perform public.persist_birth_time_dynamic_private_state",
):
assert invariant in body
assert f"revoke all on function public.{function_name}" in sql
assert f"grant execute on function public.{function_name}" in sql
complete = _function(
sql,
"complete_birth_time_dynamic_scoring_job",
"fail_birth_time_dynamic_scoring_job",
)
assert "v_job.status = 'completed'" in complete
assert "v_job.result is distinct from p_candidate_result" in complete
assert "p_candidate_result ->> 'algorithmversion'" in complete
failed = _function(sql, "fail_birth_time_dynamic_scoring_job")
assert "v_job.status = 'failed'" in failed
assert "v_job.failure_code is distinct from p_failure_code" in failed
def test_private_state_upsert_has_one_internal_owner_only_implementation() -> None:
sql = _sql()
body = _function(
sql,
"persist_birth_time_dynamic_private_state",
"create_birth_time_dynamic_case",
)
assert "insert into public.birth_time_rectification_dynamic_state" in body
assert "on conflict (case_id) do update" in body
assert sql.count("insert into public.birth_time_rectification_dynamic_state") == 1
assert "revoke all on function public.persist_birth_time_dynamic_private_state" in sql
@@ -1,7 +1,6 @@
import re
from pathlib import Path
MIGRATION = (
Path(__file__).resolve().parents[1]
/ "frontend"