From 723d05c62350c88beab052b0fa907dc92b2e83b3 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 19 Jul 2026 06:42:03 +0800 Subject: [PATCH] feat: persist dynamic rectification turns --- .superpowers/sdd/task-5-report.md | 86 ++++++ .../src/lib/birth-time-evidence-service.ts | 16 +- .../src/lib/birth-time-guided-candidate.ts | 4 +- .../lib/birth-time-guided-draft-revision.ts | 2 + .../src/lib/birth-time-journey-actions.ts | 7 +- .../src/lib/birth-time-journey-case-loader.ts | 222 +++++++++++++++ .../lib/birth-time-journey-dynamic-case.ts | 68 +++++ .../birth-time-journey-dynamic-persistence.ts | 149 ++++++++++ .../lib/birth-time-journey-dynamic-state.ts | 234 ++++++++++++++++ frontend/src/lib/birth-time-journey-errors.ts | 13 + .../birth-time-journey-score-transition.ts | 2 +- .../src/lib/birth-time-journey-service.ts | 39 +-- .../lib/birth-time-journey-store-errors.ts | 29 ++ frontend/src/lib/birth-time-journey-store.ts | 91 ++----- .../lib/birth-time-journey-stored-protocol.ts | 46 ++++ .../src/lib/birth-time-journey-transitions.ts | 2 +- .../birth-time-journey-turn-persistence.ts | 148 +--------- .../src/lib/birth-time-scoring-service.ts | 4 +- ...ynamic_choice_birth_time_rectification.sql | 254 ++++++++++++++++++ ..._dynamic_choice_birth_time_transitions.sql | 165 ++++++++++++ .../birth-time-dynamic-persistence-fixture.ts | 254 ++++++++++++++++++ .../birth-time-dynamic-persistence.test.ts | 250 +++++++++++++++++ frontend/tests/birth-time-guide-route.test.ts | 4 +- ...irth-time-journey-legacy-isolation.test.ts | 48 ++++ .../tests/birth-time-journey-memory-store.ts | 43 ++- ...irth-time-journey-score-transition.test.ts | 2 +- .../tests/birth-time-journey-test-support.ts | 6 +- .../birth-time-rectification-contract.test.ts | 2 +- ...birth_time_dynamic_persistence_contract.py | 134 +++++++++ tests/test_birth_time_journey_contract.py | 1 - 30 files changed, 2082 insertions(+), 243 deletions(-) create mode 100644 .superpowers/sdd/task-5-report.md create mode 100644 frontend/src/lib/birth-time-journey-case-loader.ts create mode 100644 frontend/src/lib/birth-time-journey-dynamic-case.ts create mode 100644 frontend/src/lib/birth-time-journey-dynamic-persistence.ts create mode 100644 frontend/src/lib/birth-time-journey-dynamic-state.ts create mode 100644 frontend/src/lib/birth-time-journey-errors.ts create mode 100644 frontend/src/lib/birth-time-journey-store-errors.ts create mode 100644 frontend/src/lib/birth-time-journey-stored-protocol.ts create mode 100644 frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql create mode 100644 frontend/supabase/migrations/20260718091000_dynamic_choice_birth_time_transitions.sql create mode 100644 frontend/tests/birth-time-dynamic-persistence-fixture.ts create mode 100644 frontend/tests/birth-time-dynamic-persistence.test.ts create mode 100644 tests/test_birth_time_dynamic_persistence_contract.py diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md new file mode 100644 index 00000000..7f5d5148 --- /dev/null +++ b/.superpowers/sdd/task-5-report.md @@ -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` diff --git a/frontend/src/lib/birth-time-evidence-service.ts b/frontend/src/lib/birth-time-evidence-service.ts index 8c08d7e0..3eabcd7f 100644 --- a/frontend/src/lib/birth-time-evidence-service.ts +++ b/frontend/src/lib/birth-time-evidence-service.ts @@ -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( diff --git a/frontend/src/lib/birth-time-guided-candidate.ts b/frontend/src/lib/birth-time-guided-candidate.ts index 76a3c0da..30b97ab1 100644 --- a/frontend/src/lib/birth-time-guided-candidate.ts +++ b/frontend/src/lib/birth-time-guided-candidate.ts @@ -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; } diff --git a/frontend/src/lib/birth-time-guided-draft-revision.ts b/frontend/src/lib/birth-time-guided-draft-revision.ts index 4e79bfe2..463e43b4 100644 --- a/frontend/src/lib/birth-time-guided-draft-revision.ts +++ b/frontend/src/lib/birth-time-guided-draft-revision.ts @@ -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); } diff --git a/frontend/src/lib/birth-time-journey-actions.ts b/frontend/src/lib/birth-time-journey-actions.ts index 80efb063..e253e5b9 100644 --- a/frontend/src/lib/birth-time-journey-actions.ts +++ b/frontend/src/lib/birth-time-journey-actions.ts @@ -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 { 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); diff --git a/frontend/src/lib/birth-time-journey-case-loader.ts b/frontend/src/lib/birth-time-journey-case-loader.ts new file mode 100644 index 00000000..5079f465 --- /dev/null +++ b/frontend/src/lib/birth-time-journey-case-loader.ts @@ -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; +}; +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 { + 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; +} diff --git a/frontend/src/lib/birth-time-journey-dynamic-case.ts b/frontend/src/lib/birth-time-journey-dynamic-case.ts new file mode 100644 index 00000000..c6a25e32 --- /dev/null +++ b/frontend/src/lib/birth-time-journey-dynamic-case.ts @@ -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 { + 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); +} diff --git a/frontend/src/lib/birth-time-journey-dynamic-persistence.ts b/frontend/src/lib/birth-time-journey-dynamic-persistence.ts new file mode 100644 index 00000000..b573a36f --- /dev/null +++ b/frontend/src/lib/birth-time-journey-dynamic-persistence.ts @@ -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>, + ) => PromiseLike; +}; + +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, + asOfDate: () => string, +) { + async function loadedDynamic(userId: string, caseId: string): Promise { + 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 { + 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 { + 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); + }, + }; +} diff --git a/frontend/src/lib/birth-time-journey-dynamic-state.ts b/frontend/src/lib/birth-time-journey-dynamic-state.ts new file mode 100644 index 00000000..6796da67 --- /dev/null +++ b/frontend/src/lib/birth-time-journey-dynamic-state.ts @@ -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> | 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: [], + }; +} diff --git a/frontend/src/lib/birth-time-journey-errors.ts b/frontend/src/lib/birth-time-journey-errors.ts new file mode 100644 index 00000000..74ffd21a --- /dev/null +++ b/frontend/src/lib/birth-time-journey-errors.ts @@ -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"; +} diff --git a/frontend/src/lib/birth-time-journey-score-transition.ts b/frontend/src/lib/birth-time-journey-score-transition.ts index d41433a9..b28c6153 100644 --- a/frontend/src/lib/birth-time-journey-score-transition.ts +++ b/frontend/src/lib/birth-time-journey-score-transition.ts @@ -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; diff --git a/frontend/src/lib/birth-time-journey-service.ts b/frontend/src/lib/birth-time-journey-service.ts index 02f3f6fa..f2aa9bf2 100644 --- a/frontend/src/lib/birth-time-journey-service.ts +++ b/frontend/src/lib/birth-time-journey-service.ts @@ -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; +}; + +export type LegacyStoredRectificationCase = StoredRectificationCaseBase + & LegacyStoredFields; + +export type DynamicStoredRectificationCase = StoredRectificationCaseBase + & DynamicStoredFields; + +export type StoredRectificationCase = + | LegacyStoredRectificationCase + | DynamicStoredRectificationCase; export interface BirthTimeJourneyStore { saveAssessment(value: PersistedJourneyAssessment): Promise; loadCase(userId: string, caseId: string): Promise; saveScoring(value: StoredRectificationCase): Promise; saveTurn(value: StoredRectificationCase, expectedVersion: number, actionId: string): Promise; + saveDynamicTurn(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string): Promise; + upgradeLegacyActiveCase(value: StoredRectificationCase): Promise; createScoringJob(value: StoredRectificationCase, expectedVersion: number, actionId: string, job: ScoringJobSpec): Promise; claimScoringJob(identity: ScoringJobIdentity): Promise; completeScoringJob(value: StoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string): Promise; @@ -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); diff --git a/frontend/src/lib/birth-time-journey-store-errors.ts b/frontend/src/lib/birth-time-journey-store-errors.ts new file mode 100644 index 00000000..ab85041d --- /dev/null +++ b/frontend/src/lib/birth-time-journey-store-errors.ts @@ -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; + } +} diff --git a/frontend/src/lib/birth-time-journey-store.ts b/frontend/src/lib/birth-time-journey-store.ts index f946321a..042090ef 100644 --- a/frontend/src/lib/birth-time-journey-store.ts +++ b/frontend/src/lib/birth-time-journey-store.ts @@ -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, diff --git a/frontend/src/lib/birth-time-journey-stored-protocol.ts b/frontend/src/lib/birth-time-journey-stored-protocol.ts new file mode 100644 index 00000000..2306a8c4 --- /dev/null +++ b/frontend/src/lib/birth-time-journey-stored-protocol.ts @@ -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> | null; + readonly currentChoiceQuestion: PersistedDynamicChoiceQuestion | null; + readonly choiceAnswers: readonly StoredChoiceAnswer[]; + readonly choiceEvidence: readonly ServerChoiceEvidence[]; + readonly dynamicControl: DynamicControlState; + readonly agentContext: readonly string[]; +}; diff --git a/frontend/src/lib/birth-time-journey-transitions.ts b/frontend/src/lib/birth-time-journey-transitions.ts index ec68faef..d2a6a00d 100644 --- a/frontend/src/lib/birth-time-journey-transitions.ts +++ b/frontend/src/lib/birth-time-journey-transitions.ts @@ -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; diff --git a/frontend/src/lib/birth-time-journey-turn-persistence.ts b/frontend/src/lib/birth-time-journey-turn-persistence.ts index 2575328d..ddc429b7 100644 --- a/frontend/src/lib/birth-time-journey-turn-persistence.ts +++ b/frontend/src/lib/birth-time-journey-turn-persistence.ts @@ -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 { - 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, diff --git a/frontend/src/lib/birth-time-scoring-service.ts b/frontend/src/lib/birth-time-scoring-service.ts index d5fa6811..967e0ed2 100644 --- a/frontend/src/lib/birth-time-scoring-service.ts +++ b/frontend/src/lib/birth-time-scoring-service.ts @@ -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, diff --git a/frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql b/frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql new file mode 100644 index 00000000..94c49b10 --- /dev/null +++ b/frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql @@ -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; diff --git a/frontend/supabase/migrations/20260718091000_dynamic_choice_birth_time_transitions.sql b/frontend/supabase/migrations/20260718091000_dynamic_choice_birth_time_transitions.sql new file mode 100644 index 00000000..75f1883c --- /dev/null +++ b/frontend/supabase/migrations/20260718091000_dynamic_choice_birth_time_transitions.sql @@ -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; diff --git a/frontend/tests/birth-time-dynamic-persistence-fixture.ts b/frontend/tests/birth-time-dynamic-persistence-fixture.ts new file mode 100644 index 00000000..c91587fc --- /dev/null +++ b/frontend/tests/birth-time-dynamic-persistence-fixture.ts @@ -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> }[] = []; + return { + calls, + persistence: createDynamicTurnPersistence({ + async rpc(name: string, args: Readonly>) { + 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"] }, + }; +} diff --git a/frontend/tests/birth-time-dynamic-persistence.test.ts b/frontend/tests/birth-time-dynamic-persistence.test.ts new file mode 100644 index 00000000..76eb1224 --- /dev/null +++ b/frontend/tests/birth-time-dynamic-persistence.test.ts @@ -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> }[] = []; + 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>) { + 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, + ); +}); diff --git a/frontend/tests/birth-time-guide-route.test.ts b/frontend/tests/birth-time-guide-route.test.ts index 670bd6b1..d9d769dd 100644 --- a/frontend/tests/birth-time-guide-route.test.ts +++ b/frontend/tests/birth-time-guide-route.test.ts @@ -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", diff --git a/frontend/tests/birth-time-journey-legacy-isolation.test.ts b/frontend/tests/birth-time-journey-legacy-isolation.test.ts index 7b39e336..865ec7d2 100644 --- a/frontend/tests/birth-time-journey-legacy-isolation.test.ts +++ b/frontend/tests/birth-time-journey-legacy-isolation.test.ts @@ -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); +}); diff --git a/frontend/tests/birth-time-journey-memory-store.ts b/frontend/tests/birth-time-journey-memory-store.ts index ed09735f..71f5e529 100644 --- a/frontend/tests/birth-time-journey-memory-store.ts +++ b/frontend/tests/birth-time-journey-memory-store.ts @@ -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; }, }; } diff --git a/frontend/tests/birth-time-journey-score-transition.test.ts b/frontend/tests/birth-time-journey-score-transition.test.ts index d7675100..5baba6e3 100644 --- a/frontend/tests/birth-time-journey-score-transition.test.ts +++ b/frontend/tests/birth-time-journey-score-transition.test.ts @@ -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, diff --git a/frontend/tests/birth-time-journey-test-support.ts b/frontend/tests/birth-time-journey-test-support.ts index daa92153..458f1aaa 100644 --- a/frontend/tests/birth-time-journey-test-support.ts +++ b/frontend/tests/birth-time-journey-test-support.ts @@ -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 | 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({ diff --git a/frontend/tests/birth-time-rectification-contract.test.ts b/frontend/tests/birth-time-rectification-contract.test.ts index 706e3d43..3ba25baf 100644 --- a/frontend/tests/birth-time-rectification-contract.test.ts +++ b/frontend/tests/birth-time-rectification-contract.test.ts @@ -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", () => { diff --git a/tests/test_birth_time_dynamic_persistence_contract.py b/tests/test_birth_time_dynamic_persistence_contract.py new file mode 100644 index 00000000..dd5e3a5a --- /dev/null +++ b/tests/test_birth_time_dynamic_persistence_contract.py @@ -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 diff --git a/tests/test_birth_time_journey_contract.py b/tests/test_birth_time_journey_contract.py index 3173f8e2..87bd8d99 100644 --- a/tests/test_birth_time_journey_contract.py +++ b/tests/test_birth_time_journey_contract.py @@ -1,7 +1,6 @@ import re from pathlib import Path - MIGRATION = ( Path(__file__).resolve().parents[1] / "frontend"