fix: block legacy dynamic candidate adoption

This commit is contained in:
Jesse_Chen
2026-07-20 15:11:42 +08:00
parent 8f98e8585d
commit 7ef8f7c146
8 changed files with 117 additions and 13 deletions
@@ -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();
@@ -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);
@@ -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
@@ -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"
@@ -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",
@@ -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);
});
@@ -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<number>();
let loads = 0;
@@ -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,
});
});