fix: block legacy dynamic candidate adoption
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user