diff --git a/frontend/src/app/api/birth-time-candidate-completion/route.ts b/frontend/src/app/api/birth-time-candidate-completion/route.ts index d86196bb..4452fe57 100644 --- a/frontend/src/app/api/birth-time-candidate-completion/route.ts +++ b/frontend/src/app/api/birth-time-candidate-completion/route.ts @@ -29,7 +29,7 @@ export async function POST(request: Request) { const admin = createAdminSupabaseClient(); const { data: stored, error: caseError } = await admin .from("birth_time_rectification_cases") - .select("id,user_id,status,candidate_result_id,candidate_result,turn_state") + .select("id,user_id,journey_protocol,status,candidate_result_id,candidate_result,turn_state") .eq("id", parsed.data.caseId) .eq("user_id", user.id) .maybeSingle(); diff --git a/frontend/src/hooks/use-birth-time-guided-journey.ts b/frontend/src/hooks/use-birth-time-guided-journey.ts index 5b4d5384..dd643006 100644 --- a/frontend/src/hooks/use-birth-time-guided-journey.ts +++ b/frontend/src/hooks/use-birth-time-guided-journey.ts @@ -190,6 +190,7 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG const resultId = turn?.candidateResult?.resultId; const winner = turn?.candidateResult?.winningSegment; if (!turn || !resultId || winner?.representativeTime !== time) return; + if (turn.journeyProtocol === "dynamic-choice-v2") return; const release = claimMutation(busy); if (release === null) return; setPending(true); diff --git a/frontend/src/lib/birth-time-candidate-completion.ts b/frontend/src/lib/birth-time-candidate-completion.ts index a018bf2d..c05b974a 100644 --- a/frontend/src/lib/birth-time-candidate-completion.ts +++ b/frontend/src/lib/birth-time-candidate-completion.ts @@ -31,6 +31,7 @@ export function candidateWorkingTime( return assessment?.id === request.caseId && assessment?.user_id === request.userId + && assessment?.journey_protocol === "legacy-guided-v1" && terminalStatusMatches && assessment.candidate_result_id === request.resultId && action?.resultId === request.resultId diff --git a/frontend/src/lib/birth-time-guided-terminal.ts b/frontend/src/lib/birth-time-guided-terminal.ts index bcd2de3f..4063b94e 100644 --- a/frontend/src/lib/birth-time-guided-terminal.ts +++ b/frontend/src/lib/birth-time-guided-terminal.ts @@ -15,15 +15,12 @@ export type GuidedTerminalPath = export function guidedTerminalPath(journey: JourneyClientResponse): GuidedTerminalPath | null { const kind = journey.nextAction.kind; - const winner = journey.candidateResult?.winningSegment; if (journey.journeyProtocol === "dynamic-choice-v2" - && winner && (kind === "present_low_result" || kind === "present_medium_result" || kind === "candidate_saved")) { return { - kind: "complete_with_candidate", - time: winner.representativeTime, + kind: "edit_birth_time_details", preservesCase: true, - appliesCandidateTime: true, + appliesCandidateTime: false, }; } return kind === "present_low_result" || kind === "candidate_saved" diff --git a/frontend/tests/birth-time-candidate-completion.test.ts b/frontend/tests/birth-time-candidate-completion.test.ts index 0e69ecf5..d860f59c 100644 --- a/frontend/tests/birth-time-candidate-completion.test.ts +++ b/frontend/tests/birth-time-candidate-completion.test.ts @@ -5,6 +5,7 @@ import { candidateWorkingTime } from "../src/lib/birth-time-candidate-completion const terminalCase = { id: "5425f9e7-3d45-491d-aab3-24cfd4261d51", user_id: "07e583fc-90b9-4fcb-a9d3-8de654eeac9a", + journey_protocol: "legacy-guided-v1", status: "candidate", candidate_result_id: "d9133ba2-afcf-56da-b40b-ace3d7124a7d", candidate_result: { @@ -95,6 +96,17 @@ test("non-terminal cases cannot be adopted for consultation", () => { }), null); }); +test("legacy candidate completion rejects dynamic-choice-v2 cases", () => { + assert.equal(candidateWorkingTime({ + ...terminalCase, + journey_protocol: "dynamic-choice-v2", + }, completionRequest), null); +}); + +test("legacy candidate completion requires an explicit legacy protocol", () => { + assert.equal(candidateWorkingTime({ ...terminalCase, journey_protocol: undefined }, completionRequest), null); +}); + const rejectedCompletions = [ { name: "case owned by another user", diff --git a/frontend/tests/birth-time-dynamic-candidate-confirmation.test.ts b/frontend/tests/birth-time-dynamic-candidate-confirmation.test.ts index 608055ab..05f8dbba 100644 --- a/frontend/tests/birth-time-dynamic-candidate-confirmation.test.ts +++ b/frontend/tests/birth-time-dynamic-candidate-confirmation.test.ts @@ -119,6 +119,85 @@ test("dynamic confirmation replays only its exact receipt and rejects stale vers }), StaleJourneyTurnError); }); +test("dynamic confirmation rejects every binding mismatch without writing", async () => { + const cases = [ + { + name: "wrong protocol", + current: { ...highConfidenceDynamicCase(), journeyProtocol: "legacy-guided-v1" as const }, + command: {}, + }, + { + name: "wrong current action", + current: { + ...highConfidenceDynamicCase(), + dynamicTurnState: { ...highConfidenceDynamicCase().dynamicTurnState, nextAction: { kind: "present_medium_result" as const, resultId: highCandidate.resultId } }, + }, + command: {}, + }, + { + name: "wrong result", + current: highConfidenceDynamicCase(), + command: { resultId: "a3e41512-9fa0-4866-a187-e3b3aa07aee0" }, + }, + { + name: "wrong representative time", + current: highConfidenceDynamicCase(), + command: { time: "17:14" }, + }, + { + name: "confirmation disabled", + current: { + ...highConfidenceDynamicCase(), + dynamicTurnState: { ...highConfidenceDynamicCase().dynamicTurnState, permissions: { canConfirmCandidate: false } }, + }, + command: {}, + }, + { + name: "stale version", + current: highConfidenceDynamicCase(), + command: { expectedVersion: highConfidenceDynamicCase().turnVersion - 1 }, + }, + ] as const; + + for (const scenario of cases) { + const memory = memoryStore(scenario.current); + const service = createBirthTimeJourneyService({ store: memory.store, engine: { + async scan() { throw new Error("unexpected scan"); }, + async score() { throw new Error("unexpected score"); }, + async scoreEvents() { throw new Error("unexpected event score"); }, + async buildDifferencePacket() { throw new Error("unexpected packet"); }, + async scoreChoices() { throw new Error("unexpected choice score"); }, + } }); + await assert.rejects(service.confirmDynamicCandidate({ + userId: ownerId, + caseId: scenario.current.id, + actionId, + expectedVersion: scenario.current.turnVersion, + resultId: highCandidate.resultId, + time: "17:15", + ...scenario.command, + })); + assert.equal(memory.committedTurnWrites(), 0, scenario.name); + } +}); + +test("dynamic confirmation rejects same-action payload mismatches after a successful receipt", async () => { + const current = highConfidenceDynamicCase(); + const memory = memoryStore(current); + const service = createBirthTimeJourneyService({ store: memory.store, engine: { + async scan() { throw new Error("unexpected scan"); }, + async score() { throw new Error("unexpected score"); }, + async scoreEvents() { throw new Error("unexpected event score"); }, + async buildDifferencePacket() { throw new Error("unexpected packet"); }, + async scoreChoices() { throw new Error("unexpected choice score"); }, + } }); + const command = { userId: ownerId, caseId: current.id, actionId, expectedVersion: current.turnVersion, resultId: highCandidate.resultId, time: "17:15" }; + await service.confirmDynamicCandidate(command); + await assert.rejects(service.confirmDynamicCandidate({ ...command, time: "17:14" }), StaleJourneyTurnError); + await assert.rejects(service.confirmDynamicCandidate({ ...command, resultId: "a3e41512-9fa0-4866-a187-e3b3aa07aee0" }), StaleJourneyTurnError); + assert.equal(memory.committedTurnWrites(), 1); +}); + test("dynamic confirmation rejects a non-representative minute before writing", async () => { const current = highConfidenceDynamicCase(); const memory = memoryStore(current); @@ -169,3 +248,14 @@ test("dynamic confirmation RPC locks the v2 case and is service-role only", () = assert.match(migration, /set active_birth_time = p_time,[\s\S]*birth_time_status = 'confirmed',[\s\S]*rectification_case_id = p_case_id/i); assert.match(migration, /revoke all on function public\.confirm_birth_time_dynamic_candidate\([\s\S]*?from public, anon, authenticated;[\s\S]*?grant execute on function public\.confirm_birth_time_dynamic_candidate\([\s\S]*?to service_role;/i); }); + +test("dynamic confirmation SQL binds replay and mutation to the exact guarded receipt", () => { + assert.match(migration, /security definer\s+set search_path = ''/i); + assert.match(migration, /from public\.birth_time_rectification_cases c[\s\S]*?where c\.id = p_case_id and c\.user_id = p_user_id[\s\S]*?for update/i); + assert.match(migration, /from public\.birth_time_rectification_dynamic_state s[\s\S]*?where s\.case_id = p_case_id and s\.user_id = p_user_id[\s\S]*?for update/i); + assert.match(migration, /v_case\.turn_version is distinct from p_expected_version \+ 1[\s\S]*?dynamic_control -> 'lastActionReceipt' is distinct from v_receipt/i); + assert.match(migration, /v_case\.status is distinct from 'confirming'[\s\S]*?request_candidate_confirmation[\s\S]*?canConfirmCandidate/i); + assert.match(migration, /update public\.birth_time_rectification_cases[\s\S]*?turn_version = p_expected_version \+ 1[\s\S]*?update public\.birth_time_rectification_dynamic_state[\s\S]*?lastActionReceipt[\s\S]*?update public\.profiles/i); + assert.match(migration, /revoke all on function public\.confirm_birth_time_dynamic_candidate\([\s\S]*?from public, anon, authenticated;/i); + assert.match(migration, /grant execute on function public\.confirm_birth_time_dynamic_candidate\([\s\S]*?to service_role;/i); +}); diff --git a/frontend/tests/birth-time-guided-review-fixes.test.ts b/frontend/tests/birth-time-guided-review-fixes.test.ts index 3c950bc6..f0144aa5 100644 --- a/frontend/tests/birth-time-guided-review-fixes.test.ts +++ b/frontend/tests/birth-time-guided-review-fixes.test.ts @@ -56,17 +56,21 @@ test("low without a result and saved medium both return to declared-time editing }); }); -test("dynamic medium terminal completes with its candidate working time", () => { +test("dynamic non-confirmable terminal stays in declared-time editing", () => { const medium = dynamicBirthTimePreview("medium"); assert.deepEqual(guidedTerminalPath(medium), { - kind: "complete_with_candidate", - time: "05:43", + kind: "edit_birth_time_details", preservesCase: true, - appliesCandidateTime: true, + appliesCandidateTime: false, }); }); +test("candidate completion hook cannot invoke the legacy endpoint for dynamic turns", () => { + const hookSource = readFileSync(new URL("../src/hooks/use-birth-time-guided-journey.ts", import.meta.url), "utf8"); + assert.match(hookSource, /if \(turn\.journeyProtocol === "dynamic-choice-v2"\) return;/); +}); + test("request identity cache and scheduled polling deduplicate Strict Mode starts", async () => { const cache = createIdentityRequestCache(); let loads = 0; diff --git a/frontend/tests/birth-time-rectification-contract.test.ts b/frontend/tests/birth-time-rectification-contract.test.ts index decb96d3..eaf2a67c 100644 --- a/frontend/tests/birth-time-rectification-contract.test.ts +++ b/frontend/tests/birth-time-rectification-contract.test.ts @@ -75,9 +75,8 @@ test("low-confidence preview mirrors the persisted dynamic terminal state", () = assert.equal(low.candidateResult.winningSegment?.representativeTime, "05:21"); assert.equal(low.nextAction.resultId, low.candidateResult.resultId); assert.deepEqual(guidedTerminalPath(low), { - kind: "complete_with_candidate", - time: low.candidateResult.winningSegment?.representativeTime, + kind: "edit_birth_time_details", preservesCase: true, - appliesCandidateTime: true, + appliesCandidateTime: false, }); });