From 50c1de20d32c42bd23e547cb20f73bef55f460d5 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 19 Jul 2026 08:46:59 +0800 Subject: [PATCH] fix: harden dynamic rectification orchestration --- .superpowers/sdd/task-6-report.md | 26 +++- .../lib/birth-time-dynamic-action-replay.ts | 30 ++++ .../src/lib/birth-time-dynamic-actions.ts | 94 ++++--------- .../lib/birth-time-dynamic-choice-internal.ts | 16 +++ .../birth-time-dynamic-result-validator.ts | 64 +++++++++ .../lib/birth-time-dynamic-scoring-service.ts | 2 + .../lib/birth-time-dynamic-special-actions.ts | 133 ++++++++++++++++++ .../birth-time-journey-dynamic-persistence.ts | 7 +- .../lib/birth-time-journey-dynamic-state.ts | 2 + ...0_dynamic_choice_scoring_job_lifecycle.sql | 8 +- .../birth-time-dynamic-idempotence.test.ts | 82 +++++++++++ .../birth-time-dynamic-job-lease.test.ts | 50 +++++++ .../birth-time-dynamic-job-memory-store.ts | 11 +- ...irth-time-dynamic-result-validator.test.ts | 82 +++++++++++ .../tests/birth-time-dynamic-scoring.test.ts | 48 +++++-- ...birth_time_dynamic_scoring_job_contract.py | 7 + 16 files changed, 572 insertions(+), 90 deletions(-) create mode 100644 frontend/src/lib/birth-time-dynamic-action-replay.ts create mode 100644 frontend/src/lib/birth-time-dynamic-result-validator.ts create mode 100644 frontend/src/lib/birth-time-dynamic-special-actions.ts create mode 100644 frontend/tests/birth-time-dynamic-idempotence.test.ts create mode 100644 frontend/tests/birth-time-dynamic-job-lease.test.ts create mode 100644 frontend/tests/birth-time-dynamic-result-validator.test.ts diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md index 48616063..ef2760b0 100644 --- a/.superpowers/sdd/task-6-report.md +++ b/.superpowers/sdd/task-6-report.md @@ -20,6 +20,14 @@ Terminal low, medium, confirmation, and ready turns are one-way: answer, generat pause, and finish mutations cannot restart them. New assessments reload and return their persisted v2 generation turn instead of projecting the former legacy baseline question. +The focused review follow-up closes three additional safety seams. Scoring now independently +recomputes the deterministic confidence class from persisted effective evidence/domain counts, +margin thresholds, and segment width; medium and high cannot be accepted below their gates. +Winning segments must be a chronological subset of the persisted range, with exact inclusive +width and midpoint representative time, including across midnight. Unmatched-context, pause, +and finish retries now carry a private typed receipt and replay only the identical action, +version, and payload; cross-action or changed-payload receipt reuse is stale. + ## Persistence Amendment Added service-role-only `create_birth_time_dynamic_scoring_job` and @@ -28,10 +36,15 @@ validates/persists the public turn, private state, canonical receipt, and pendin checks ownership, job identity, evidence fingerprint, algorithm version, current action, and lease; completed replay requires a coherent persisted result/action. Production store methods use these RPCs directly and do not call legacy scoring wrappers or expose the private row. +Claim now locks job then case, matching completion/failure order and removing the prior lock +cycle. The executable memory store also models the 60-second processing lease and reclaim. ## Main Files - `frontend/src/lib/birth-time-dynamic-actions.ts` +- `frontend/src/lib/birth-time-dynamic-special-actions.ts` +- `frontend/src/lib/birth-time-dynamic-action-replay.ts` +- `frontend/src/lib/birth-time-dynamic-result-validator.ts` - `frontend/src/lib/birth-time-dynamic-transitions.ts` - `frontend/src/lib/birth-time-dynamic-scoring-service.ts` - `frontend/src/lib/birth-time-dynamic-scoring-job-store.ts` @@ -55,13 +68,16 @@ use these RPCs directly and do not call legacy scoring wrappers or expose the pr - Persistence RED: all SQL contract cases failed before the ordered v2 job migration existed. - Assessment regression RED: a newly persisted v2 case returned a response with no `journeyProtocol`; GREEN now reloads and returns the stored dynamic generation turn. +- Review RED: `medium + null segment + 1/1` was accepted, claim locked case before job, and + unmatched/pause/finish lost-response retries failed before receipt replay. Dedicated tests + reproduced each failure before the focused fix. Final verification: - Focused Task 6 TypeScript: **22/22 passed**. - Route/telemetry regression subset after v2 assessment routing: **28/28 passed**. -- Full frontend TypeScript tests: **344/344 passed**. -- Dynamic scoring-job SQL contracts: **3/3 passed**. +- Full frontend TypeScript tests: **351/351 passed**. +- Dynamic scoring-job SQL contracts: **4/4 passed**. - ESLint: **0 errors**, with the two pre-existing `page.tsx` hook warnings. - `git diff --check`: passed. - Every changed/new Task 6 TypeScript, test, and migration module is at most 250 pure LOC. @@ -71,7 +87,9 @@ Final verification: Live PostgreSQL execution was unavailable from the inherited Task 5 environment because the Docker daemon socket was absent. The database claim is therefore limited to static SQL -contracts plus executable TypeScript RPC fakes; no live-database pass is claimed. +token/order contracts plus executable TypeScript RPC fakes; no live-database pass is claimed. +The SQL tests do not simulate PostgreSQL locking; the lock-order assertion is structural, while +lease/reclaim behavior is executed by the typed memory store. ## Handoff @@ -81,3 +99,5 @@ browser coordination. In particular, v2 polling must call `pollDynamicScoringJob assessment response through the existing telemetry wrapper. Commit message: `feat: orchestrate dynamic rectification turns` + +Focused review fix commit message: `fix: harden dynamic rectification orchestration` diff --git a/frontend/src/lib/birth-time-dynamic-action-replay.ts b/frontend/src/lib/birth-time-dynamic-action-replay.ts new file mode 100644 index 00000000..0a898e68 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-action-replay.ts @@ -0,0 +1,30 @@ +import type { DynamicStoredRectificationCase } from "./birth-time-journey-service.ts"; +import { StaleJourneyTurnError } from "./birth-time-journey-store-errors.ts"; + +export function replayedDynamicAction( + stored: DynamicStoredRectificationCase, + actionId: string, + expectedVersion: number, + matches: () => boolean, +): boolean { + if (!stored.processedActionIds.includes(actionId.toLowerCase())) return false; + if (stored.turnVersion === expectedVersion + 1 && matches()) return true; + throw new StaleJourneyTurnError(stored.id, expectedVersion, stored.turnVersion); +} + +export function samePersistedDynamicReceipt( + proposed: DynamicStoredRectificationCase, + current: DynamicStoredRectificationCase, + actionId: string, + expectedVersion: number, +): boolean { + const expected = proposed.dynamicControl.lastActionReceipt; + const actual = current.dynamicControl.lastActionReceipt; + if (expected?.actionId !== actionId) return actual?.actionId !== actionId; + return current.turnVersion === expectedVersion + 1 + && actual?.actionId === expected.actionId + && actual.kind === expected.kind + && actual.turnVersion === expected.turnVersion + && actual.questionId === expected.questionId + && actual.note === expected.note; +} diff --git a/frontend/src/lib/birth-time-dynamic-actions.ts b/frontend/src/lib/birth-time-dynamic-actions.ts index 3736a035..e4c667b8 100644 --- a/frontend/src/lib/birth-time-dynamic-actions.ts +++ b/frontend/src/lib/birth-time-dynamic-actions.ts @@ -1,13 +1,9 @@ -import { z } from "zod"; +import { replayedDynamicAction } from "./birth-time-dynamic-action-replay.ts"; import type { PersistedDynamicChoiceQuestion } from "./birth-time-dynamic-choice-internal.ts"; import { dynamicDifferenceInput } from "./birth-time-dynamic-engine-input.ts"; import { publicQuestionAction } from "./birth-time-dynamic-transitions.ts"; -import { - answerTransition, - isDynamicTerminal, - toPausedDynamicAction, - withDynamicAction, -} from "./birth-time-dynamic-transitions.ts"; +import { answerTransition, isDynamicTerminal, withDynamicAction } from "./birth-time-dynamic-transitions.ts"; +import { createDynamicSpecialActions } from "./birth-time-dynamic-special-actions.ts"; import { storedDynamicJourneyResponse } from "./birth-time-journey-response.ts"; import type { BirthTimeJourneyEngine, @@ -27,7 +23,6 @@ type ChoiceCommand = { }; type TurnCommand = Pick; type QuestionCommand = TurnCommand & { readonly unmatchedNote?: string | null }; -type UnmatchedCommand = TurnCommand & { readonly questionId: string; readonly note: string }; export class BirthTimeDynamicActionError extends Error { readonly name = "BirthTimeDynamicActionError"; @@ -88,14 +83,26 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) { ) { return ports.store.saveDynamicTurn(updated, stored.turnVersion, actionId); } + const specialActions = createDynamicSpecialActions(ports, load, requireMutable); return { + ...specialActions, async answerDynamicChoice(userId: string, command: ChoiceCommand) { const stored = await load(userId, command.caseId); - requireMutable(stored); - if (stored.processedActionIds.includes(command.actionId.toLowerCase())) { + const lastAnswer = stored.choiceAnswers.at(-1); + const actionKind = stored.dynamicTurnState.nextAction.kind; + if (replayedDynamicAction(stored, command.actionId, command.turnVersion, () => ( + lastAnswer?.questionId === command.questionId && lastAnswer.optionId === command.optionId + && stored.dynamicControl.lastActionReceipt?.actionId !== command.actionId.toLowerCase() + && (lastAnswer.kind === "primary" + ? actionKind === "score_pending" + : lastAnswer.kind === "unknown" + ? actionKind === "generate_dynamic_question" + : actionKind === "clarify_unmatched_answer") + ))) { return storedDynamicJourneyResponse(stored); } + requireMutable(stored); const action = stored.dynamicTurnState.nextAction; const question = stored.currentChoiceQuestion; if (stored.turnVersion !== command.turnVersion @@ -135,33 +142,6 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) { return storedDynamicJourneyResponse(await save(stored, updated, command.actionId)); }, - async submitUnmatchedContext(userId: string, command: UnmatchedCommand) { - const stored = await load(userId, command.caseId); - requireMutable(stored); - const question = stored.currentChoiceQuestion; - if (stored.turnVersion !== command.turnVersion - || stored.dynamicTurnState.nextAction.kind !== "clarify_unmatched_answer" - || question?.questionId !== command.questionId) throw stale(stored, command.turnVersion); - const note = z.string().trim().max(240).parse(command.note); - const action = { kind: "generate_dynamic_question" as const }; - const updated = withDynamicAction(stored, action, stored.turnVersion + 1); - const next = { - ...updated, - currentChoiceQuestion: null, - agentContext: note.length === 0 - ? stored.agentContext - : [...stored.agentContext.slice(-9), note], - dynamicControl: { - ...stored.dynamicControl, - dismissedOpportunityIds: [ - ...stored.dynamicControl.dismissedOpportunityIds, - question.opportunityId, - ], - }, - }; - return storedDynamicJourneyResponse(await save(stored, next, command.actionId)); - }, - async loadDynamicQuestionBuild(userId: string, command: QuestionCommand) { const stored = await load(userId, command.caseId); requireMutable(stored); @@ -179,10 +159,17 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) { question: PersistedDynamicChoiceQuestion | null, ) { const stored = await load(userId, command.caseId); - requireMutable(stored); - if (stored.processedActionIds.includes(command.actionId.toLowerCase())) { + if (replayedDynamicAction(stored, command.actionId, command.turnVersion, () => ( + question === null + ? stored.currentChoiceQuestion === null + && stored.dynamicTurnState.nextAction.kind === "present_low_result" + : stored.currentChoiceQuestion?.questionId === question.questionId + && stored.currentChoiceQuestion.questionFingerprint === question.questionFingerprint + && stored.dynamicControl.lastActionReceipt?.actionId !== command.actionId.toLowerCase() + ))) { return { nextAction: stored.dynamicTurnState.nextAction }; } + requireMutable(stored); const kind = stored.dynamicTurnState.nextAction.kind; if (stored.turnVersion !== command.turnVersion || (kind !== "generate_dynamic_question" && kind !== "retry_question_generation")) { @@ -210,20 +197,6 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) { return { nextAction: saved.dynamicTurnState.nextAction }; }, - async pauseDynamic(userId: string, caseId: string, actionId: string, turnVersion: number) { - const stored = await load(userId, caseId); - requireMutable(stored); - if (stored.turnVersion !== turnVersion || stored.dynamicTurnState.nextAction.kind === "paused") { - throw stale(stored, turnVersion); - } - const pausedAction = toPausedDynamicAction(stored.dynamicTurnState.nextAction); - const updated = withDynamicAction(stored, { kind: "paused" }, stored.turnVersion + 1); - return storedDynamicJourneyResponse(await save(stored, { - ...updated, - dynamicControl: { ...stored.dynamicControl, pausedAction }, - }, actionId)); - }, - async resumeDynamic(userId: string, caseId: string) { const stored = await load(userId, caseId); if (isDynamicTerminal(stored) || stored.dynamicTurnState.nextAction.kind !== "paused") { @@ -245,20 +218,5 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) { return storedDynamicJourneyResponse(saved); }, - async finishDynamic(userId: string, caseId: string, actionId: string, turnVersion: number) { - const stored = await load(userId, caseId); - requireMutable(stored); - if (stored.turnVersion !== turnVersion) throw stale(stored, turnVersion); - const candidate = stored.candidateResult; - const action = candidate?.confidence === "medium" - ? { kind: "present_medium_result" as const, resultId: candidate.resultId } - : { kind: "present_low_result" as const, resultId: candidate?.resultId ?? null }; - const updated = withDynamicAction(stored, action, stored.turnVersion + 1); - return storedDynamicJourneyResponse(await save(stored, { - ...updated, - snapshot: terminalSnapshot(stored), - currentChoiceQuestion: null, - }, actionId)); - }, }; } diff --git a/frontend/src/lib/birth-time-dynamic-choice-internal.ts b/frontend/src/lib/birth-time-dynamic-choice-internal.ts index 97bc52ed..cb0a9bd7 100644 --- a/frontend/src/lib/birth-time-dynamic-choice-internal.ts +++ b/frontend/src/lib/birth-time-dynamic-choice-internal.ts @@ -84,6 +84,7 @@ export type StoredChoiceAnswer = { readonly kind: PublicChoiceKind; readonly opportunityId: string; readonly answeredAt: string; + readonly unmatchedContext?: string; }; export type ServerChoiceEvidence = { @@ -120,6 +121,13 @@ export type DynamicControlState = { readonly dismissedOpportunityIds: readonly string[]; readonly recentRanges: readonly TimeRange[]; readonly pausedAction: PausedDynamicAction | null; + readonly lastActionReceipt?: { + readonly actionId: string; + readonly kind: "unmatched_context" | "pause" | "finish"; + readonly turnVersion: number; + readonly questionId?: string; + readonly note?: string; + } | null; }; const evidencePartitionBaseSchema = z.object({ @@ -199,6 +207,7 @@ export const storedChoiceAnswerSchema = z.object({ kind: publicChoiceKindSchema, opportunityId: z.string().trim().min(1), answeredAt: z.string().datetime({ offset: true }), + unmatchedContext: z.string().max(240).optional(), }).strict().readonly(); export const serverChoiceEvidenceSchema = z.object({ @@ -236,6 +245,13 @@ export const dynamicControlStateSchema = z.object({ dismissedOpportunityIds: z.array(z.string().trim().min(1)).readonly(), recentRanges: z.array(timeRangeSchema).readonly(), pausedAction: pausedDynamicActionSchema.nullable(), + lastActionReceipt: z.object({ + actionId: z.string().uuid(), + kind: z.enum(["unmatched_context", "pause", "finish"]), + turnVersion: z.number().int().nonnegative(), + questionId: z.string().trim().min(1).optional(), + note: z.string().max(240).optional(), + }).strict().readonly().nullable().optional(), }).strict().readonly(); export function toPublicDynamicChoiceQuestion( diff --git a/frontend/src/lib/birth-time-dynamic-result-validator.ts b/frontend/src/lib/birth-time-dynamic-result-validator.ts new file mode 100644 index 00000000..97e77c98 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-result-validator.ts @@ -0,0 +1,64 @@ +import type { DynamicChoiceScoringResult } from "./birth-time-dynamic-choice-internal.ts"; +import type { TimeRange } from "./birth-time-dynamic-choice.ts"; +import { BirthTimeScoringJobError } from "./birth-time-scoring-job.ts"; + +function minute(value: string): number { + const [hour, part] = value.split(":").map(Number); + return hour * 60 + part; +} + +function rangeMinutes(range: TimeRange): readonly number[] { + const start = minute(range.startTime); + const end = minute(range.endTime); + const values = [start]; + let current = start; + while (current !== end) { + current = (current + 1) % 1_440; + values.push(current); + } + return values; +} + +function segmentIsCoherent( + result: DynamicChoiceScoringResult, + currentRange: TimeRange, +): boolean { + const segment = result.candidate.winningSegment; + if (segment === null) return true; + const candidates = rangeMinutes(currentRange); + const start = candidates.indexOf(minute(segment.startTime)); + const end = candidates.indexOf(minute(segment.endTime)); + if (start < 0 || end < start) return false; + const width = end - start + 1; + const representative = candidates[start + Math.floor((width - 1) / 2)]; + return segment.widthMinutes === width + && minute(segment.representativeTime) === representative; +} + +export function assertDynamicScoringResult( + result: DynamicChoiceScoringResult, + currentRange: TimeRange, +): void { + const candidate = result.candidate; + const segment = candidate.winningSegment; + const blocked = candidate.reasons.includes("missing_mandatory_layers"); + const high = !blocked + && result.effectiveAnswerCount >= 4 + && result.dimensionCount >= 3 + && segment !== null + && segment.widthMinutes <= 5 + && candidate.marginPercent >= 20; + const medium = !blocked + && result.effectiveAnswerCount >= 3 + && result.dimensionCount >= 2 + && segment !== null + && segment.widthMinutes <= 15 + && candidate.marginPercent >= 10; + const expected = high ? "high" : medium ? "medium" : "low"; + if (!segmentIsCoherent(result, currentRange) + || candidate.confidence !== expected + || candidate.canApply !== high + || candidate.evidence.length !== 0) { + throw new BirthTimeScoringJobError("invalid_result"); + } +} diff --git a/frontend/src/lib/birth-time-dynamic-scoring-service.ts b/frontend/src/lib/birth-time-dynamic-scoring-service.ts index a5b58a05..3d497f5f 100644 --- a/frontend/src/lib/birth-time-dynamic-scoring-service.ts +++ b/frontend/src/lib/birth-time-dynamic-scoring-service.ts @@ -4,6 +4,7 @@ import { dynamicDifferenceInput, } from "./birth-time-dynamic-engine-input.ts"; import { completeDynamicScoreTransition, isDynamicTerminal, withDynamicAction } from "./birth-time-dynamic-transitions.ts"; +import { assertDynamicScoringResult } from "./birth-time-dynamic-result-validator.ts"; import { storedDynamicJourneyResponse } from "./birth-time-journey-response.ts"; import type { BirthTimeJourneyEngine, @@ -94,6 +95,7 @@ export function createDynamicScoringService(ports: BirthTimeJourneyPorts) { const result = dynamicChoiceScoringResultSchema.parse( await engine.scoreChoices(dynamicChoiceScoreInput(stored)), ); + assertDynamicScoringResult(result, stored.dynamicTurnState.progress.currentRange); requireCounts(stored, result); const build = await engine.buildDifferencePacket(dynamicDifferenceInput(stored)); const useful = build.packet.opportunities.filter((opportunity) => ( diff --git a/frontend/src/lib/birth-time-dynamic-special-actions.ts b/frontend/src/lib/birth-time-dynamic-special-actions.ts new file mode 100644 index 00000000..160a81e8 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-special-actions.ts @@ -0,0 +1,133 @@ +import { z } from "zod"; +import { replayedDynamicAction } from "./birth-time-dynamic-action-replay.ts"; +import { toPausedDynamicAction, withDynamicAction } from "./birth-time-dynamic-transitions.ts"; +import { storedDynamicJourneyResponse } from "./birth-time-journey-response.ts"; +import type { BirthTimeJourneyPorts, DynamicStoredRectificationCase } from "./birth-time-journey-service.ts"; +import { StaleJourneyTurnError } from "./birth-time-journey-store-errors.ts"; + +type UnmatchedCommand = { + readonly caseId: string; + readonly actionId: string; + readonly turnVersion: number; + readonly questionId: string; + readonly note: string; +}; + +function stale(stored: DynamicStoredRectificationCase, expected: number) { + return new StaleJourneyTurnError(stored.id, expected, stored.turnVersion); +} + +function terminalSnapshot(stored: DynamicStoredRectificationCase) { + return { + ...stored.snapshot, + state: "candidate" as const, + assistantIntent: "present_saved_candidate_range" as const, + input: "candidate_actions" as const, + confidence: stored.candidateResult?.confidence ?? "low" as const, + canApply: false, + activeTime: null, + }; +} + +export function createDynamicSpecialActions(ports: BirthTimeJourneyPorts, load: ( + userId: string, caseId: string, +) => Promise, requireMutable: ( + stored: DynamicStoredRectificationCase, +) => void) { + const save = (stored: DynamicStoredRectificationCase, updated: DynamicStoredRectificationCase, actionId: string) => ( + ports.store.saveDynamicTurn(updated, stored.turnVersion, actionId) + ); + return { + async submitUnmatchedContext(userId: string, command: UnmatchedCommand) { + const stored = await load(userId, command.caseId); + const note = z.string().trim().max(240).parse(command.note); + const priorAnswer = stored.choiceAnswers.at(-1); + const receipt = stored.dynamicControl.lastActionReceipt; + if (replayedDynamicAction(stored, command.actionId, command.turnVersion, () => ( + stored.dynamicTurnState.nextAction.kind === "generate_dynamic_question" + && priorAnswer?.kind === "unmatched" && priorAnswer.questionId === command.questionId + && priorAnswer.unmatchedContext === note + && receipt?.actionId === command.actionId.toLowerCase() + && receipt.kind === "unmatched_context" && receipt.turnVersion === command.turnVersion + && receipt.questionId === command.questionId && receipt.note === note + ))) return storedDynamicJourneyResponse(stored); + requireMutable(stored); + const question = stored.currentChoiceQuestion; + if (stored.turnVersion !== command.turnVersion + || stored.dynamicTurnState.nextAction.kind !== "clarify_unmatched_answer" + || question?.questionId !== command.questionId + || priorAnswer?.kind !== "unmatched" + || priorAnswer.questionId !== command.questionId) throw stale(stored, command.turnVersion); + const updated = withDynamicAction(stored, { kind: "generate_dynamic_question" }, stored.turnVersion + 1); + const saved = await save(stored, { + ...updated, + currentChoiceQuestion: null, + choiceAnswers: [...stored.choiceAnswers.slice(0, -1), { ...priorAnswer, unmatchedContext: note }], + agentContext: note.length === 0 ? stored.agentContext : [...stored.agentContext.slice(-9), note], + dynamicControl: { + ...stored.dynamicControl, + dismissedOpportunityIds: [...stored.dynamicControl.dismissedOpportunityIds, question.opportunityId], + lastActionReceipt: { + actionId: command.actionId.toLowerCase(), kind: "unmatched_context", + turnVersion: command.turnVersion, questionId: command.questionId, note, + }, + }, + }, command.actionId); + return storedDynamicJourneyResponse(saved); + }, + + async pauseDynamic(userId: string, caseId: string, actionId: string, turnVersion: number) { + const stored = await load(userId, caseId); + const receipt = stored.dynamicControl.lastActionReceipt; + if (replayedDynamicAction(stored, actionId, turnVersion, () => ( + stored.dynamicTurnState.nextAction.kind === "paused" + && stored.dynamicControl.pausedAction !== null + && receipt?.actionId === actionId.toLowerCase() + && receipt.kind === "pause" && receipt.turnVersion === turnVersion + ))) return storedDynamicJourneyResponse(stored); + requireMutable(stored); + if (stored.turnVersion !== turnVersion || stored.dynamicTurnState.nextAction.kind === "paused") { + throw stale(stored, turnVersion); + } + const pausedAction = toPausedDynamicAction(stored.dynamicTurnState.nextAction); + const updated = withDynamicAction(stored, { kind: "paused" }, stored.turnVersion + 1); + const saved = await save(stored, { + ...updated, + dynamicControl: { + ...stored.dynamicControl, + pausedAction, + lastActionReceipt: { actionId: actionId.toLowerCase(), kind: "pause", turnVersion }, + }, + }, actionId); + return storedDynamicJourneyResponse(saved); + }, + + async finishDynamic(userId: string, caseId: string, actionId: string, turnVersion: number) { + const stored = await load(userId, caseId); + const receipt = stored.dynamicControl.lastActionReceipt; + if (replayedDynamicAction(stored, actionId, turnVersion, () => ( + (stored.dynamicTurnState.nextAction.kind === "present_low_result" + || stored.dynamicTurnState.nextAction.kind === "present_medium_result") + && receipt?.actionId === actionId.toLowerCase() + && receipt.kind === "finish" && receipt.turnVersion === turnVersion + ))) return storedDynamicJourneyResponse(stored); + requireMutable(stored); + if (stored.turnVersion !== turnVersion) throw stale(stored, turnVersion); + const candidate = stored.candidateResult; + const action = candidate?.confidence === "medium" + ? { kind: "present_medium_result" as const, resultId: candidate.resultId } + : { kind: "present_low_result" as const, resultId: candidate?.resultId ?? null }; + const updated = withDynamicAction(stored, action, stored.turnVersion + 1); + const saved = await save(stored, { + ...updated, + snapshot: terminalSnapshot(stored), + currentChoiceQuestion: null, + dynamicControl: { + ...stored.dynamicControl, + lastActionReceipt: { actionId: actionId.toLowerCase(), kind: "finish", turnVersion }, + }, + }, actionId); + return storedDynamicJourneyResponse(saved); + }, + }; +} diff --git a/frontend/src/lib/birth-time-journey-dynamic-persistence.ts b/frontend/src/lib/birth-time-journey-dynamic-persistence.ts index a4269916..021532b5 100644 --- a/frontend/src/lib/birth-time-journey-dynamic-persistence.ts +++ b/frontend/src/lib/birth-time-journey-dynamic-persistence.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { samePersistedDynamicReceipt } from "./birth-time-dynamic-action-replay.ts"; import { toPublicDynamicChoiceQuestion } from "./birth-time-dynamic-choice-internal.ts"; import { dynamicJourneyTurnStateSchema, @@ -143,8 +144,10 @@ export function createDynamicTurnPersistence( }); if (result.error) { const current = await loadCase(value.userId, value.id); - if (isStaleRpc(result.error) && current?.processedActionIds?.includes(receipt)) { - return loadedDynamic(value.userId, value.id); + if (isStaleRpc(result.error) && current?.journeyProtocol === "dynamic-choice-v2" + && current.processedActionIds.includes(receipt)) { + if (samePersistedDynamicReceipt(value, current, receipt, expectedVersion)) return current; + throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion); } if (isStaleRpc(result.error)) { throw new StaleJourneyTurnError( diff --git a/frontend/src/lib/birth-time-journey-dynamic-state.ts b/frontend/src/lib/birth-time-journey-dynamic-state.ts index 0f07687a..37889f4a 100644 --- a/frontend/src/lib/birth-time-journey-dynamic-state.ts +++ b/frontend/src/lib/birth-time-journey-dynamic-state.ts @@ -138,6 +138,7 @@ export function createInitialDynamicState( dismissedOpportunityIds: [], recentRanges: [currentRange], pausedAction: null, + lastActionReceipt: null, }); const ready = snapshot.state === "ready"; return { @@ -201,6 +202,7 @@ export function prepareLegacyDynamicUpgrade( dismissedOpportunityIds: [], recentRanges: [currentRange], pausedAction: null, + lastActionReceipt: null, }); const dynamicTurnState = dynamicJourneyTurnStateSchema.parse({ journeyProtocol: "dynamic-choice-v2", diff --git a/frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql b/frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql index 6288f2f4..d8d385bb 100644 --- a/frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql +++ b/frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql @@ -94,14 +94,14 @@ declare v_result_id text; v_updated_id uuid; begin - select c.* into v_case from public.birth_time_rectification_cases c - where c.id = p_case_id and c.user_id = p_user_id - and c.journey_protocol = 'dynamic-choice-v2' for update; - if not found then raise exception 'birth_time_dynamic_case_not_found'; end if; select j.* into v_job from public.birth_time_rectification_scoring_jobs j where j.id = p_job_id and j.case_id = p_case_id and j.user_id = p_user_id for update; if not found then raise exception 'birth_time_dynamic_scoring_job_not_found'; end if; + select c.* into v_case from public.birth_time_rectification_cases c + where c.id = p_case_id and c.user_id = p_user_id + and c.journey_protocol = 'dynamic-choice-v2' for update; + if not found then raise exception 'birth_time_dynamic_case_not_found'; end if; if v_job.evidence_fingerprint is distinct from p_evidence_fingerprint then raise exception 'birth_time_dynamic_scoring_fingerprint_mismatch'; end if; diff --git a/frontend/tests/birth-time-dynamic-idempotence.test.ts b/frontend/tests/birth-time-dynamic-idempotence.test.ts new file mode 100644 index 00000000..2a66610f --- /dev/null +++ b/frontend/tests/birth-time-dynamic-idempotence.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts"; +import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts"; +import { dynamicCase, ownerId, persistedQuestion } from "./birth-time-dynamic-persistence-fixture.ts"; +import { dynamicJobStore } from "./birth-time-dynamic-job-memory-store.ts"; +import { memoryStore } from "./birth-time-journey-memory-store.ts"; + +const answerId = "9a921af8-ddcc-4d20-b4c8-fbbb3e6a814d"; +const actionId = "700406ad-1ca6-437d-9f77-61354ba8e36a"; + +function flow() { + const memory = memoryStore(dynamicCase()); + const jobs = dynamicJobStore(memory.store, () => { + const current = memory.savedCase(); + return current?.journeyProtocol === "dynamic-choice-v2" ? current : null; + }); + return createBirthTimeJourneyService({ + store: jobs.store, + engine: { + async scan() { throw new Error("unexpected scan"); }, + async score() { throw new Error("unexpected score"); }, + async scoreEvents() { throw new Error("unexpected event score"); }, + }, + }); +} + +test("unmatched context replays only the identical action and payload", async () => { + const service = flow(); + const unmatched = persistedQuestion.options.find((option) => option.kind === "unmatched"); + if (!unmatched) throw new Error("missing unmatched option"); + const clarification = await service.answerDynamicChoice(ownerId, { + caseId: dynamicCase().id, + actionId: answerId, + turnVersion: 7, + questionId: persistedQuestion.questionId, + optionId: unmatched.optionId, + }); + const command = { + caseId: dynamicCase().id, + actionId, + turnVersion: clarification.turnVersion, + questionId: persistedQuestion.questionId, + note: "更像是 2017 年", + }; + const saved = await service.submitUnmatchedContext(ownerId, command); + const replay = await service.submitUnmatchedContext(ownerId, command); + assert.deepEqual(replay.nextAction, saved.nextAction); + assert.equal(replay.turnVersion, saved.turnVersion); + await assert.rejects( + service.submitUnmatchedContext(ownerId, { ...command, note: "其实是 2018 年" }), + StaleJourneyTurnError, + ); + await assert.rejects( + service.pauseDynamic(ownerId, dynamicCase().id, actionId, command.turnVersion), + StaleJourneyTurnError, + ); +}); + +test("pause replays after a lost response but cannot impersonate finish", async () => { + const service = flow(); + const saved = await service.pauseDynamic(ownerId, dynamicCase().id, actionId, 7); + const replay = await service.pauseDynamic(ownerId, dynamicCase().id, actionId, 7); + assert.deepEqual(replay.nextAction, saved.nextAction); + assert.equal(replay.turnVersion, saved.turnVersion); + await assert.rejects( + service.finishDynamic(ownerId, dynamicCase().id, actionId, 7), + StaleJourneyTurnError, + ); +}); + +test("finish replays after a lost response but cannot impersonate pause", async () => { + const service = flow(); + const saved = await service.finishDynamic(ownerId, dynamicCase().id, actionId, 7); + const replay = await service.finishDynamic(ownerId, dynamicCase().id, actionId, 7); + assert.deepEqual(replay.nextAction, saved.nextAction); + assert.equal(replay.turnVersion, saved.turnVersion); + await assert.rejects( + service.pauseDynamic(ownerId, dynamicCase().id, actionId, 7), + StaleJourneyTurnError, + ); +}); diff --git a/frontend/tests/birth-time-dynamic-job-lease.test.ts b/frontend/tests/birth-time-dynamic-job-lease.test.ts new file mode 100644 index 00000000..209f9046 --- /dev/null +++ b/frontend/tests/birth-time-dynamic-job-lease.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts"; +import { + dynamicChoiceScoringAlgorithmVersion, + dynamicEvidenceFingerprint, +} from "../src/lib/birth-time-scoring-job.ts"; +import { dynamicCase, ownerId, persistedQuestion } from "./birth-time-dynamic-persistence-fixture.ts"; +import { dynamicJobStore } from "./birth-time-dynamic-job-memory-store.ts"; +import { memoryStore } from "./birth-time-journey-memory-store.ts"; + +test("dynamic processing claims are reclaimed only after the lease", async () => { + const memory = memoryStore(dynamicCase()); + const jobs = dynamicJobStore(memory.store, () => { + const current = memory.savedCase(); + return current?.journeyProtocol === "dynamic-choice-v2" ? current : null; + }); + const service = createBirthTimeJourneyService({ + store: jobs.store, + now: () => new Date("2026-07-18T08:00:00.000Z"), + engine: { + async scan() { throw new Error("unexpected scan"); }, + async score() { throw new Error("unexpected score"); }, + async scoreEvents() { throw new Error("unexpected event score"); }, + }, + }); + const pending = await service.answerDynamicChoice(ownerId, { + caseId: dynamicCase().id, + actionId: "9a921af8-ddcc-4d20-b4c8-fbbb3e6a814d", + turnVersion: 7, + questionId: persistedQuestion.questionId, + optionId: persistedQuestion.options[0].optionId, + }); + if (pending.nextAction.kind !== "score_pending") throw new Error("expected pending score"); + const stored = memory.savedCase(); + if (!stored || stored.journeyProtocol !== "dynamic-choice-v2") throw new Error("missing v2 case"); + const claim = jobs.store.claimDynamicScoringJob; + if (!claim) throw new Error("missing dynamic claim"); + const identity = { + userId: ownerId, + caseId: stored.id, + jobId: pending.nextAction.jobId, + evidenceFingerprint: dynamicEvidenceFingerprint(stored.choiceEvidence), + algorithmVersion: dynamicChoiceScoringAlgorithmVersion, + } as const; + + assert.equal((await claim({ ...identity, now: "2026-07-18T08:00:00.000Z" })).kind, "claimed"); + assert.equal((await claim({ ...identity, now: "2026-07-18T08:00:59.999Z" })).kind, "processing"); + assert.equal((await claim({ ...identity, now: "2026-07-18T08:01:00.000Z" })).kind, "claimed"); +}); diff --git a/frontend/tests/birth-time-dynamic-job-memory-store.ts b/frontend/tests/birth-time-dynamic-job-memory-store.ts index 5245b18b..669ce2ab 100644 --- a/frontend/tests/birth-time-dynamic-job-memory-store.ts +++ b/frontend/tests/birth-time-dynamic-job-memory-store.ts @@ -3,13 +3,14 @@ import type { DynamicStoredRectificationCase, } from "../src/lib/birth-time-journey-service.ts"; import type { DynamicScoringJobSpec } from "../src/lib/birth-time-scoring-job.ts"; -import { BirthTimeScoringJobError } from "../src/lib/birth-time-scoring-job.ts"; +import { BirthTimeScoringJobError, scoringJobDurationMs, scoringProcessingLeaseMs } from "../src/lib/birth-time-scoring-job.ts"; import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts"; type DynamicMemoryJob = DynamicScoringJobSpec & { readonly caseId: string; readonly userId: string; readonly status: "pending" | "processing" | "completed" | "failed"; + readonly updatedAt: string; }; export function dynamicJobStore( @@ -32,6 +33,7 @@ export function dynamicJobStore( caseId: value.id, userId: value.userId, status: "pending", + updatedAt: new Date(Date.parse(spec.expiresAt) - scoringJobDurationMs).toISOString(), }); return base.saveDynamicTurn(value, expectedVersion, receipt); }, @@ -48,9 +50,12 @@ export function dynamicJobStore( return { kind: "completed", algorithmVersion: job.algorithmVersion }; } if (job.status === "processing") { - return { kind: "processing", algorithmVersion: job.algorithmVersion }; + const leaseEnds = Date.parse(job.updatedAt) + scoringProcessingLeaseMs; + if (Date.parse(identity.now) < leaseEnds) { + return { kind: "processing", algorithmVersion: job.algorithmVersion }; + } } - jobs.set(job.jobId, { ...job, status: "processing" }); + jobs.set(job.jobId, { ...job, status: "processing", updatedAt: identity.now }); return { kind: "claimed", algorithmVersion: job.algorithmVersion }; }, async completeDynamicScoringJob(value, command) { diff --git a/frontend/tests/birth-time-dynamic-result-validator.test.ts b/frontend/tests/birth-time-dynamic-result-validator.test.ts new file mode 100644 index 00000000..5e190079 --- /dev/null +++ b/frontend/tests/birth-time-dynamic-result-validator.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { DynamicChoiceScoringResult } from "../src/lib/birth-time-dynamic-choice-internal.ts"; +import { assertDynamicScoringResult } from "../src/lib/birth-time-dynamic-result-validator.ts"; +import { BirthTimeScoringJobError } from "../src/lib/birth-time-scoring-job.ts"; + +const result: DynamicChoiceScoringResult = { + candidate: { + resultId: "097b7b4c-60f3-4ed8-b290-64b2084182e7", + confidence: "medium", + canApply: false, + winningSegment: { + startTime: "05:10", endTime: "05:12", representativeTime: "05:11", widthMinutes: 3, + }, + eventCount: 3, + domainCount: 2, + topScore: 10, + secondScore: 9, + marginPercent: 10, + reasons: [], + evidence: [], + algorithmVersion: "birth-time-choice-scoring-v2", + }, + evidenceMode: "dynamic_choice", + effectiveAnswerCount: 3, + dimensionCount: 2, +}; + +const currentRange = { startTime: "05:00", endTime: "06:00" } as const; + +test("medium and high results must satisfy deterministic confidence gates", () => { + assert.doesNotThrow(() => assertDynamicScoringResult(result, currentRange)); + assert.doesNotThrow(() => assertDynamicScoringResult({ + ...result, + effectiveAnswerCount: 4, + dimensionCount: 3, + candidate: { + ...result.candidate, + confidence: "high", + canApply: true, + eventCount: 4, + domainCount: 3, + marginPercent: 20, + }, + }, currentRange)); + assert.throws(() => assertDynamicScoringResult({ + ...result, + effectiveAnswerCount: 1, + dimensionCount: 1, + candidate: { + ...result.candidate, + winningSegment: null, + eventCount: 1, + domainCount: 1, + }, + }, currentRange), BirthTimeScoringJobError); +}); + +test("winning segments must be a coherent subset of the persisted range", () => { + for (const winningSegment of [ + { startTime: "04:59", endTime: "05:01", representativeTime: "05:00", widthMinutes: 3 }, + { startTime: "05:10", endTime: "05:12", representativeTime: "05:10", widthMinutes: 3 }, + { startTime: "05:10", endTime: "05:12", representativeTime: "05:11", widthMinutes: 2 }, + ] as const) { + assert.throws(() => assertDynamicScoringResult({ + ...result, + candidate: { ...result.candidate, winningSegment }, + }, currentRange), BirthTimeScoringJobError); + } +}); + +test("cross-midnight ranges retain chronological segment validation", () => { + assert.doesNotThrow(() => assertDynamicScoringResult({ + ...result, + candidate: { + ...result.candidate, + winningSegment: { + startTime: "23:59", endTime: "00:01", representativeTime: "00:00", widthMinutes: 3, + }, + }, + }, { startTime: "23:58", endTime: "00:02" })); +}); diff --git a/frontend/tests/birth-time-dynamic-scoring.test.ts b/frontend/tests/birth-time-dynamic-scoring.test.ts index 903ac217..7c1386fa 100644 --- a/frontend/tests/birth-time-dynamic-scoring.test.ts +++ b/frontend/tests/birth-time-dynamic-scoring.test.ts @@ -4,6 +4,7 @@ import { completeDynamicScoreTransition, } from "../src/lib/birth-time-dynamic-transitions.ts"; import type { CandidateResult } from "../src/lib/birth-time-evidence.ts"; +import type { ServerChoiceEvidence } from "../src/lib/birth-time-dynamic-choice-internal.ts"; import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts"; import { dynamicCase, @@ -30,8 +31,12 @@ const lowCandidate: CandidateResult = { const actionId = "ab2d936b-5ce7-45d8-a0fb-33f48f960f36"; -function freshDynamicCase(candidateResult: CandidateResult | null = null) { +function freshDynamicCase( + candidateResult: CandidateResult | null = null, + priorEvidence: readonly ServerChoiceEvidence[] = [], +) { const stored = dynamicCase(); + const effectiveAnswerCount = priorEvidence.length; return { ...stored, eventContext: { @@ -41,18 +46,19 @@ function freshDynamicCase(candidateResult: CandidateResult | null = null) { tz: 8, }, candidateResult, + choiceEvidence: priorEvidence, dynamicControl: { ...stored.dynamicControl, - answeredCount: 0, - effectiveAnswerCount: 0, + answeredCount: effectiveAnswerCount, + effectiveAnswerCount, plateauCount: candidateResult === null ? 0 : 1, }, dynamicTurnState: { ...stored.dynamicTurnState, progress: { ...stored.dynamicTurnState.progress, - answeredCount: 0, - effectiveAnswerCount: 0, + answeredCount: effectiveAnswerCount, + effectiveAnswerCount, plateauCount: candidateResult === null ? 0 : 1, }, }, @@ -62,9 +68,10 @@ function freshDynamicCase(candidateResult: CandidateResult | null = null) { function scoringFlow(input: { readonly candidate?: CandidateResult; readonly initialCandidate?: CandidateResult | null; + readonly priorEvidence?: readonly ServerChoiceEvidence[]; readonly failOnce?: boolean; } = {}) { - const initial = freshDynamicCase(input.initialCandidate ?? null); + const initial = freshDynamicCase(input.initialCandidate ?? null, input.priorEvidence); const memory = memoryStore(initial); const jobs = dynamicJobStore(memory.store, () => { const value = memory.savedCase(); @@ -88,8 +95,8 @@ function scoringFlow(input: { return { candidate, evidenceMode: "dynamic_choice" as const, - effectiveAnswerCount: 1, - dimensionCount: 1, + effectiveAnswerCount: candidate.eventCount, + dimensionCount: candidate.domainCount, }; }, async buildDifferencePacket(value) { @@ -150,6 +157,9 @@ test("high confidence requires explicit confirmation without applying a time", ( representativeTime: "05:11", widthMinutes: 2, }, + eventCount: 4, + domainCount: 3, + marginPercent: 20, }; const result = completeDynamicScoreTransition({ stored: { ...stored, currentChoiceQuestion: null }, @@ -210,8 +220,26 @@ test("dynamic scoring failure retries the same job without duplicating evidence" }); test("the second plateau is terminal and resume stays terminal", async () => { - const medium = { ...lowCandidate, confidence: "medium" as const }; - const flow = scoringFlow({ initialCandidate: medium, candidate: medium }); + const medium = { + ...lowCandidate, + confidence: "medium" as const, + winningSegment: { + startTime: "05:10", endTime: "05:12", representativeTime: "05:11", widthMinutes: 3, + }, + eventCount: 3, + domainCount: 3, + marginPercent: 10, + }; + const priorEvidence = ["education_change", "relationship_change"].map((dimensionCode, index) => ({ + questionId: `prior-${index}`, + opportunityId: `opportunity-${index}`, + partitionId: `partition-${index}`, + dimensionCode, + candidateScores: { "05:10": 1 }, + informationGain: 0.5, + })); + const previous = { ...medium, confidence: "low" as const, eventCount: 2, domainCount: 2 }; + const flow = scoringFlow({ initialCandidate: previous, candidate: medium, priorEvidence }); const pending = await flow.service.answerDynamicChoice(ownerId, { caseId: dynamicCase().id, actionId, diff --git a/tests/test_birth_time_dynamic_scoring_job_contract.py b/tests/test_birth_time_dynamic_scoring_job_contract.py index f51655de..71b905bb 100644 --- a/tests/test_birth_time_dynamic_scoring_job_contract.py +++ b/tests/test_birth_time_dynamic_scoring_job_contract.py @@ -67,6 +67,13 @@ def test_dynamic_job_claim_locks_v2_and_validates_completed_replay() -> None: assert "algorithm_version text" in body +def test_dynamic_job_claim_uses_job_then_case_lock_order() -> None: + body = _function(_sql(), "claim_birth_time_dynamic_scoring_job") + job_lock = body.index("from public.birth_time_rectification_scoring_jobs j") + case_lock = body.index("from public.birth_time_rectification_cases c") + assert job_lock < case_lock + + def test_dynamic_job_functions_are_service_role_only_and_reviewable() -> None: sql = _sql() for name in (