From 9bd93f33cf6c1f751ccac0e5c124f7abec508f59 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 19 Jul 2026 07:20:40 +0800 Subject: [PATCH] fix: isolate dynamic rectification persistence --- .superpowers/sdd/task-5-report.md | 24 ++-- .../src/app/api/birth-time-journey/route.ts | 3 + .../lib/birth-time-guided-candidate-store.ts | 9 +- .../src/lib/birth-time-journey-case-loader.ts | 4 + .../lib/birth-time-journey-dynamic-case.ts | 2 - .../birth-time-journey-dynamic-persistence.ts | 4 +- .../lib/birth-time-journey-dynamic-state.ts | 1 + .../src/lib/birth-time-journey-response.ts | 18 +++ .../src/lib/birth-time-journey-service.ts | 59 +++++----- frontend/src/lib/birth-time-journey-store.ts | 27 +++-- .../lib/birth-time-journey-stored-protocol.ts | 8 +- .../birth-time-journey-turn-persistence.ts | 10 +- .../lib/birth-time-scoring-job-persistence.ts | 3 +- .../src/lib/birth-time-scoring-job-store.ts | 12 +- ...ynamic_choice_birth_time_rectification.sql | 2 - ...8092000_legacy_scoring_protocol_guards.sql | 107 ++++++++++++++++++ ...93000_legacy_candidate_protocol_guards.sql | 71 ++++++++++++ .../birth-time-agent-flow-test-support.ts | 8 +- .../birth-time-dynamic-persistence-fixture.ts | 9 +- .../birth-time-dynamic-persistence.test.ts | 33 +++++- .../tests/birth-time-dynamic-resume.test.ts | 37 ++++++ frontend/tests/birth-time-guide-route.test.ts | 1 + .../tests/birth-time-journey-memory-store.ts | 17 ++- .../tests/birth-time-journey-resume.test.ts | 1 + .../tests/birth-time-journey-service.test.ts | 4 + .../tests/birth-time-journey-test-support.ts | 1 + ...rth-time-journey-turn-persistence.test.mjs | 23 +++- ...birth_time_dynamic_persistence_contract.py | 59 ++++++++++ 28 files changed, 483 insertions(+), 74 deletions(-) create mode 100644 frontend/supabase/migrations/20260718092000_legacy_scoring_protocol_guards.sql create mode 100644 frontend/supabase/migrations/20260718093000_legacy_candidate_protocol_guards.sql create mode 100644 frontend/tests/birth-time-dynamic-resume.test.ts diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md index 7f5d5148..e6e1c1f3 100644 --- a/.superpowers/sdd/task-5-report.md +++ b/.superpowers/sdd/task-5-report.md @@ -6,10 +6,14 @@ Implemented durable `dynamic-choice-v2` persistence with a public/private data s 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. +The acceptance follow-up preserves an existing chart time when rectification starts, rejects split public/JSON turn versions, and routes v2 resume before any legacy scoring normalization. Legacy store inputs now accept only the strict legacy arm. Direct legacy updates include an atomic protocol predicate, while ordered service-role RPC wrappers lock the case and require `legacy-guided-v1` before invoking the former scoring or candidate transaction. Dynamic action receipts are canonicalized to lowercase in both production and the shared memory fake. + ## Files - `frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql` - `frontend/supabase/migrations/20260718091000_dynamic_choice_birth_time_transitions.sql` +- `frontend/supabase/migrations/20260718092000_legacy_scoring_protocol_guards.sql` +- `frontend/supabase/migrations/20260718093000_legacy_candidate_protocol_guards.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` @@ -19,6 +23,7 @@ New assessments initialize the public case, private state, and profile pointer i - `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-response.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` @@ -27,6 +32,7 @@ New assessments initialize the public case, private state, and profile pointer i - `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-dynamic-resume.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` @@ -41,25 +47,27 @@ New assessments initialize the public case, private state, and profile pointer i - 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` +- Profile preservation RED: `.omo/evidence/task-5-profile-preservation-ts-red.log`, `.omo/evidence/task-5-profile-preservation-python-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` +- Profile preservation GREEN: `.omo/evidence/task-5-profile-preservation-ts-green.log`, `.omo/evidence/task-5-profile-preservation-python-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. +The additional regressions prove that a missing current scoring action cannot pass a SQL `NOT IN` guard through three-valued `NULL` logic, that the supported unknown-time assessment initializes a valid full-day dynamic range, that SQL/public JSON turn versions cannot disagree, that v2 resume performs zero legacy writes, that an upgrade race cannot cross a legacy protocol predicate, and that uppercase UUID retries replay the stored advanced dynamic state. ## 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. +- Focused persistence, resume, and upgrade-race TypeScript: 28/28 passed. +- Relevant Python persistence, engine, and scoring contracts: 43/43 passed. +- Full birth-time frontend suite: 259/259 passed. +- Full frontend suite: 334/334 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. +- All changed TypeScript, test, and ordered migration modules are at most 250 pure LOC; maximum is 250. - 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: @@ -75,7 +83,7 @@ Evidence: ## Review -Fresh review: **CLEAR / APPROVE**, with no blockers. Artifact: `.omo/evidence/task-5-code-review.md`. +Fresh follow-up review against the current acceptance-fix diff: **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. @@ -84,3 +92,5 @@ Task 6 remains responsible for routing public `assess`/`resume` responses and dy ## Commit Commit message: `feat: persist dynamic rectification turns` + +Follow-up commit message: `fix: isolate dynamic rectification persistence` diff --git a/frontend/src/app/api/birth-time-journey/route.ts b/frontend/src/app/api/birth-time-journey/route.ts index 30dae882..6d79cd43 100644 --- a/frontend/src/app/api/birth-time-journey/route.ts +++ b/frontend/src/app/api/birth-time-journey/route.ts @@ -117,6 +117,9 @@ export async function POST(request: Request) { case "poll_scoring": { const before = await service.resume(user.id, parsed.data.caseId); + if (before.journeyProtocol === "dynamic-choice-v2") { + throw new GuidedJourneyLegacyMutationError(parsed.data.caseId); + } const response = await service.pollScoringJob(user.id, parsed.data.caseId, parsed.data.jobId); recordScoringJourneyMetric(before, response); return NextResponse.json(response); diff --git a/frontend/src/lib/birth-time-guided-candidate-store.ts b/frontend/src/lib/birth-time-guided-candidate-store.ts index 2fb6ebf8..941f20e1 100644 --- a/frontend/src/lib/birth-time-guided-candidate-store.ts +++ b/frontend/src/lib/birth-time-guided-candidate-store.ts @@ -54,7 +54,10 @@ export function createSupabaseGuidedCandidateStore( const { error } = await supabase.rpc(functionName, args); if (error) { const current = await loadCase(value.userId, value.id); - if (current?.processedActionIds?.includes(command.actionId.toLowerCase())) return current; + if ( + current?.journeyProtocol === "legacy-guided-v1" + && current.processedActionIds?.includes(command.actionId.toLowerCase()) + ) return current; if (isDomainError(error, "stale_guided_candidate_turn")) { throw new StaleJourneyTurnError( value.id, @@ -68,7 +71,9 @@ export function createSupabaseGuidedCandidateStore( throw new BirthTimeJourneyStoreError("update_case"); } const current = await loadCase(value.userId, value.id); - if (!current) throw new BirthTimeJourneyStoreError("load_case"); + if (!current || current.journeyProtocol !== "legacy-guided-v1") { + throw new BirthTimeJourneyStoreError("load_case"); + } return current; }, }; diff --git a/frontend/src/lib/birth-time-journey-case-loader.ts b/frontend/src/lib/birth-time-journey-case-loader.ts index 5079f465..5bd31f63 100644 --- a/frontend/src/lib/birth-time-journey-case-loader.ts +++ b/frontend/src/lib/birth-time-journey-case-loader.ts @@ -162,6 +162,10 @@ export async function loadStoredRectificationCase( : null; if (dynamicTurn && !dynamicTurn.success) throw new BirthTimeJourneyStoreError("load_case"); const dynamicTurnState = dynamicTurn?.data ?? null; + if ( + dynamicTurnState !== null + && dynamicTurnState.turnVersion !== parsed.turn_version + ) throw new BirthTimeJourneyStoreError("load_case"); const evidenceDraft = parseEvidenceDraft(parsed.evidence_draft); let dynamicPrivate = null; if (parsed.journey_protocol === "dynamic-choice-v2") { diff --git a/frontend/src/lib/birth-time-journey-dynamic-case.ts b/frontend/src/lib/birth-time-journey-dynamic-case.ts index c6a25e32..c26959a4 100644 --- a/frontend/src/lib/birth-time-journey-dynamic-case.ts +++ b/frontend/src/lib/birth-time-journey-dynamic-case.ts @@ -53,8 +53,6 @@ export async function saveDynamicAssessment( p_private_state: initial.privateState, p_profile: { reportedBirthTime: details.reportedTime, - activeBirthTime: value.snapshot.activeTime, - birthTime: value.snapshot.activeTime, birthTimeSource: value.assessment.source, birthTimePeriod: details.period, birthTimeClue: details.clue, diff --git a/frontend/src/lib/birth-time-journey-dynamic-persistence.ts b/frontend/src/lib/birth-time-journey-dynamic-persistence.ts index b573a36f..66b7a4d7 100644 --- a/frontend/src/lib/birth-time-journey-dynamic-persistence.ts +++ b/frontend/src/lib/birth-time-journey-dynamic-persistence.ts @@ -6,6 +6,7 @@ import { import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts"; import type { DynamicStoredRectificationCase, + LegacyStoredRectificationCase, StoredRectificationCase, } from "./birth-time-journey-service.ts"; import { @@ -119,9 +120,8 @@ export function createDynamicTurnPersistence( }, async upgradeLegacyActiveCase( - value: StoredRectificationCase, + value: LegacyStoredRectificationCase, ): Promise { - 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", { diff --git a/frontend/src/lib/birth-time-journey-dynamic-state.ts b/frontend/src/lib/birth-time-journey-dynamic-state.ts index 6796da67..0f07687a 100644 --- a/frontend/src/lib/birth-time-journey-dynamic-state.ts +++ b/frontend/src/lib/birth-time-journey-dynamic-state.ts @@ -221,6 +221,7 @@ export function prepareLegacyDynamicUpgrade( journeyProtocol: "dynamic-choice-v2", turnVersion: value.turnVersion ?? 0, processedActionIds: value.processedActionIds ?? [], + persistedProgress: value.persistedProgress ?? { adaptiveRound: 0, askedDomains: [] }, turnState: null, evidenceDraft: null, dynamicTurnState, diff --git a/frontend/src/lib/birth-time-journey-response.ts b/frontend/src/lib/birth-time-journey-response.ts index e05ab300..cda4bece 100644 --- a/frontend/src/lib/birth-time-journey-response.ts +++ b/frontend/src/lib/birth-time-journey-response.ts @@ -4,6 +4,8 @@ import { } from "./birth-time-journey-turn.ts"; import type { PersistedJourneyProgress } from "./birth-time-journey-turn-persistence.ts"; import type { + DynamicStoredRectificationCase, + DynamicVersionedJourneyResponse, JourneyResponseBase, StoredRectificationCase, VersionedJourneyResponse, @@ -132,6 +134,22 @@ export function storedJourneyResponse( }; } +export function storedDynamicJourneyResponse( + stored: DynamicStoredRectificationCase, +): DynamicVersionedJourneyResponse { + return { + caseId: stored.id, + snapshot: stored.snapshot, + questionnaire: stored.questionnaire, + scoring: stored.scoring ?? null, + answers: stored.answers, + lifeEvents: stored.lifeEvents ?? [], + candidateResult: stored.candidateResult ?? null, + evidenceDraft: null, + ...stored.dynamicTurnState, + }; +} + export function persistedJourneyResponse( stored: StoredRectificationCase, ): VersionedJourneyResponse { diff --git a/frontend/src/lib/birth-time-journey-service.ts b/frontend/src/lib/birth-time-journey-service.ts index f2aa9bf2..6f3aaa18 100644 --- a/frontend/src/lib/birth-time-journey-service.ts +++ b/frontend/src/lib/birth-time-journey-service.ts @@ -1,33 +1,21 @@ import { assessBirthTime, withRectificationScoring, type BirthTimeAssessment, type JourneySnapshot, type RectificationScoring } from "./birth-time-journey.ts"; import { createJourneyTurnActions } from "./birth-time-journey-actions.ts"; -import { - assertLegacyJourneyMutation, - createBirthTimeEvidenceActions, -} from "./birth-time-evidence-service.ts"; +import { assertLegacyJourneyMutation, createBirthTimeEvidenceActions } from "./birth-time-evidence-service.ts"; import type { CandidateResult, LifeEvent } from "./birth-time-evidence.ts"; import type { CandidateVargaSample } from "./birth-time-question-planner.ts"; -import { projectJourneyResponse, storedJourneyResponse } from "./birth-time-journey-response.ts"; +import { projectJourneyResponse, storedDynamicJourneyResponse, storedJourneyResponse } from "./birth-time-journey-response.ts"; import type { JourneyTurnState } from "./birth-time-journey-turn.ts"; +import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts"; import type { ScoringJobClaim, ScoringJobIdentity, ScoringJobSpec } from "./birth-time-scoring-job.ts"; import { createBirthTimeScoringService } from "./birth-time-scoring-service.ts"; import { scanAssessment } from "./birth-time-journey-assessment.ts"; import type { GuidedCandidateCommit } from "./birth-time-guided-candidate.ts"; import { createGuidedCandidateActions } from "./birth-time-guided-candidate.ts"; import { createGuidedDraftRevisionActions } from "./birth-time-guided-draft-revision.ts"; -import type { - CandidateDifferenceBuild, - DynamicChoiceScoringResult, - ServerChoiceEvidence, -} from "./birth-time-dynamic-choice-internal.ts"; +import type { CandidateDifferenceBuild, DynamicChoiceScoringResult, ServerChoiceEvidence } from "./birth-time-dynamic-choice-internal.ts"; import type { TimeRange } from "./birth-time-dynamic-choice.ts"; -import type { - DynamicStoredFields, - LegacyStoredFields, -} from "./birth-time-journey-stored-protocol.ts"; -import { - RectificationCaseNotFoundError, - RectificationQuestionsUnavailableError, -} from "./birth-time-journey-errors.ts"; +import type { DynamicStoredFields, LegacyStoredFields } from "./birth-time-journey-stored-protocol.ts"; +import { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError } from "./birth-time-journey-errors.ts"; export { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError }; @@ -152,18 +140,18 @@ export type StoredRectificationCase = export interface BirthTimeJourneyStore { saveAssessment(value: PersistedJourneyAssessment): Promise; loadCase(userId: string, caseId: string): Promise; - saveScoring(value: StoredRectificationCase): Promise; - saveTurn(value: StoredRectificationCase, expectedVersion: number, actionId: string): Promise; + saveScoring(value: LegacyStoredRectificationCase): Promise; + saveTurn(value: LegacyStoredRectificationCase, 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; + upgradeLegacyActiveCase(value: LegacyStoredRectificationCase): Promise; + createScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string, job: ScoringJobSpec): Promise; claimScoringJob(identity: ScoringJobIdentity): Promise; - completeScoringJob(value: StoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string): Promise; - failScoringJob(value: StoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string, failureCode: string): Promise; - saveCandidateResult(value: StoredRectificationCase): Promise; - saveCandidate(value: StoredRectificationCase): Promise; - confirmCandidate(value: StoredRectificationCase): Promise; - commitGuidedCandidate(value: StoredRectificationCase, command: GuidedCandidateCommit): Promise; + completeScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string): Promise; + failScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string, failureCode: string): Promise; + saveCandidateResult(value: LegacyStoredRectificationCase): Promise; + saveCandidate(value: LegacyStoredRectificationCase): Promise; + confirmCandidate(value: LegacyStoredRectificationCase): Promise; + commitGuidedCandidate(value: LegacyStoredRectificationCase, command: GuidedCandidateCommit): Promise; } export type BirthTimeJourneyPorts = { @@ -182,11 +170,15 @@ export type JourneyResponseBase = { readonly candidateResult: CandidateResult | null; }; -export type VersionedJourneyResponse = JourneyResponseBase & JourneyTurnState; +export type VersionedJourneyResponse = JourneyResponseBase & JourneyTurnState + & { readonly journeyProtocol?: undefined }; + +export type DynamicVersionedJourneyResponse = JourneyResponseBase & DynamicJourneyTurnState + & { readonly evidenceDraft: null }; export type LegacyJourneyResponse = JourneyResponseBase & { readonly turnVersion?: undefined; readonly nextAction?: undefined; readonly progress?: undefined; readonly permissions?: undefined; readonly evidenceDraft?: undefined; }; -export type JourneyResponse = LegacyJourneyResponse | VersionedJourneyResponse; +export type JourneyResponse = LegacyJourneyResponse | VersionedJourneyResponse | DynamicVersionedJourneyResponse; export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) { const evidenceActions = createBirthTimeEvidenceActions(ports); @@ -217,9 +209,12 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) { }, 0); }, - async resume(userId: string, caseId: string): Promise { + async resume(userId: string, caseId: string): Promise { const stored = await ports.store.loadCase(userId, caseId); if (!stored) throw new RectificationCaseNotFoundError(caseId); + if (stored.journeyProtocol === "dynamic-choice-v2") { + return storedDynamicJourneyResponse(stored); + } const completedLegacyQuestionnaire = stored.snapshot.input === "rectification_questions" && stored.scoring?.nextRound === null && stored.scoring.nextRoundQuestions.length === 0 @@ -245,7 +240,7 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) { const answers = { ...stored.answers, [questionId]: answer }; const scoring = await ports.engine.score({ questionnaire: stored.questionnaire, answers }); const snapshot = withRectificationScoring(stored.snapshot, scoring); - const updated = { ...stored, answers, scoring, snapshot } satisfies StoredRectificationCase; + const updated = { ...stored, answers, scoring, snapshot } satisfies LegacyStoredRectificationCase; await ports.store.saveScoring(updated); return projectJourneyResponse({ caseId, diff --git a/frontend/src/lib/birth-time-journey-store.ts b/frontend/src/lib/birth-time-journey-store.ts index 042090ef..d4020351 100644 --- a/frontend/src/lib/birth-time-journey-store.ts +++ b/frontend/src/lib/birth-time-journey-store.ts @@ -54,7 +54,7 @@ export function createSupabaseBirthTimeJourneyStore( ...guidedCandidates, async saveScoring(value) { - const { error } = await supabase + const { data, error } = await supabase .from("birth_time_rectification_cases") .update({ status: caseStatus(value.snapshot), @@ -64,8 +64,11 @@ export function createSupabaseBirthTimeJourneyStore( updated_at: new Date().toISOString(), }) .eq("id", value.id) - .eq("user_id", value.userId); - if (error) throw new BirthTimeJourneyStoreError("update_case"); + .eq("user_id", value.userId) + .eq("journey_protocol", "legacy-guided-v1") + .select("id") + .maybeSingle(); + if (error || !data) throw new BirthTimeJourneyStoreError("update_case"); const { error: profileError } = await supabase .from("profiles") @@ -77,7 +80,7 @@ export function createSupabaseBirthTimeJourneyStore( async saveCandidateResult(value) { const winner = value.candidateResult?.winningSegment ?? null; - const { error } = await supabase + const { data, error } = await supabase .from("birth_time_rectification_cases") .update({ status: caseStatus(value.snapshot), @@ -91,8 +94,11 @@ export function createSupabaseBirthTimeJourneyStore( updated_at: new Date().toISOString(), }) .eq("id", value.id) - .eq("user_id", value.userId); - if (error) throw new BirthTimeJourneyStoreError("update_case"); + .eq("user_id", value.userId) + .eq("journey_protocol", "legacy-guided-v1") + .select("id") + .maybeSingle(); + if (error || !data) throw new BirthTimeJourneyStoreError("update_case"); const { error: profileError } = await supabase .from("profiles") @@ -106,13 +112,16 @@ export function createSupabaseBirthTimeJourneyStore( }, async saveCandidate(value) { - const { error } = await supabase + const { data, error } = await supabase .from("birth_time_rectification_cases") .update({ candidate_saved_at: new Date().toISOString(), updated_at: new Date().toISOString() }) .eq("id", value.id) .eq("user_id", value.userId) - .eq("candidate_result_id", value.candidateResult?.resultId ?? null); - if (error) throw new BirthTimeJourneyStoreError("update_case"); + .eq("journey_protocol", "legacy-guided-v1") + .eq("candidate_result_id", value.candidateResult?.resultId ?? null) + .select("id") + .maybeSingle(); + if (error || !data) throw new BirthTimeJourneyStoreError("update_case"); }, async confirmCandidate(value) { diff --git a/frontend/src/lib/birth-time-journey-stored-protocol.ts b/frontend/src/lib/birth-time-journey-stored-protocol.ts index 2306a8c4..9133166e 100644 --- a/frontend/src/lib/birth-time-journey-stored-protocol.ts +++ b/frontend/src/lib/birth-time-journey-stored-protocol.ts @@ -14,7 +14,7 @@ type LegacyProgress = { }; export type LegacyStoredFields = { - readonly journeyProtocol?: "legacy-guided-v1"; + readonly journeyProtocol: "legacy-guided-v1"; readonly turnVersion?: number; readonly turnState?: JourneyTurnState | null; readonly evidenceDraft?: EvidenceDraft | null; @@ -32,11 +32,11 @@ export type LegacyStoredFields = { export type DynamicStoredFields = { readonly journeyProtocol: "dynamic-choice-v2"; readonly turnVersion: number; - readonly turnState?: null; + readonly turnState: null; readonly dynamicTurnState: DynamicJourneyTurnState; - readonly evidenceDraft?: null; + readonly evidenceDraft: null; readonly processedActionIds: readonly string[]; - readonly persistedProgress?: LegacyProgress; + readonly persistedProgress: LegacyProgress; readonly candidateModel: Readonly> | null; readonly currentChoiceQuestion: PersistedDynamicChoiceQuestion | null; readonly choiceAnswers: readonly StoredChoiceAnswer[]; diff --git a/frontend/src/lib/birth-time-journey-turn-persistence.ts b/frontend/src/lib/birth-time-journey-turn-persistence.ts index ddc429b7..3ca50096 100644 --- a/frontend/src/lib/birth-time-journey-turn-persistence.ts +++ b/frontend/src/lib/birth-time-journey-turn-persistence.ts @@ -1,7 +1,7 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { z } from "zod"; import type { JourneySnapshot } from "./birth-time-journey.ts"; -import type { StoredRectificationCase } from "./birth-time-journey-service.ts"; +import type { LegacyStoredRectificationCase, StoredRectificationCase } from "./birth-time-journey-service.ts"; import { evidenceDomains } from "./birth-time-question-planner.ts"; import type { EvidenceDomain } from "./birth-time-question-planner.ts"; import type { EvidenceDraft, JourneyTurnState } from "./birth-time-journey-turn.ts"; @@ -61,7 +61,7 @@ export function createJourneyTurnPersistence( ) { return { async saveTurn( - value: StoredRectificationCase, + value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string, ): Promise { @@ -87,6 +87,7 @@ export function createJourneyTurnPersistence( }) .eq("id", value.id) .eq("user_id", value.userId) + .eq("journey_protocol", "legacy-guided-v1") .eq("turn_version", expectedVersion) .not("processed_action_ids", "cs", `{${parsedActionId}}`) .select("id") @@ -104,7 +105,10 @@ export function createJourneyTurnPersistence( } satisfies StoredRectificationCase; } const current = await loadCase(value.userId, value.id); - if (current?.processedActionIds?.includes(parsedActionId)) return current; + if ( + current?.journeyProtocol === "legacy-guided-v1" + && current.processedActionIds?.includes(parsedActionId) + ) return current; throw new StaleJourneyTurnError(value.id, expectedVersion, current?.turnVersion ?? 0); }, }; diff --git a/frontend/src/lib/birth-time-scoring-job-persistence.ts b/frontend/src/lib/birth-time-scoring-job-persistence.ts index 03ff216d..6c211c60 100644 --- a/frontend/src/lib/birth-time-scoring-job-persistence.ts +++ b/frontend/src/lib/birth-time-scoring-job-persistence.ts @@ -1,6 +1,7 @@ import { createScoringJobSpec } from "./birth-time-scoring-job.ts"; import type { BirthTimeJourneyStore, + LegacyStoredRectificationCase, StoredRectificationCase, } from "./birth-time-journey-service.ts"; @@ -11,7 +12,7 @@ export type JourneyTurnPersistencePorts = { export async function persistGuidedJourneyTurn( ports: JourneyTurnPersistencePorts, - value: StoredRectificationCase, + value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string, ): Promise { diff --git a/frontend/src/lib/birth-time-scoring-job-store.ts b/frontend/src/lib/birth-time-scoring-job-store.ts index ad79e33e..8fb36d79 100644 --- a/frontend/src/lib/birth-time-scoring-job-store.ts +++ b/frontend/src/lib/birth-time-scoring-job-store.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { BirthTimeScoringJobError } from "./birth-time-scoring-job.ts"; import type { BirthTimeJourneyStore, + LegacyStoredRectificationCase, StoredRectificationCase, } from "./birth-time-journey-service.ts"; import { BirthTimeJourneyStoreError } from "./birth-time-journey-turn-persistence.ts"; @@ -48,9 +49,11 @@ async function requireReloaded( loadCase: (userId: string, caseId: string) => Promise, userId: string, caseId: string, -): Promise { +): Promise { const stored = await loadCase(userId, caseId); - if (!stored) throw new BirthTimeJourneyStoreError("load_case"); + if (!stored || stored.journeyProtocol !== "legacy-guided-v1") { + throw new BirthTimeJourneyStoreError("load_case"); + } return stored; } @@ -78,7 +81,10 @@ export function createSupabaseScoringJobStore( }); if (error) { const current = await loadCase(value.userId, value.id); - if (current?.processedActionIds?.includes(actionId.toLowerCase())) return current; + if ( + current?.journeyProtocol === "legacy-guided-v1" + && current.processedActionIds?.includes(actionId.toLowerCase()) + ) return current; throw mapScoringRpcError(error.message, error.code); } return requireReloaded(loadCase, value.userId, value.id); diff --git a/frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql b/frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql index 94c49b10..ab1a466f 100644 --- a/frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql +++ b/frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql @@ -142,8 +142,6 @@ begin update public.profiles set reported_birth_time = nullif(p_profile ->> 'reportedBirthTime', '')::time, - active_birth_time = nullif(p_profile ->> 'activeBirthTime', '')::time, - birth_time = nullif(p_profile ->> 'birthTime', '')::time, birth_time_source = p_profile ->> 'birthTimeSource', birth_time_period = nullif(p_profile ->> 'birthTimePeriod', ''), birth_time_clue = nullif(p_profile ->> 'birthTimeClue', ''), diff --git a/frontend/supabase/migrations/20260718092000_legacy_scoring_protocol_guards.sql b/frontend/supabase/migrations/20260718092000_legacy_scoring_protocol_guards.sql new file mode 100644 index 00000000..9f6315d6 --- /dev/null +++ b/frontend/supabase/migrations/20260718092000_legacy_scoring_protocol_guards.sql @@ -0,0 +1,107 @@ +begin; + +alter function public.create_birth_time_scoring_job( + uuid, uuid, uuid, bigint, uuid, text, text, timestamptz, + jsonb, jsonb, jsonb, integer, text[] +) rename to create_birth_time_scoring_job_without_protocol_guard; +alter function public.claim_birth_time_scoring_job( + uuid, uuid, uuid, text, text, timestamptz +) rename to claim_birth_time_scoring_job_without_protocol_guard; +alter function public.complete_birth_time_scoring_job( + uuid, uuid, uuid, bigint, text, jsonb, jsonb, jsonb, + time without time zone, time without time zone, integer, text[] +) rename to complete_birth_time_scoring_job_without_protocol_guard; +alter function public.fail_birth_time_scoring_job( + uuid, uuid, uuid, bigint, text, text, jsonb +) rename to fail_birth_time_scoring_job_without_protocol_guard; + +create function public.create_birth_time_scoring_job( + p_user_id uuid, p_case_id uuid, p_job_id uuid, + p_expected_version bigint, p_action_id uuid, + p_evidence_fingerprint text, p_algorithm_version text, + p_expires_at timestamptz, p_snapshot jsonb, p_turn_state jsonb, + p_life_events jsonb, p_adaptive_round integer, p_asked_domains text[] +) returns uuid language plpgsql security definer set search_path = '' as $$ +begin + perform 1 from public.birth_time_rectification_cases + where id = p_case_id and user_id = p_user_id + and journey_protocol = 'legacy-guided-v1' for update; + if not found then raise exception 'birth_time_legacy_protocol_required'; end if; + return public.create_birth_time_scoring_job_without_protocol_guard( + p_user_id, p_case_id, p_job_id, p_expected_version, p_action_id, + p_evidence_fingerprint, p_algorithm_version, p_expires_at, + p_snapshot, p_turn_state, p_life_events, p_adaptive_round, p_asked_domains + ); +end; +$$; + +create function public.claim_birth_time_scoring_job( + p_user_id uuid, p_case_id uuid, p_job_id uuid, + p_evidence_fingerprint text, p_algorithm_version text, p_now timestamptz +) returns table (claim_state text, algorithm_version text) +language plpgsql security definer set search_path = '' as $$ +begin + perform 1 from public.birth_time_rectification_cases + where id = p_case_id and user_id = p_user_id + and journey_protocol = 'legacy-guided-v1' for update; + if not found then raise exception 'birth_time_legacy_protocol_required'; end if; + return query select * + from public.claim_birth_time_scoring_job_without_protocol_guard( + p_user_id, p_case_id, p_job_id, p_evidence_fingerprint, + p_algorithm_version, p_now + ); +end; +$$; + +create function public.complete_birth_time_scoring_job( + p_user_id uuid, p_case_id uuid, p_job_id uuid, + p_expected_version bigint, p_evidence_fingerprint text, + p_snapshot jsonb, p_turn_state jsonb, p_candidate_result jsonb, + p_candidate_start time without time zone, + p_candidate_end time without time zone, + p_adaptive_round integer, p_asked_domains text[] +) returns void language plpgsql security definer set search_path = '' as $$ +begin + perform 1 from public.birth_time_rectification_cases + where id = p_case_id and user_id = p_user_id + and journey_protocol = 'legacy-guided-v1' for update; + if not found then raise exception 'birth_time_legacy_protocol_required'; end if; + perform public.complete_birth_time_scoring_job_without_protocol_guard( + p_user_id, p_case_id, p_job_id, p_expected_version, + p_evidence_fingerprint, p_snapshot, p_turn_state, p_candidate_result, + p_candidate_start, p_candidate_end, p_adaptive_round, p_asked_domains + ); +end; +$$; + +create function public.fail_birth_time_scoring_job( + p_user_id uuid, p_case_id uuid, p_job_id uuid, + p_expected_version bigint, p_evidence_fingerprint text, + p_failure_code text, p_turn_state jsonb +) returns void language plpgsql security definer set search_path = '' as $$ +begin + perform 1 from public.birth_time_rectification_cases + where id = p_case_id and user_id = p_user_id + and journey_protocol = 'legacy-guided-v1' for update; + if not found then raise exception 'birth_time_legacy_protocol_required'; end if; + perform public.fail_birth_time_scoring_job_without_protocol_guard( + p_user_id, p_case_id, p_job_id, p_expected_version, + p_evidence_fingerprint, p_failure_code, p_turn_state + ); +end; +$$; + +revoke all on function public.create_birth_time_scoring_job_without_protocol_guard(uuid, uuid, uuid, bigint, uuid, text, text, timestamptz, jsonb, jsonb, jsonb, integer, text[]) from public, anon, authenticated, service_role; +revoke all on function public.claim_birth_time_scoring_job_without_protocol_guard(uuid, uuid, uuid, text, text, timestamptz) from public, anon, authenticated, service_role; +revoke all on function public.complete_birth_time_scoring_job_without_protocol_guard(uuid, uuid, uuid, bigint, text, jsonb, jsonb, jsonb, time without time zone, time without time zone, integer, text[]) from public, anon, authenticated, service_role; +revoke all on function public.fail_birth_time_scoring_job_without_protocol_guard(uuid, uuid, uuid, bigint, text, text, jsonb) from public, anon, authenticated, service_role; +revoke all on function public.create_birth_time_scoring_job(uuid, uuid, uuid, bigint, uuid, text, text, timestamptz, jsonb, jsonb, jsonb, integer, text[]) from public, anon, authenticated; +revoke all on function public.claim_birth_time_scoring_job(uuid, uuid, uuid, text, text, timestamptz) from public, anon, authenticated; +revoke all on function public.complete_birth_time_scoring_job(uuid, uuid, uuid, bigint, text, jsonb, jsonb, jsonb, time without time zone, time without time zone, integer, text[]) from public, anon, authenticated; +revoke all on function public.fail_birth_time_scoring_job(uuid, uuid, uuid, bigint, text, text, jsonb) from public, anon, authenticated; +grant execute on function public.create_birth_time_scoring_job(uuid, uuid, uuid, bigint, uuid, text, text, timestamptz, jsonb, jsonb, jsonb, integer, text[]) to service_role; +grant execute on function public.claim_birth_time_scoring_job(uuid, uuid, uuid, text, text, timestamptz) to service_role; +grant execute on function public.complete_birth_time_scoring_job(uuid, uuid, uuid, bigint, text, jsonb, jsonb, jsonb, time without time zone, time without time zone, integer, text[]) to service_role; +grant execute on function public.fail_birth_time_scoring_job(uuid, uuid, uuid, bigint, text, text, jsonb) to service_role; + +commit; diff --git a/frontend/supabase/migrations/20260718093000_legacy_candidate_protocol_guards.sql b/frontend/supabase/migrations/20260718093000_legacy_candidate_protocol_guards.sql new file mode 100644 index 00000000..f4f565f9 --- /dev/null +++ b/frontend/supabase/migrations/20260718093000_legacy_candidate_protocol_guards.sql @@ -0,0 +1,71 @@ +begin; + +alter function public.confirm_birth_time_candidate( + uuid, uuid, uuid, time without time zone, jsonb +) rename to confirm_birth_time_candidate_without_protocol_guard; +alter function public.save_guided_birth_time_candidate( + uuid, uuid, uuid, uuid, integer, jsonb +) rename to save_guided_birth_time_candidate_without_protocol_guard; +alter function public.confirm_guided_birth_time_candidate( + uuid, uuid, uuid, time without time zone, uuid, integer, jsonb, jsonb +) rename to confirm_guided_birth_time_candidate_without_protocol_guard; + +create function public.confirm_birth_time_candidate( + p_user_id uuid, p_case_id uuid, p_result_id uuid, + p_time time without time zone, p_snapshot jsonb +) returns void language plpgsql security definer set search_path = '' as $$ +begin + perform 1 from public.birth_time_rectification_cases + where id = p_case_id and user_id = p_user_id + and journey_protocol = 'legacy-guided-v1' for update; + if not found then raise exception 'birth_time_legacy_protocol_required'; end if; + perform public.confirm_birth_time_candidate_without_protocol_guard( + p_user_id, p_case_id, p_result_id, p_time, p_snapshot + ); +end; +$$; + +create function public.save_guided_birth_time_candidate( + p_user_id uuid, p_case_id uuid, p_result_id uuid, + p_action_id uuid, p_expected_version integer, p_turn_state jsonb +) returns void language plpgsql security definer set search_path = '' as $$ +begin + perform 1 from public.birth_time_rectification_cases + where id = p_case_id and user_id = p_user_id + and journey_protocol = 'legacy-guided-v1' for update; + if not found then raise exception 'birth_time_legacy_protocol_required'; end if; + perform public.save_guided_birth_time_candidate_without_protocol_guard( + p_user_id, p_case_id, p_result_id, p_action_id, + p_expected_version, p_turn_state + ); +end; +$$; + +create function public.confirm_guided_birth_time_candidate( + p_user_id uuid, p_case_id uuid, p_result_id uuid, + p_time time without time zone, p_action_id uuid, + p_expected_version integer, p_snapshot jsonb, p_turn_state jsonb +) returns void language plpgsql security definer set search_path = '' as $$ +begin + perform 1 from public.birth_time_rectification_cases + where id = p_case_id and user_id = p_user_id + and journey_protocol = 'legacy-guided-v1' for update; + if not found then raise exception 'birth_time_legacy_protocol_required'; end if; + perform public.confirm_guided_birth_time_candidate_without_protocol_guard( + p_user_id, p_case_id, p_result_id, p_time, p_action_id, + p_expected_version, p_snapshot, p_turn_state + ); +end; +$$; + +revoke all on function public.confirm_birth_time_candidate_without_protocol_guard(uuid, uuid, uuid, time without time zone, jsonb) from public, anon, authenticated, service_role; +revoke all on function public.save_guided_birth_time_candidate_without_protocol_guard(uuid, uuid, uuid, uuid, integer, jsonb) from public, anon, authenticated, service_role; +revoke all on function public.confirm_guided_birth_time_candidate_without_protocol_guard(uuid, uuid, uuid, time without time zone, uuid, integer, jsonb, jsonb) from public, anon, authenticated, service_role; +revoke all on function public.confirm_birth_time_candidate(uuid, uuid, uuid, time without time zone, jsonb) from public, anon, authenticated; +revoke all on function public.save_guided_birth_time_candidate(uuid, uuid, uuid, uuid, integer, jsonb) from public, anon, authenticated; +revoke all on function public.confirm_guided_birth_time_candidate(uuid, uuid, uuid, time without time zone, uuid, integer, jsonb, jsonb) from public, anon, authenticated; +grant execute on function public.confirm_birth_time_candidate(uuid, uuid, uuid, time without time zone, jsonb) to service_role; +grant execute on function public.save_guided_birth_time_candidate(uuid, uuid, uuid, uuid, integer, jsonb) to service_role; +grant execute on function public.confirm_guided_birth_time_candidate(uuid, uuid, uuid, time without time zone, uuid, integer, jsonb, jsonb) to service_role; + +commit; diff --git a/frontend/tests/birth-time-agent-flow-test-support.ts b/frontend/tests/birth-time-agent-flow-test-support.ts index 005446e2..cf611a22 100644 --- a/frontend/tests/birth-time-agent-flow-test-support.ts +++ b/frontend/tests/birth-time-agent-flow-test-support.ts @@ -7,6 +7,7 @@ import { createGuidedCandidateActions } from "../src/lib/birth-time-guided-candi import { createBirthTimeJourneyService, type LegacyBirthTimeJourneyEngine, + type DynamicVersionedJourneyResponse, type StoredRectificationCase, type VersionedJourneyResponse, } from "../src/lib/birth-time-journey-service.ts"; @@ -107,7 +108,12 @@ export function createHarness(input: { }; } -export function assertLegalTurn(turn: VersionedJourneyResponse): void { +export function assertLegalTurn( + turn: VersionedJourneyResponse | DynamicVersionedJourneyResponse, +): asserts turn is VersionedJourneyResponse { + if (turn.journeyProtocol === "dynamic-choice-v2") { + assert.fail("legacy flow unexpectedly resumed a dynamic journey"); + } assert.ok(turn.nextAction, "every legal journey response has nextAction"); assert.ok(turn.turnVersion >= 0); switch (turn.nextAction.kind) { diff --git a/frontend/tests/birth-time-dynamic-persistence-fixture.ts b/frontend/tests/birth-time-dynamic-persistence-fixture.ts index c91587fc..eaf9d256 100644 --- a/frontend/tests/birth-time-dynamic-persistence-fixture.ts +++ b/frontend/tests/birth-time-dynamic-persistence-fixture.ts @@ -142,13 +142,16 @@ export const privateRow = { export function loadClient( privateState: typeof privateRow | null, omitProcessedActions = false, + publicTurnVersion = publicRow.turn_version, ) { const { processed_action_ids: ignoredReceipts, ...rowWithoutReceipts } = publicRow; void ignoredReceipts; return { from(table: string) { const row = table === "birth_time_rectification_cases" - ? omitProcessedActions ? rowWithoutReceipts : publicRow + ? omitProcessedActions + ? rowWithoutReceipts + : { ...publicRow, turn_version: publicTurnVersion } : table === "birth_time_rectification_dynamic_state" ? privateState : { latitude: 31.2304, longitude: 121.4737, timezone_offset: 8 }; @@ -173,8 +176,11 @@ export function dynamicCase(): DynamicStoredRectificationCase { lifeEvents: [], candidateResult: null, turnVersion: 7, + turnState: null, dynamicTurnState, + evidenceDraft: null, processedActionIds: [], + persistedProgress: { adaptiveRound: 0, askedDomains: [] }, candidateModel: privateRow.candidate_model, currentChoiceQuestion: persistedQuestion, choiceAnswers: [], @@ -215,6 +221,7 @@ export function legacyCase(active: boolean): LegacyStoredRectificationCase { return { id: caseId, userId: ownerId, + journeyProtocol: "legacy-guided-v1", snapshot, questionnaire: null, answers: { q1: "A" }, diff --git a/frontend/tests/birth-time-dynamic-persistence.test.ts b/frontend/tests/birth-time-dynamic-persistence.test.ts index 76eb1224..e52f5d40 100644 --- a/frontend/tests/birth-time-dynamic-persistence.test.ts +++ b/frontend/tests/birth-time-dynamic-persistence.test.ts @@ -75,6 +75,19 @@ test("unknown birth time initializes the full-day dynamic range", () => { ]); }); +test("mixed-null reported ranges fail instead of inventing one boundary", () => { + const mixedRange = { + ...snapshot, + reportedRange: { + label: "04:00—未知", + startTime: "04:00", + endTime: null, + }, + }; + + assert.throws(() => createInitialDynamicState(mixedRange, "2026-07-18")); +}); + test("new v2 case creation crosses one atomic RPC boundary", async () => { const calls: { readonly name: string; readonly args: Readonly> }[] = []; const savedId = await saveDynamicAssessment({ @@ -100,6 +113,15 @@ test("new v2 case creation crosses one atomic RPC boundary", async () => { assert.equal(calls[0]?.name, "create_birth_time_dynamic_case"); assert.equal(calls[0]?.args.p_user_id, ownerId); assert.equal(JSON.stringify(calls[0]?.args.p_public_case).includes("candidateModel"), false); + assert.deepEqual(calls[0]?.args.p_profile, { + reportedBirthTime: null, + birthTimeSource: "period_only", + birthTimePeriod: "early_morning", + birthTimeClue: null, + uncertaintyBeforeMinutes: null, + uncertaintyAfterMinutes: null, + birthTimeStatus: "rectifying", + }); assert.deepEqual(calls[0]?.args.p_private_state, createInitialDynamicState( snapshot, "2026-07-18", @@ -120,6 +142,13 @@ test("v2 load rejects missing required public persistence fields", async () => { ); }); +test("v2 load rejects incoherent public row and JSON turn versions", async () => { + await assert.rejects( + loadStoredRectificationCase(loadClient(privateRow, false, 8), ownerId, caseId), + BirthTimeJourneyStoreError, + ); +}); + test("v2 cases cannot fall through to legacy mutation paths", () => { assert.throws(() => assertLegacyJourneyMutation(dynamicCase()), { name: "GuidedJourneyLegacyMutationError", @@ -166,11 +195,12 @@ test("memory replay returns the stored advanced dynamic turn", async () => { const changed = { ...initial, agentContext: ["persisted context"] }; const saved = await memory.store.saveDynamicTurn(changed, 7, actionId); - const replay = await memory.store.saveDynamicTurn(initial, 7, actionId); + const replay = await memory.store.saveDynamicTurn(initial, 7, actionId.toUpperCase()); assert.deepEqual(replay, saved); assert.equal(replay.turnVersion, 8); assert.deepEqual(replay.agentContext, ["persisted context"]); + assert.deepEqual(replay.processedActionIds, [actionId]); assert.equal(memory.committedTurnWrites(), 1); }); @@ -203,6 +233,7 @@ test("legacy active upgrade preserves evidence and starts v2 without legacy fing journeyProtocol: "dynamic-choice-v2", turnVersion: loaded.turnVersion ?? 0, processedActionIds: loaded.processedActionIds ?? [], + persistedProgress: loaded.persistedProgress ?? { adaptiveRound: 0, askedDomains: [] }, dynamicTurnState: parsedTurn, turnState: null, evidenceDraft: null, diff --git a/frontend/tests/birth-time-dynamic-resume.test.ts b/frontend/tests/birth-time-dynamic-resume.test.ts new file mode 100644 index 00000000..c825f7d8 --- /dev/null +++ b/frontend/tests/birth-time-dynamic-resume.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts"; +import { + caseId, + dynamicCase, + ownerId, +} from "./birth-time-dynamic-persistence-fixture.ts"; +import { memoryStore } from "./birth-time-journey-memory-store.ts"; +import { unusedJourneyEngine } from "./birth-time-journey-test-support.ts"; + +test("v2 resume returns the stored dynamic turn without legacy scoring writes", async () => { + const stored = { + ...dynamicCase(), + scoring: { + answeredCount: 5, + candidateClusterRankings: [], + nextRound: null, + nextRoundQuestions: [], + raw: {}, + }, + }; + const memory = memoryStore(stored); + const service = createBirthTimeJourneyService({ store: memory.store, engine: unusedJourneyEngine }); + + const resumed = await service.resume(ownerId, caseId); + + assert.equal(resumed.journeyProtocol, "dynamic-choice-v2"); + assert.deepEqual({ + journeyProtocol: resumed.journeyProtocol, + turnVersion: resumed.turnVersion, + nextAction: resumed.nextAction, + progress: resumed.progress, + permissions: resumed.permissions, + }, stored.dynamicTurnState); + assert.equal(memory.legacyWrites(), 0); +}); diff --git a/frontend/tests/birth-time-guide-route.test.ts b/frontend/tests/birth-time-guide-route.test.ts index d9d769dd..6e29f0a3 100644 --- a/frontend/tests/birth-time-guide-route.test.ts +++ b/frontend/tests/birth-time-guide-route.test.ts @@ -44,6 +44,7 @@ function storedCase(): LegacyStoredRectificationCase { return { id: caseId, userId: "owner-1", + journeyProtocol: "legacy-guided-v1", snapshot: { state: "rectifying", assistantIntent: "continue_rectification_questions", diff --git a/frontend/tests/birth-time-journey-memory-store.ts b/frontend/tests/birth-time-journey-memory-store.ts index 71f5e529..25e60f5c 100644 --- a/frontend/tests/birth-time-journey-memory-store.ts +++ b/frontend/tests/birth-time-journey-memory-store.ts @@ -53,6 +53,9 @@ export function memoryStore( if (!savedCase) { throw new MissingTestCaseError(); } + if (savedCase.journeyProtocol !== "legacy-guided-v1") { + throw new StaleJourneyTurnError(savedCase.id, expectedVersion, savedCase.turnVersion); + } const processedActionIds = savedCase.processedActionIds ?? []; if (processedActionIds.includes(actionId)) { return savedCase; @@ -70,8 +73,9 @@ export function memoryStore( }, async saveDynamicTurn(value, expectedVersion, actionId) { if (!savedCase) throw new MissingTestCaseError(); + const receipt = actionId.toLowerCase(); const receipts = savedCase.processedActionIds ?? []; - if (receipts.includes(actionId)) { + if (receipts.includes(receipt)) { if (!savedDynamicCase) throw new MissingTestCaseError(); return savedDynamicCase; } @@ -82,7 +86,7 @@ export function memoryStore( ...value, turnVersion: expectedVersion + 1, dynamicTurnState: { ...value.dynamicTurnState, turnVersion: expectedVersion + 1 }, - processedActionIds: [...receipts, actionId], + processedActionIds: [...receipts, receipt], }; savedCase = savedDynamic; savedDynamicCase = savedDynamic; @@ -90,7 +94,7 @@ export function memoryStore( return savedDynamic; }, async upgradeLegacyActiveCase(value) { - if (value.journeyProtocol === "dynamic-choice-v2" || isTerminalLegacyCase(value)) { + if (isTerminalLegacyCase(value)) { return value; } const upgraded = prepareLegacyDynamicUpgrade(value, asOfDate); @@ -115,6 +119,13 @@ export function memoryStore( if (!savedCase) { throw new MissingTestCaseError(); } + if (savedCase.journeyProtocol !== "legacy-guided-v1") { + throw new StaleJourneyTurnError( + savedCase.id, + command.expectedVersion, + savedCase.turnVersion, + ); + } const receipt = command.actionId.toLowerCase(); const receipts = savedCase.processedActionIds ?? []; if (receipts.includes(receipt)) { diff --git a/frontend/tests/birth-time-journey-resume.test.ts b/frontend/tests/birth-time-journey-resume.test.ts index 16278589..a2ea7fc8 100644 --- a/frontend/tests/birth-time-journey-resume.test.ts +++ b/frontend/tests/birth-time-journey-resume.test.ts @@ -48,6 +48,7 @@ test("legacy low-score resume displays adaptive round one exactly once", async ( const first = await flow.service.resume("user-1", journeyCaseId); const second = await flow.service.resume("user-1", journeyCaseId); + if (first.journeyProtocol === "dynamic-choice-v2") assert.fail("expected legacy response"); assert.equal(first.nextAction.kind, "ask_adaptive_evidence"); assert.equal(first.progress.adaptiveRound, 1); assert.deepEqual(second.nextAction, first.nextAction); diff --git a/frontend/tests/birth-time-journey-service.test.ts b/frontend/tests/birth-time-journey-service.test.ts index 73ae0b8b..eda33b57 100644 --- a/frontend/tests/birth-time-journey-service.test.ts +++ b/frontend/tests/birth-time-journey-service.test.ts @@ -78,6 +78,7 @@ test("journey service accumulates legacy answers while preserving the applicatio const storedCase: StoredRectificationCase = { id: journeyCaseId, userId: "user-1", + journeyProtocol: "legacy-guided-v1", snapshot: assessed.snapshot, questionnaire, answers: { education_environment_shift: "A" }, @@ -121,6 +122,7 @@ test("journey service resumes an owner-scoped unfinished legacy case", async () const storedCase: StoredRectificationCase = { id: journeyCaseId, userId: "user-1", + journeyProtocol: "legacy-guided-v1", snapshot: { state: "rectifying", assistantIntent: "continue_rectification_questions", @@ -150,6 +152,7 @@ test("journey service heals a completed legacy questionnaire into life-event col const storedCase: StoredRectificationCase = { id: journeyCaseId, userId: "user-1", + journeyProtocol: "legacy-guided-v1", snapshot: { state: "candidate", assistantIntent: "present_saved_candidate_range", @@ -184,6 +187,7 @@ test("journey service resumes a fail-closed case without a questionnaire", async const storedCase: StoredRectificationCase = { id: journeyCaseId, userId: "user-1", + journeyProtocol: "legacy-guided-v1", snapshot: { state: "rectifying", assistantIntent: "explain_assessment_unavailable", diff --git a/frontend/tests/birth-time-journey-test-support.ts b/frontend/tests/birth-time-journey-test-support.ts index 458f1aaa..3ebef5b7 100644 --- a/frontend/tests/birth-time-journey-test-support.ts +++ b/frontend/tests/birth-time-journey-test-support.ts @@ -128,6 +128,7 @@ export function guidedCase(input: GuidedCaseInput = {}): LegacyStoredRectificati return { id: journeyCaseId, userId: "user-1", + journeyProtocol: "legacy-guided-v1", snapshot: { state: "rectifying", assistantIntent: candidateResult?.confidence === "low" diff --git a/frontend/tests/birth-time-journey-turn-persistence.test.mjs b/frontend/tests/birth-time-journey-turn-persistence.test.mjs index 76b0df76..9320a924 100644 --- a/frontend/tests/birth-time-journey-turn-persistence.test.mjs +++ b/frontend/tests/birth-time-journey-turn-persistence.test.mjs @@ -11,6 +11,7 @@ const actionId = "45857b75-4718-4590-aaf5-7113a03ea765"; const storedCase = { id: "case-1", userId: "user-1", + journeyProtocol: "legacy-guided-v1", snapshot: { state: "rectifying" }, answers: {}, turnState: { nextAction: { kind: "paused" } }, @@ -79,6 +80,7 @@ test("saveTurn uses one owner-and-version-constrained update", async () => { }], ["id", "case-1"], ["user_id", "user-1"], + ["journey_protocol", "legacy-guided-v1"], ["turn_version", 4], ["not", "processed_action_ids", "cs", `{${actionId}}`], ["select", "id"], @@ -127,7 +129,25 @@ test("saveTurn treats an uppercase UUID replay as the stored lowercase receipt", assert.equal(saved, current); assert.equal(fake.calls[0][1].processed_action_ids[0], actionId); - assert.deepEqual(fake.calls[4], ["not", "processed_action_ids", "cs", `{${actionId}}`]); + assert.deepEqual(fake.calls[5], ["not", "processed_action_ids", "cs", `{${actionId}}`]); +}); + +test("saveTurn cannot overwrite a case upgraded between load and write", async () => { + const fake = updateClient({ data: null, error: null }); + const upgraded = { + ...storedCase, + journeyProtocol: "dynamic-choice-v2", + turnVersion: 4, + processedActionIds: [], + }; + const persistence = createJourneyTurnPersistence(fake.client, async () => upgraded); + + await assert.rejects( + persistence.saveTurn(storedCase, 4, actionId), + StaleJourneyTurnError, + ); + + assert.deepEqual(fake.calls[3], ["journey_protocol", "legacy-guided-v1"]); }); test("saveTurn rejects a non-UUID action receipt before writing", async () => { @@ -181,6 +201,7 @@ test("load accepts only the exact empty legacy turn state", async () => { const value = await loadStoredRectificationCase(loadClient(storedRow()), "12dc56f0-1f17-4a2f-86bf-1056ab78def9", "45857b75-4718-4590-aaf5-7113a03ea765"); assert.equal(value?.turnState, null); + assert.equal(value?.journeyProtocol, "legacy-guided-v1"); }); test("load rejects a malformed nonempty persisted turn state", async () => { diff --git a/tests/test_birth_time_dynamic_persistence_contract.py b/tests/test_birth_time_dynamic_persistence_contract.py index dd5e3a5a..a924aef1 100644 --- a/tests/test_birth_time_dynamic_persistence_contract.py +++ b/tests/test_birth_time_dynamic_persistence_contract.py @@ -11,6 +11,10 @@ MIGRATION = ( TRANSITIONS_MIGRATION = MIGRATION.with_name( "20260718091000_dynamic_choice_birth_time_transitions.sql" ) +LEGACY_GUARD_MIGRATIONS = ( + MIGRATION.with_name("20260718092000_legacy_scoring_protocol_guards.sql"), + MIGRATION.with_name("20260718093000_legacy_candidate_protocol_guards.sql"), +) def _sql() -> str: @@ -65,6 +69,27 @@ def test_dynamic_case_creation_is_one_service_role_transaction() -> None: assert "grant execute on function public.create_birth_time_dynamic_case" in sql +def test_dynamic_case_creation_updates_only_profile_rectification_metadata() -> None: + body = _function( + _sql(), "create_birth_time_dynamic_case", "save_birth_time_dynamic_turn" + ) + profile_update = body.split("update public.profiles", 1)[1].split( + "where id = p_user_id", 1 + )[0] + assignments = set(re.findall(r"([a-z_]+)\s*=", profile_update)) + assert assignments == { + "reported_birth_time", + "birth_time_source", + "birth_time_period", + "birth_time_clue", + "uncertainty_before_minutes", + "uncertainty_after_minutes", + "birth_time_status", + "rectification_confidence", + "rectification_case_id", + } + + 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") @@ -132,3 +157,37 @@ def test_private_state_upsert_has_one_internal_owner_only_implementation() -> No 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 + + +def test_legacy_rpc_writes_lock_and_reject_upgraded_cases_atomically() -> None: + source = "\n".join( + path.read_text(encoding="utf-8").lower() for path in LEGACY_GUARD_MIGRATIONS + ) + guarded_functions = ( + "create_birth_time_scoring_job", + "claim_birth_time_scoring_job", + "complete_birth_time_scoring_job", + "fail_birth_time_scoring_job", + "confirm_birth_time_candidate", + "save_guided_birth_time_candidate", + "confirm_guided_birth_time_candidate", + ) + for function_name in guarded_functions: + wrapper = source.split(f"create function public.{function_name}(", 1)[1] + wrapper = wrapper.split("$$;", 1)[0] + assert "journey_protocol = 'legacy-guided-v1' for update" in wrapper + assert "birth_time_legacy_protocol_required" in wrapper + assert f"public.{function_name}_without_protocol_guard" in wrapper + assert ( + f"revoke all on function public.{function_name}_without_protocol_guard" + in source + ) + + +def test_new_protocol_guard_migrations_stay_within_review_budget() -> None: + for path in LEGACY_GUARD_MIGRATIONS: + pure_lines = [ + line for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("--") + ] + assert len(pure_lines) <= 250