From e90f77bac1553eb50b83523ef9498331501b97f0 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 19 Jul 2026 09:12:45 +0800 Subject: [PATCH] fix: close dynamic receipt replay gaps --- .superpowers/sdd/task-6-report.md | 26 +++- ...dynamic-choice-birth-time-rectification.md | 5 + .../lib/birth-time-dynamic-action-receipt.ts | 67 ++++++++++ .../lib/birth-time-dynamic-action-replay.ts | 12 +- .../src/lib/birth-time-dynamic-actions.ts | 67 ++++++++-- .../lib/birth-time-dynamic-choice-internal.ts | 18 +-- .../birth-time-dynamic-scoring-job-store.ts | 13 +- .../birth-time-journey-dynamic-persistence.ts | 10 +- ...0_dynamic_choice_scoring_job_lifecycle.sql | 18 +++ ...0_dynamic_choice_exact_action_receipts.sql | 84 ++++++++++++ .../birth-time-dynamic-action-receipt.test.ts | 45 +++++++ .../birth-time-dynamic-idempotence.test.ts | 64 ++++++++- .../birth-time-dynamic-job-lease.test.ts | 41 ++++++ .../birth-time-dynamic-job-memory-store.ts | 33 ++++- .../birth-time-dynamic-persistence-fixture.ts | 8 +- .../birth-time-dynamic-persistence.test.ts | 33 +++-- ...birth-time-dynamic-receipt-test-support.ts | 29 +++++ .../birth-time-dynamic-rpc-replay.test.ts | 122 ++++++++++++++++++ .../birth-time-dynamic-scoring-store.test.ts | 15 ++- .../tests/birth-time-journey-memory-store.ts | 6 +- ...th_time_dynamic_action_receipt_contract.py | 51 ++++++++ ...birth_time_dynamic_scoring_job_contract.py | 2 + 22 files changed, 696 insertions(+), 73 deletions(-) create mode 100644 frontend/src/lib/birth-time-dynamic-action-receipt.ts create mode 100644 frontend/supabase/migrations/20260718095000_dynamic_choice_exact_action_receipts.sql create mode 100644 frontend/tests/birth-time-dynamic-action-receipt.test.ts create mode 100644 frontend/tests/birth-time-dynamic-receipt-test-support.ts create mode 100644 frontend/tests/birth-time-dynamic-rpc-replay.test.ts create mode 100644 tests/test_birth_time_dynamic_action_receipt_contract.py diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md index ef2760b0..26964f32 100644 --- a/.superpowers/sdd/task-6-report.md +++ b/.superpowers/sdd/task-6-report.md @@ -28,6 +28,15 @@ width and midpoint representative time, including across midnight. Unmatched-con 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. +The second review follow-up closes the database success-path seam. Every action-bearing v2 +mutation now persists a canonical strict receipt: answers bind question and option, question +commits bind submitted/result fingerprints or a terminal result, and special actions/resume +bind their exact payload. A new ordered migration replaces the already-deployed generic turn +save: it locks case then private state and permits processed-action success only for version +`expected + 1` plus exact JSONB receipt equality. Dynamic scoring-job creation applies the same +locked private receipt check. TypeScript revalidates version and receipt after both successful +and failed RPC responses, so a normal duplicate-success response cannot bypass comparison. + ## Persistence Amendment Added service-role-only `create_birth_time_dynamic_scoring_job` and @@ -38,12 +47,15 @@ lease; completed replay requires a coherent persisted result/action. Production 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. +Its completed replay additionally binds the current job action and checks persisted candidate +result/action coherence. ## 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-action-receipt.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` @@ -54,6 +66,7 @@ cycle. The executable memory store also models the 60-second processing lease an - `frontend/src/lib/birth-time-journey-store.ts` - `frontend/src/lib/birth-time-scoring-job.ts` - `frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql` +- `frontend/supabase/migrations/20260718095000_dynamic_choice_exact_action_receipts.sql` - `frontend/tests/birth-time-dynamic-actions.test.ts` - `frontend/tests/birth-time-dynamic-scoring.test.ts` - `frontend/tests/birth-time-dynamic-scoring-store.test.ts` @@ -71,13 +84,18 @@ cycle. The executable memory store also models the 60-second processing lease an - 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. +- Second review RED: database duplicate-success returned on action ID alone, answer/commit did + not write typed receipts, and TypeScript trusted a successful RPC version without comparing + the reloaded receipt. SQL and RPC fakes now reproduce exact duplicate success, concurrent + cross-action success, changed answer/commit payloads, lease reclaim, and corrupted completed + result/action replay. Final verification: -- Focused Task 6 TypeScript: **22/22 passed**. +- Focused Task 6 TypeScript: **43/43 passed**. - Route/telemetry regression subset after v2 assessment routing: **28/28 passed**. -- Full frontend TypeScript tests: **351/351 passed**. -- Dynamic scoring-job SQL contracts: **4/4 passed**. +- Full frontend TypeScript tests: **358/358 passed**. +- Dynamic action-receipt and scoring-job SQL contracts: **7/7 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. @@ -101,3 +119,5 @@ assessment response through the existing telemetry wrapper. Commit message: `feat: orchestrate dynamic rectification turns` Focused review fix commit message: `fix: harden dynamic rectification orchestration` + +Exact receipt fix commit message: `fix: close dynamic receipt replay gaps` diff --git a/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md b/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md index dc5608fd..62fb9d00 100644 --- a/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md +++ b/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md @@ -916,6 +916,10 @@ private-state boundary or leaving browser polling unable to complete. - Add one ordered migration at or below 250 pure lines for `create_birth_time_dynamic_scoring_job(...)` and `claim_birth_time_dynamic_scoring_job(...)`. +- Add a later ordered replacement for `save_birth_time_dynamic_turn(...)` so a processed + action succeeds only when the locked private `lastActionReceipt` exactly matches the proposed + canonical receipt and expected next version. Apply the same exact-receipt rule to dynamic + scoring-job creation; TypeScript success and error reloads must independently verify it. - Creation owner-locks a v2 case, validates expected version/action/question/job/fingerprint/ algorithm, atomically persists the advanced public turn, private dynamic state, canonical action receipt, and one pending job, and replays only the identical completed action. @@ -930,6 +934,7 @@ private-state boundary or leaving browser polling unable to complete. **Files:** - Create: `frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql` +- Create: `frontend/supabase/migrations/20260718095000_dynamic_choice_exact_action_receipts.sql` - Create: `frontend/src/lib/birth-time-dynamic-transitions.ts` - Create: `frontend/src/lib/birth-time-dynamic-actions.ts` - Create: `frontend/src/lib/birth-time-dynamic-scoring-service.ts` diff --git a/frontend/src/lib/birth-time-dynamic-action-receipt.ts b/frontend/src/lib/birth-time-dynamic-action-receipt.ts new file mode 100644 index 00000000..55c9ca30 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-action-receipt.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +type ReceiptBase = { + readonly actionId: string; + readonly turnVersion: number; +}; + +export type DynamicActionReceipt = ReceiptBase & ( + | { readonly kind: "answer_choice"; readonly questionId: string; readonly optionId: string } + | { + readonly kind: "commit_question"; + readonly outcome: "question" | "terminal"; + readonly questionId: string | null; + readonly questionFingerprint: string | null; + readonly partitionFingerprint: string | null; + readonly submittedQuestionFingerprint: string | null; + readonly submittedPartitionFingerprint: string | null; + } + | { readonly kind: "unmatched_context"; readonly questionId: string; readonly note: string } + | { readonly kind: "pause" } + | { readonly kind: "finish" } + | { readonly kind: "resume" } +); + +const receiptBase = { + actionId: z.string().uuid().refine((value) => value === value.toLowerCase()), + turnVersion: z.number().int().nonnegative(), +} as const; +const identifier = z.string().trim().min(1); + +export const dynamicActionReceiptSchema: z.ZodType = z.union([ + z.object({ + ...receiptBase, + kind: z.literal("answer_choice"), + questionId: identifier, + optionId: identifier, + }).strict(), + z.object({ + ...receiptBase, + kind: z.literal("commit_question"), + outcome: z.literal("question"), + questionId: identifier, + questionFingerprint: identifier, + partitionFingerprint: identifier, + submittedQuestionFingerprint: identifier, + submittedPartitionFingerprint: identifier, + }).strict(), + z.object({ + ...receiptBase, + kind: z.literal("commit_question"), + outcome: z.literal("terminal"), + questionId: z.null(), + questionFingerprint: z.null(), + partitionFingerprint: z.null(), + submittedQuestionFingerprint: identifier.nullable(), + submittedPartitionFingerprint: identifier.nullable(), + }).strict(), + z.object({ + ...receiptBase, + kind: z.literal("unmatched_context"), + questionId: identifier, + note: z.string().max(240), + }).strict(), + z.object({ ...receiptBase, kind: z.literal("pause") }).strict(), + z.object({ ...receiptBase, kind: z.literal("finish") }).strict(), + z.object({ ...receiptBase, kind: z.literal("resume") }).strict(), +]).readonly(); diff --git a/frontend/src/lib/birth-time-dynamic-action-replay.ts b/frontend/src/lib/birth-time-dynamic-action-replay.ts index 0a898e68..3ccbe1b8 100644 --- a/frontend/src/lib/birth-time-dynamic-action-replay.ts +++ b/frontend/src/lib/birth-time-dynamic-action-replay.ts @@ -1,4 +1,5 @@ import type { DynamicStoredRectificationCase } from "./birth-time-journey-service.ts"; +import { dynamicActionReceiptSchema } from "./birth-time-dynamic-action-receipt.ts"; import { StaleJourneyTurnError } from "./birth-time-journey-store-errors.ts"; export function replayedDynamicAction( @@ -20,11 +21,10 @@ export function samePersistedDynamicReceipt( ): boolean { const expected = proposed.dynamicControl.lastActionReceipt; const actual = current.dynamicControl.lastActionReceipt; - if (expected?.actionId !== actionId) return actual?.actionId !== actionId; + if (expected?.actionId !== actionId) return false; 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; + && actual !== null + && actual !== undefined + && JSON.stringify(dynamicActionReceiptSchema.parse(actual)) + === JSON.stringify(dynamicActionReceiptSchema.parse(expected)); } diff --git a/frontend/src/lib/birth-time-dynamic-actions.ts b/frontend/src/lib/birth-time-dynamic-actions.ts index e4c667b8..c682851f 100644 --- a/frontend/src/lib/birth-time-dynamic-actions.ts +++ b/frontend/src/lib/birth-time-dynamic-actions.ts @@ -91,9 +91,14 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) { const stored = await load(userId, command.caseId); const lastAnswer = stored.choiceAnswers.at(-1); const actionKind = stored.dynamicTurnState.nextAction.kind; + const receipt = stored.dynamicControl.lastActionReceipt; if (replayedDynamicAction(stored, command.actionId, command.turnVersion, () => ( lastAnswer?.questionId === command.questionId && lastAnswer.optionId === command.optionId - && stored.dynamicControl.lastActionReceipt?.actionId !== command.actionId.toLowerCase() + && receipt?.kind === "answer_choice" + && receipt.actionId === command.actionId.toLowerCase() + && receipt.turnVersion === command.turnVersion + && receipt.questionId === command.questionId + && receipt.optionId === command.optionId && (lastAnswer.kind === "primary" ? actionKind === "score_pending" : lastAnswer.kind === "unknown" @@ -113,13 +118,26 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) { } const option = question.options.find((item) => item.optionId === command.optionId); if (!option) throw stale(stored, command.turnVersion); - const updated = answerTransition({ + const transitioned = answerTransition({ stored, option, answeredAt: (ports.now?.() ?? new Date()).toISOString(), jobId: globalThis.crypto.randomUUID(), nextVersion: stored.turnVersion + 1, }); + const updated = { + ...transitioned, + dynamicControl: { + ...transitioned.dynamicControl, + lastActionReceipt: { + actionId: command.actionId.toLowerCase(), + kind: "answer_choice" as const, + turnVersion: command.turnVersion, + questionId: command.questionId, + optionId: command.optionId, + }, + }, + }; if (option.kind === "primary") { const createJob = ports.store.createDynamicScoringJob; if (!createJob) throw new BirthTimeDynamicActionError("unavailable"); @@ -159,13 +177,13 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) { question: PersistedDynamicChoiceQuestion | null, ) { const stored = await load(userId, command.caseId); + const receipt = stored.dynamicControl.lastActionReceipt; 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() + receipt?.kind === "commit_question" + && receipt.actionId === command.actionId.toLowerCase() + && receipt.turnVersion === command.turnVersion + && receipt.submittedQuestionFingerprint === (question?.questionFingerprint ?? null) + && receipt.submittedPartitionFingerprint === (question?.candidatePartitionFingerprint ?? null) ))) { return { nextAction: stored.dynamicTurnState.nextAction }; } @@ -184,14 +202,28 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) { ? { kind: "present_low_result" as const, resultId: stored.candidateResult?.resultId ?? null } : publicQuestionAction(nextQuestion); const updated = withDynamicAction(stored, action, stored.turnVersion + 1); + const priorControl = nextQuestion === null ? stored.dynamicControl : { + ...stored.dynamicControl, + questionFingerprints: [...stored.dynamicControl.questionFingerprints, nextQuestion.questionFingerprint], + partitionFingerprints: [...stored.dynamicControl.partitionFingerprints, nextQuestion.candidatePartitionFingerprint], + }; const saved = await save(stored, { ...updated, snapshot: nextQuestion === null ? terminalSnapshot(stored) : stored.snapshot, currentChoiceQuestion: nextQuestion, - dynamicControl: nextQuestion === null ? stored.dynamicControl : { - ...stored.dynamicControl, - questionFingerprints: [...stored.dynamicControl.questionFingerprints, nextQuestion.questionFingerprint], - partitionFingerprints: [...stored.dynamicControl.partitionFingerprints, nextQuestion.candidatePartitionFingerprint], + dynamicControl: { + ...priorControl, + lastActionReceipt: { + actionId: command.actionId.toLowerCase(), + kind: "commit_question" as const, + turnVersion: command.turnVersion, + outcome: nextQuestion === null ? "terminal" as const : "question" as const, + questionId: nextQuestion?.questionId ?? null, + questionFingerprint: nextQuestion?.questionFingerprint ?? null, + partitionFingerprint: nextQuestion?.candidatePartitionFingerprint ?? null, + submittedQuestionFingerprint: question?.questionFingerprint ?? null, + submittedPartitionFingerprint: question?.candidatePartitionFingerprint ?? null, + }, }, }, command.actionId); return { nextAction: saved.dynamicTurnState.nextAction }; @@ -211,10 +243,17 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) { : paused; if (action === null) throw new BirthTimeDynamicActionError("invalid_turn"); const updated = withDynamicAction(stored, action, stored.turnVersion + 1); + const actionId = globalThis.crypto.randomUUID(); const saved = await save(stored, { ...updated, - dynamicControl: { ...stored.dynamicControl, pausedAction: null }, - }, globalThis.crypto.randomUUID()); + dynamicControl: { + ...stored.dynamicControl, + pausedAction: null, + lastActionReceipt: { + actionId, kind: "resume", turnVersion: stored.turnVersion, + }, + }, + }, actionId); return storedDynamicJourneyResponse(saved); }, diff --git a/frontend/src/lib/birth-time-dynamic-choice-internal.ts b/frontend/src/lib/birth-time-dynamic-choice-internal.ts index cb0a9bd7..e9a31b8e 100644 --- a/frontend/src/lib/birth-time-dynamic-choice-internal.ts +++ b/frontend/src/lib/birth-time-dynamic-choice-internal.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { candidateResultSchema } from "./birth-time-evidence.ts"; +import { dynamicActionReceiptSchema } from "./birth-time-dynamic-action-receipt.ts"; import { DYNAMIC_QUESTION_LABEL_MAX_LENGTH, DYNAMIC_QUESTION_PROMPT_MAX_LENGTH, @@ -11,6 +12,7 @@ import { validateOptionSet, } from "./birth-time-dynamic-choice.ts"; import type { CandidateResult } from "./birth-time-evidence.ts"; +import type { DynamicActionReceipt } from "./birth-time-dynamic-action-receipt.ts"; import type { PublicChoiceKind, PublicDynamicChoiceQuestion, TimeRange } from "./birth-time-dynamic-choice.ts"; const finiteScoresSchema = z.record(z.number().finite()); @@ -121,13 +123,7 @@ 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; + readonly lastActionReceipt?: DynamicActionReceipt | null; }; const evidencePartitionBaseSchema = z.object({ @@ -245,13 +241,7 @@ 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(), + lastActionReceipt: dynamicActionReceiptSchema.nullable().optional(), }).strict().readonly(); export function toPublicDynamicChoiceQuestion( diff --git a/frontend/src/lib/birth-time-dynamic-scoring-job-store.ts b/frontend/src/lib/birth-time-dynamic-scoring-job-store.ts index 21dfa2c4..ad46c63a 100644 --- a/frontend/src/lib/birth-time-dynamic-scoring-job-store.ts +++ b/frontend/src/lib/birth-time-dynamic-scoring-job-store.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { samePersistedDynamicReceipt } from "./birth-time-dynamic-action-replay.ts"; import { BirthTimeScoringJobError } from "./birth-time-scoring-job.ts"; import type { BirthTimeJourneyStore, @@ -89,7 +90,10 @@ export function createDynamicScoringJobStore( if (result.error) { const current = await loadCase(value.userId, value.id); if (current?.journeyProtocol === "dynamic-choice-v2" - && current.processedActionIds.includes(receipt)) return current; + && current.processedActionIds.includes(receipt)) { + if (samePersistedDynamicReceipt(value, current, receipt, expectedVersion)) return current; + throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion); + } if (result.error.message.includes("stale_birth_time_dynamic_scoring_job")) { throw new StaleJourneyTurnError(value.id, expectedVersion, current?.turnVersion ?? 0); } @@ -99,7 +103,12 @@ export function createDynamicScoringJobStore( if (!version.success || version.data !== expectedVersion + 1) { throw new BirthTimeJourneyStoreError("update_case"); } - return loadDynamic(loadCase, value.userId, value.id); + const current = await loadDynamic(loadCase, value.userId, value.id); + if (!current.processedActionIds.includes(receipt) + || !samePersistedDynamicReceipt(value, current, receipt, expectedVersion)) { + throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion); + } + return current; }, async claimDynamicScoringJob(identity) { diff --git a/frontend/src/lib/birth-time-journey-dynamic-persistence.ts b/frontend/src/lib/birth-time-journey-dynamic-persistence.ts index 021532b5..1d8f4ae5 100644 --- a/frontend/src/lib/birth-time-journey-dynamic-persistence.ts +++ b/frontend/src/lib/birth-time-journey-dynamic-persistence.ts @@ -158,8 +158,14 @@ export function createDynamicTurnPersistence( } throw new BirthTimeJourneyStoreError("update_case"); } - rpcVersionSchema.parse(result.data); - return loadedDynamic(value.userId, value.id); + const version = rpcVersionSchema.parse(result.data); + const current = await loadedDynamic(value.userId, value.id); + if (version !== expectedVersion + 1 + || !current.processedActionIds.includes(receipt) + || !samePersistedDynamicReceipt(value, current, receipt, expectedVersion)) { + throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion); + } + return current; }, async completeDynamicScoringJob( 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 d8d385bb..e89f17bc 100644 --- a/frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql +++ b/frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql @@ -9,7 +9,9 @@ create function public.create_birth_time_dynamic_scoring_job( ) returns bigint language plpgsql security definer set search_path = '' as $$ declare v_case public.birth_time_rectification_cases%rowtype; + v_private public.birth_time_rectification_dynamic_state%rowtype; v_job public.birth_time_rectification_scoring_jobs%rowtype; + v_receipt jsonb; v_new_version bigint; begin select c.* into v_case from public.birth_time_rectification_cases c @@ -17,6 +19,19 @@ begin and c.journey_protocol = 'dynamic-choice-v2' for update; if not found then raise exception 'birth_time_dynamic_case_not_found'; end if; + select s.* into v_private from public.birth_time_rectification_dynamic_state s + where s.case_id = p_case_id and s.user_id = p_user_id for update; + if not found then raise exception 'birth_time_dynamic_private_state_missing'; end if; + v_receipt = p_private_state #> '{dynamicControl,lastActionReceipt}'; + if jsonb_typeof(v_receipt) is distinct from 'object' + or v_receipt ->> 'actionId' is distinct from p_action_id::text + or v_receipt ->> 'kind' is distinct from 'answer_choice' + or (v_receipt ->> 'turnVersion')::bigint is distinct from p_expected_version + or v_receipt ->> 'questionId' is distinct from p_question_id + or coalesce(v_receipt ->> 'optionId', '') = '' then + raise exception 'birth_time_dynamic_scoring_turn_invalid'; + end if; + if p_action_id = any(v_case.processed_action_ids) then 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; @@ -24,6 +39,9 @@ begin or v_case.turn_version is distinct from p_expected_version + 1 or v_case.turn_state #>> '{nextAction,kind}' is distinct from 'score_pending' or v_case.turn_state #>> '{nextAction,jobId}' is distinct from p_job_id::text + or v_private.dynamic_control -> 'lastActionReceipt' is distinct from v_receipt + or v_private.dynamic_control #>> '{lastActionReceipt,actionId}' + is distinct from p_action_id::text or v_job.evidence_fingerprint is distinct from p_evidence_fingerprint or v_job.algorithm_version is distinct from p_algorithm_version then raise exception 'stale_birth_time_dynamic_scoring_job'; diff --git a/frontend/supabase/migrations/20260718095000_dynamic_choice_exact_action_receipts.sql b/frontend/supabase/migrations/20260718095000_dynamic_choice_exact_action_receipts.sql new file mode 100644 index 00000000..9fc81be1 --- /dev/null +++ b/frontend/supabase/migrations/20260718095000_dynamic_choice_exact_action_receipts.sql @@ -0,0 +1,84 @@ +begin; + +create or replace function public.save_birth_time_dynamic_turn( + p_user_id uuid, p_case_id uuid, p_expected_version bigint, p_action_id uuid, + p_public_turn_state jsonb, p_snapshot jsonb, p_candidate_result jsonb, + p_private_state jsonb +) returns bigint language plpgsql security definer set search_path = '' as $$ +declare + v_case public.birth_time_rectification_cases%rowtype; + v_private public.birth_time_rectification_dynamic_state%rowtype; + v_receipt jsonb; + v_new_version bigint; +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 for update; + if not found or v_case.journey_protocol is distinct from 'dynamic-choice-v2' then + raise exception 'birth_time_dynamic_case_not_found'; + end if; + select s.* into v_private from public.birth_time_rectification_dynamic_state s + where s.case_id = p_case_id and s.user_id = p_user_id for update; + if not found then raise exception 'birth_time_dynamic_private_state_missing'; end if; + v_receipt = p_private_state #> '{dynamicControl,lastActionReceipt}'; + if jsonb_typeof(v_receipt) is distinct from 'object' + or v_receipt ->> 'actionId' is distinct from p_action_id::text + or (v_receipt ->> 'turnVersion')::bigint is distinct from p_expected_version then + raise exception 'birth_time_dynamic_turn_invalid'; + end if; + if p_action_id = any(v_case.processed_action_ids) then + if v_case.turn_version is distinct from p_expected_version + 1 + or v_private.dynamic_control -> 'lastActionReceipt' is distinct from v_receipt + or v_private.dynamic_control #>> '{lastActionReceipt,actionId}' + is distinct from p_action_id::text then + raise exception 'stale_birth_time_dynamic_turn'; + end if; + return v_case.turn_version; + end if; + if v_case.turn_version is distinct from p_expected_version + or p_public_turn_state ->> 'journeyProtocol' is distinct from 'dynamic-choice-v2' + or (p_public_turn_state ->> 'turnVersion')::bigint is distinct from p_expected_version + 1 + or jsonb_typeof(p_private_state) is distinct from 'object' + or jsonb_path_exists(p_public_turn_state, '$.**.partitionId') + or jsonb_path_exists(p_public_turn_state, '$.**.candidateScores') + or jsonb_path_exists(p_public_turn_state, '$.**.agentContext') then + raise exception 'birth_time_dynamic_turn_invalid'; + end if; + update public.birth_time_rectification_cases + set status = case p_snapshot ->> 'state' + when 'ready' then 'confirmed' when 'confirming' then 'confirming' + when 'candidate' then 'candidate' else 'rectifying' end, + journey_snapshot = p_snapshot, + candidate_result = coalesce(p_candidate_result, '{}'::jsonb), + event_scoring_version = p_candidate_result ->> 'algorithmVersion', + candidate_result_id = case when p_candidate_result ? 'resultId' + then (p_candidate_result ->> 'resultId')::uuid else null end, + candidate_start = case + when p_candidate_result #>> '{winningSegment,startTime}' is null then null + else (p_candidate_result #>> '{winningSegment,startTime}')::time end, + candidate_end = case + when p_candidate_result #>> '{winningSegment,endTime}' is null then null + else (p_candidate_result #>> '{winningSegment,endTime}')::time end, + turn_version = p_expected_version + 1, turn_state = p_public_turn_state, + evidence_draft = null, + processed_action_ids = case when cardinality(processed_action_ids) >= 100 + then processed_action_ids[2:100] || p_action_id + else processed_action_ids || p_action_id end, + updated_at = now() + where id = p_case_id and user_id = p_user_id and turn_version = p_expected_version + returning turn_version into v_new_version; + if v_new_version is null then raise exception 'stale_birth_time_dynamic_turn'; end if; + perform public.persist_birth_time_dynamic_private_state( + p_case_id, p_user_id, p_private_state + ); + return v_new_version; +end; +$$; + +revoke all on function public.save_birth_time_dynamic_turn( + uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb +) from public, anon, authenticated; +grant execute on function public.save_birth_time_dynamic_turn( + uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb +) to service_role; + +commit; diff --git a/frontend/tests/birth-time-dynamic-action-receipt.test.ts b/frontend/tests/birth-time-dynamic-action-receipt.test.ts new file mode 100644 index 00000000..ee300681 --- /dev/null +++ b/frontend/tests/birth-time-dynamic-action-receipt.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { dynamicActionReceiptSchema } from "../src/lib/birth-time-dynamic-action-receipt.ts"; + +const base = { + actionId: "700406ad-1ca6-437d-9f77-61354ba8e36a", + turnVersion: 7, +} as const; + +test("dynamic receipts strictly bind every user mutation payload", () => { + for (const receipt of [ + { ...base, kind: "answer_choice", questionId: "question", optionId: "option" }, + { + ...base, + kind: "commit_question", + outcome: "question", + questionId: "question", + questionFingerprint: "question-fingerprint", + partitionFingerprint: "partition-fingerprint", + submittedQuestionFingerprint: "question-fingerprint", + submittedPartitionFingerprint: "partition-fingerprint", + }, + { ...base, kind: "unmatched_context", questionId: "question", note: "大约 2017 年" }, + { ...base, kind: "pause" }, + { ...base, kind: "finish" }, + ]) assert.equal(dynamicActionReceiptSchema.safeParse(receipt).success, true); +}); + +test("dynamic receipts reject missing, cross-kind, and noncanonical fields", () => { + for (const receipt of [ + { ...base, kind: "answer_choice", questionId: "question" }, + { ...base, kind: "pause", note: "forged" }, + { ...base, actionId: base.actionId.toUpperCase(), kind: "finish" }, + { + ...base, + kind: "commit_question", + outcome: "terminal", + questionId: "forged-question", + questionFingerprint: null, + partitionFingerprint: null, + submittedQuestionFingerprint: null, + submittedPartitionFingerprint: null, + }, + ]) assert.equal(dynamicActionReceiptSchema.safeParse(receipt).success, false); +}); diff --git a/frontend/tests/birth-time-dynamic-idempotence.test.ts b/frontend/tests/birth-time-dynamic-idempotence.test.ts index 2a66610f..bb0ab15e 100644 --- a/frontend/tests/birth-time-dynamic-idempotence.test.ts +++ b/frontend/tests/birth-time-dynamic-idempotence.test.ts @@ -9,13 +9,13 @@ 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()); +function flow(initial = dynamicCase()) { + const memory = memoryStore(initial); const jobs = dynamicJobStore(memory.store, () => { const current = memory.savedCase(); return current?.journeyProtocol === "dynamic-choice-v2" ? current : null; }); - return createBirthTimeJourneyService({ + const service = createBirthTimeJourneyService({ store: jobs.store, engine: { async scan() { throw new Error("unexpected scan"); }, @@ -23,10 +23,11 @@ function flow() { async scoreEvents() { throw new Error("unexpected event score"); }, }, }); + return { memory, service }; } test("unmatched context replays only the identical action and payload", async () => { - const service = flow(); + 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, { @@ -58,7 +59,7 @@ test("unmatched context replays only the identical action and payload", async () }); test("pause replays after a lost response but cannot impersonate finish", async () => { - const service = flow(); + 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); @@ -70,7 +71,7 @@ test("pause replays after a lost response but cannot impersonate finish", async }); test("finish replays after a lost response but cannot impersonate pause", async () => { - const service = flow(); + 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); @@ -80,3 +81,54 @@ test("finish replays after a lost response but cannot impersonate pause", async StaleJourneyTurnError, ); }); + +test("answer replay is bound to the exact question and option", async () => { + const { memory, service } = flow(); + const unknown = persistedQuestion.options.find((option) => option.kind === "unknown"); + const unmatched = persistedQuestion.options.find((option) => option.kind === "unmatched"); + if (!unknown || !unmatched) throw new Error("missing special options"); + const command = { + caseId: dynamicCase().id, + actionId, + turnVersion: 7, + questionId: persistedQuestion.questionId, + optionId: unknown.optionId, + }; + const saved = await service.answerDynamicChoice(ownerId, command); + const replay = await service.answerDynamicChoice(ownerId, command); + assert.equal(replay.turnVersion, saved.turnVersion); + assert.equal(memory.savedCase()?.dynamicControl?.lastActionReceipt?.kind, "answer_choice"); + await assert.rejects( + service.answerDynamicChoice(ownerId, { ...command, optionId: unmatched.optionId }), + StaleJourneyTurnError, + ); +}); + +test("question commit replay is bound to the submitted fingerprints", async () => { + const initial = { + ...dynamicCase(), + currentChoiceQuestion: null, + dynamicTurnState: { + ...dynamicCase().dynamicTurnState, + nextAction: { kind: "generate_dynamic_question" as const }, + }, + }; + const { memory, service } = flow(initial); + const question = { + ...persistedQuestion, + questionId: "af34edbf-b4b0-4ebf-9a07-5c177bc73add", + questionFingerprint: "fresh-question", + candidatePartitionFingerprint: "fresh-partition", + }; + const command = { caseId: initial.id, actionId, turnVersion: 7, unmatchedNote: null }; + const saved = await service.commitDynamicQuestion(ownerId, command, question); + const replay = await service.commitDynamicQuestion(ownerId, command, question); + assert.deepEqual(replay.nextAction, saved.nextAction); + assert.equal(memory.savedCase()?.dynamicControl?.lastActionReceipt?.kind, "commit_question"); + await assert.rejects( + service.commitDynamicQuestion(ownerId, command, { + ...question, questionFingerprint: "changed-question", + }), + StaleJourneyTurnError, + ); +}); diff --git a/frontend/tests/birth-time-dynamic-job-lease.test.ts b/frontend/tests/birth-time-dynamic-job-lease.test.ts index 209f9046..3513ec7e 100644 --- a/frontend/tests/birth-time-dynamic-job-lease.test.ts +++ b/frontend/tests/birth-time-dynamic-job-lease.test.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts"; +import { completeDynamicScoreTransition } from "../src/lib/birth-time-dynamic-transitions.ts"; import { + BirthTimeScoringJobError, dynamicChoiceScoringAlgorithmVersion, dynamicEvidenceFingerprint, } from "../src/lib/birth-time-scoring-job.ts"; @@ -47,4 +49,43 @@ test("dynamic processing claims are reclaimed only after the lease", async () => 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"); + + const processing = memory.savedCase(); + if (!processing || processing.journeyProtocol !== "dynamic-choice-v2") throw new Error("missing processing case"); + const candidate = { + resultId: "097b7b4c-60f3-4ed8-b290-64b2084182e7", + confidence: "low" as const, + canApply: false, + winningSegment: null, + eventCount: 1, + domainCount: 1, + topScore: 10, + secondScore: 9, + marginPercent: 10, + reasons: ["insufficient_effective_evidence"], + evidence: [], + algorithmVersion: dynamicChoiceScoringAlgorithmVersion, + }; + const completedTurn = completeDynamicScoreTransition({ + stored: processing, + candidate, + usefulOpportunityCount: 1, + repeatedOnly: false, + nextVersion: processing.turnVersion + 1, + }); + await jobs.store.completeDynamicScoringJob(completedTurn, { + expectedVersion: processing.turnVersion, + jobId: identity.jobId, + evidenceFingerprint: identity.evidenceFingerprint, + algorithmVersion: identity.algorithmVersion, + }); + assert.equal((await claim({ ...identity, now: "2026-07-18T08:02:00.000Z" })).kind, "completed"); + + const coherent = memory.savedCase(); + if (!coherent || coherent.journeyProtocol !== "dynamic-choice-v2") throw new Error("missing completed case"); + memory.replaceCase({ ...coherent, candidateResult: null }); + await assert.rejects( + claim({ ...identity, now: "2026-07-18T08:02:01.000Z" }), + BirthTimeScoringJobError, + ); }); diff --git a/frontend/tests/birth-time-dynamic-job-memory-store.ts b/frontend/tests/birth-time-dynamic-job-memory-store.ts index 669ce2ab..10adb802 100644 --- a/frontend/tests/birth-time-dynamic-job-memory-store.ts +++ b/frontend/tests/birth-time-dynamic-job-memory-store.ts @@ -1,7 +1,9 @@ +import { isDeepStrictEqual } from "node:util"; import type { BirthTimeJourneyStore, DynamicStoredRectificationCase, } from "../src/lib/birth-time-journey-service.ts"; +import { samePersistedDynamicReceipt } from "../src/lib/birth-time-dynamic-action-replay.ts"; import type { DynamicScoringJobSpec } 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"; @@ -11,8 +13,25 @@ type DynamicMemoryJob = DynamicScoringJobSpec & { readonly userId: string; readonly status: "pending" | "processing" | "completed" | "failed"; readonly updatedAt: string; + readonly result: NonNullable | null; }; +function completedMatches( + job: DynamicMemoryJob, + current: DynamicStoredRectificationCase, +): boolean { + const result = job.result; + const action = current.dynamicTurnState.nextAction; + if (result === null || !isDeepStrictEqual(current.candidateResult, result)) return false; + if (result.confidence === "high") { + return action.kind === "request_candidate_confirmation" && action.resultId === result.resultId; + } + if (action.kind === "generate_dynamic_question") return true; + return result.confidence === "medium" + ? action.kind === "present_medium_result" && action.resultId === result.resultId + : action.kind === "present_low_result" && action.resultId === result.resultId; +} + export function dynamicJobStore( base: BirthTimeJourneyStore, read: () => DynamicStoredRectificationCase | null, @@ -24,7 +43,10 @@ export function dynamicJobStore( const current = read(); if (!current) throw new BirthTimeScoringJobError("unavailable"); const receipt = actionId.toLowerCase(); - if (current.processedActionIds.includes(receipt)) return current; + if (current.processedActionIds.includes(receipt)) { + if (samePersistedDynamicReceipt(value, current, receipt, expectedVersion)) return current; + throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion); + } if (current.turnVersion !== expectedVersion) { throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion); } @@ -34,6 +56,7 @@ export function dynamicJobStore( userId: value.userId, status: "pending", updatedAt: new Date(Date.parse(spec.expiresAt) - scoringJobDurationMs).toISOString(), + result: null, }); return base.saveDynamicTurn(value, expectedVersion, receipt); }, @@ -46,9 +69,15 @@ export function dynamicJobStore( if (job.algorithmVersion !== identity.algorithmVersion) { throw new BirthTimeScoringJobError("algorithm_mismatch"); } + const current = read(); + if (!current) throw new BirthTimeScoringJobError("unavailable"); if (job.status === "completed") { + if (!completedMatches(job, current)) throw new BirthTimeScoringJobError("invalid_turn"); return { kind: "completed", algorithmVersion: job.algorithmVersion }; } + const action = current.dynamicTurnState.nextAction; + if ((action.kind !== "score_pending" && action.kind !== "retry_scoring") + || action.jobId !== identity.jobId) throw new BirthTimeScoringJobError("invalid_turn"); if (job.status === "processing") { const leaseEnds = Date.parse(job.updatedAt) + scoringProcessingLeaseMs; if (Date.parse(identity.now) < leaseEnds) { @@ -64,7 +93,7 @@ export function dynamicJobStore( throw new BirthTimeScoringJobError("invalid_turn"); } const saved = await base.completeDynamicScoringJob(value, command); - jobs.set(job.jobId, { ...job, status: "completed" }); + jobs.set(job.jobId, { ...job, status: "completed", result: value.candidateResult ?? null }); return saved; }, async failDynamicScoringJob(value, command) { diff --git a/frontend/tests/birth-time-dynamic-persistence-fixture.ts b/frontend/tests/birth-time-dynamic-persistence-fixture.ts index eaf9d256..862b880e 100644 --- a/frontend/tests/birth-time-dynamic-persistence-fixture.ts +++ b/frontend/tests/birth-time-dynamic-persistence-fixture.ts @@ -1,8 +1,6 @@ import { createDynamicTurnPersistence } from "../src/lib/birth-time-journey-dynamic-persistence.ts"; -import type { - DynamicStoredRectificationCase, - LegacyStoredRectificationCase, -} from "../src/lib/birth-time-journey-service.ts"; +import { dynamicPrivateStateSchema } from "../src/lib/birth-time-journey-dynamic-state.ts"; +import type { DynamicStoredRectificationCase, LegacyStoredRectificationCase } from "../src/lib/birth-time-journey-service.ts"; export const caseId = "45857b75-4718-4590-aaf5-7113a03ea765"; export const ownerId = "12dc56f0-1f17-4a2f-86bf-1056ab78def9"; @@ -205,8 +203,10 @@ export function rpcPersistence() { if (args.p_expected_version !== saved.turnVersion) { return { data: null, error: { message: "stale_birth_time_dynamic_turn" } }; } + const privateState = dynamicPrivateStateSchema.parse(args.p_private_state); saved = { ...saved, + ...privateState, turnVersion: saved.turnVersion + 1, dynamicTurnState: { ...saved.dynamicTurnState, turnVersion: saved.turnVersion + 1 }, processedActionIds: [...saved.processedActionIds, receivedAction], diff --git a/frontend/tests/birth-time-dynamic-persistence.test.ts b/frontend/tests/birth-time-dynamic-persistence.test.ts index e52f5d40..19b6a2f3 100644 --- a/frontend/tests/birth-time-dynamic-persistence.test.ts +++ b/frontend/tests/birth-time-dynamic-persistence.test.ts @@ -1,23 +1,13 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - BirthTimeDynamicStateMissingError, - createDynamicTurnPersistence, -} from "../src/lib/birth-time-journey-dynamic-persistence.ts"; +import { BirthTimeDynamicStateMissingError, createDynamicTurnPersistence } from "../src/lib/birth-time-journey-dynamic-persistence.ts"; import { saveDynamicAssessment } from "../src/lib/birth-time-journey-dynamic-case.ts"; -import { - BirthTimeJourneyStoreError, - loadStoredRectificationCase, - StaleJourneyTurnError, -} from "../src/lib/birth-time-journey-turn-persistence.ts"; +import { BirthTimeJourneyStoreError, loadStoredRectificationCase, StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts"; import type { StoredRectificationCase } from "../src/lib/birth-time-journey-service.ts"; import { assessBirthTime } from "../src/lib/birth-time-journey.ts"; import { dynamicJourneyTurnStateSchema } from "../src/lib/birth-time-journey-turn-protocol.ts"; import { assertLegacyJourneyMutation } from "../src/lib/birth-time-evidence-service.ts"; -import { - createInitialDynamicState, - dynamicPrivateStateSchema, -} from "../src/lib/birth-time-journey-dynamic-state.ts"; +import { createInitialDynamicState, dynamicPrivateStateSchema } from "../src/lib/birth-time-journey-dynamic-state.ts"; import { actionId, caseId, @@ -31,6 +21,7 @@ import { snapshot, } from "./birth-time-dynamic-persistence-fixture.ts"; import { memoryStore } from "./birth-time-journey-memory-store.ts"; +import { savedPauseReceipt, withPauseReceipt } from "./birth-time-dynamic-receipt-test-support.ts"; test("v2 load restores the exact private question and candidate model", async () => { const loaded = await loadStoredRectificationCase(loadClient(privateRow), ownerId, caseId); @@ -157,7 +148,7 @@ test("v2 cases cannot fall through to legacy mutation paths", () => { test("saveDynamicTurn persists a private snapshot and a privacy-safe public turn once", async () => { const fake = rpcPersistence(); - const updated = dynamicCase(); + const updated = withPauseReceipt(dynamicCase(), actionId); const first = await fake.persistence.saveDynamicTurn(updated, 7, actionId); const replay = await fake.persistence.saveDynamicTurn(updated, 7, actionId); @@ -192,10 +183,13 @@ test("saveDynamicTurn reports a stale version for an unprocessed action", async test("memory replay returns the stored advanced dynamic turn", async () => { const initial = dynamicCase(); const memory = memoryStore(initial); - const changed = { ...initial, agentContext: ["persisted context"] }; + const changed = withPauseReceipt({ + ...initial, + agentContext: ["persisted context"], + }, actionId); const saved = await memory.store.saveDynamicTurn(changed, 7, actionId); - const replay = await memory.store.saveDynamicTurn(initial, 7, actionId.toUpperCase()); + const replay = await memory.store.saveDynamicTurn(changed, 7, actionId.toUpperCase()); assert.deepEqual(replay, saved); assert.equal(replay.turnVersion, 8); @@ -205,9 +199,12 @@ test("memory replay returns the stored advanced dynamic turn", async () => { }); test("memory store replays seeded v2 receipts and executes legacy upgrades", async () => { - const seeded = { ...dynamicCase(), processedActionIds: [actionId] }; + const initial = dynamicCase(); + const seeded = savedPauseReceipt(initial, actionId); const dynamicMemory = memoryStore(seeded); - const replay = await dynamicMemory.store.saveDynamicTurn(dynamicCase(), 7, actionId); + const replay = await dynamicMemory.store.saveDynamicTurn( + withPauseReceipt(initial, actionId), 7, actionId, + ); assert.equal(replay, seeded); const legacyMemory = memoryStore(legacyCase(true)); diff --git a/frontend/tests/birth-time-dynamic-receipt-test-support.ts b/frontend/tests/birth-time-dynamic-receipt-test-support.ts new file mode 100644 index 00000000..5be4ca45 --- /dev/null +++ b/frontend/tests/birth-time-dynamic-receipt-test-support.ts @@ -0,0 +1,29 @@ +import type { DynamicStoredRectificationCase } from "../src/lib/birth-time-journey-service.ts"; + +export function withPauseReceipt( + value: DynamicStoredRectificationCase, + actionId: string, + turnVersion = 7, +): DynamicStoredRectificationCase { + return { + ...value, + dynamicControl: { + ...value.dynamicControl, + lastActionReceipt: { actionId, kind: "pause", turnVersion }, + }, + }; +} + +export function savedPauseReceipt( + value: DynamicStoredRectificationCase, + actionId: string, + turnVersion = 7, +): DynamicStoredRectificationCase { + const received = withPauseReceipt(value, actionId, turnVersion); + return { + ...received, + turnVersion: turnVersion + 1, + dynamicTurnState: { ...received.dynamicTurnState, turnVersion: turnVersion + 1 }, + processedActionIds: [actionId], + }; +} diff --git a/frontend/tests/birth-time-dynamic-rpc-replay.test.ts b/frontend/tests/birth-time-dynamic-rpc-replay.test.ts new file mode 100644 index 00000000..9600dc77 --- /dev/null +++ b/frontend/tests/birth-time-dynamic-rpc-replay.test.ts @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createDynamicScoringJobStore } from "../src/lib/birth-time-dynamic-scoring-job-store.ts"; +import { createDynamicTurnPersistence } from "../src/lib/birth-time-journey-dynamic-persistence.ts"; +import { createDynamicScoringJobSpec } from "../src/lib/birth-time-scoring-job.ts"; +import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts"; +import { answerTransition } from "../src/lib/birth-time-dynamic-transitions.ts"; +import type { DynamicStoredRectificationCase } from "../src/lib/birth-time-journey-service.ts"; +import { actionId, dynamicCase, persistedQuestion } from "./birth-time-dynamic-persistence-fixture.ts"; + +function savedTurn( + kind: "pause" | "finish" = "pause", +): DynamicStoredRectificationCase { + const stored = dynamicCase(); + return { + ...stored, + turnVersion: 8, + processedActionIds: [actionId], + dynamicTurnState: { + ...stored.dynamicTurnState, + turnVersion: 8, + nextAction: kind === "pause" ? { kind: "paused" } : { kind: "present_low_result", resultId: null }, + }, + dynamicControl: { + ...stored.dynamicControl, + lastActionReceipt: { actionId, kind, turnVersion: 7 }, + }, + }; +} + +test("normal and duplicate RPC success reload the exact receipt", async () => { + const loaded = savedTurn(); + const proposed = { ...loaded, turnVersion: 7 }; + const persistence = createDynamicTurnPersistence({ + async rpc() { return { data: 8, error: null }; }, + }, async () => loaded, () => "2026-07-18"); + + const first = await persistence.saveDynamicTurn(proposed, 7, actionId); + const duplicate = await persistence.saveDynamicTurn(proposed, 7, actionId); + + assert.equal(first, loaded); + assert.equal(duplicate, loaded); +}); + +test("a concurrent different action cannot use a successful RPC as replay", async () => { + const proposed = { ...savedTurn(), turnVersion: 7 }; + const concurrent = savedTurn("finish"); + const persistence = createDynamicTurnPersistence({ + async rpc() { return { data: 8, error: null }; }, + }, async () => concurrent, () => "2026-07-18"); + + await assert.rejects( + persistence.saveDynamicTurn(proposed, 7, actionId), + StaleJourneyTurnError, + ); +}); + +function pendingTurn(): DynamicStoredRectificationCase { + const stored = dynamicCase(); + const option = persistedQuestion.options.find((item) => item.kind === "primary"); + if (!option) throw new Error("missing primary option"); + const transitioned = answerTransition({ + stored, + option, + answeredAt: "2026-07-18T08:00:00.000Z", + jobId: "85b22d7e-3adc-473d-81e1-6ad29e9b06f4", + nextVersion: 8, + }); + return { + ...transitioned, + dynamicControl: { + ...transitioned.dynamicControl, + lastActionReceipt: { + actionId, + kind: "answer_choice", + turnVersion: 7, + questionId: persistedQuestion.questionId, + optionId: option.optionId, + }, + }, + }; +} + +test("dynamic scoring creation accepts only exact duplicate-success receipts", async () => { + const pending = pendingTurn(); + const spec = createDynamicScoringJobSpec( + "85b22d7e-3adc-473d-81e1-6ad29e9b06f4", + pending.choiceEvidence, + new Date("2026-07-18T08:00:00.000Z"), + ); + let loaded = { + ...pending, + turnVersion: 8, + dynamicTurnState: { ...pending.dynamicTurnState, turnVersion: 8 }, + processedActionIds: [actionId], + }; + const store = createDynamicScoringJobStore({ + async rpc() { return { data: 8, error: null }; }, + }, async () => loaded); + const exact = await store.createDynamicScoringJob( + pending, 7, actionId, persistedQuestion.questionId, spec, + ); + assert.equal(exact, loaded); + + loaded = { + ...loaded, + dynamicControl: { + ...loaded.dynamicControl, + lastActionReceipt: { + actionId, + kind: "answer_choice", + turnVersion: 7, + questionId: persistedQuestion.questionId, + optionId: persistedQuestion.options[1].optionId, + }, + }, + }; + await assert.rejects( + store.createDynamicScoringJob(pending, 7, actionId, persistedQuestion.questionId, spec), + StaleJourneyTurnError, + ); +}); diff --git a/frontend/tests/birth-time-dynamic-scoring-store.test.ts b/frontend/tests/birth-time-dynamic-scoring-store.test.ts index a23f5021..bb2a7bde 100644 --- a/frontend/tests/birth-time-dynamic-scoring-store.test.ts +++ b/frontend/tests/birth-time-dynamic-scoring-store.test.ts @@ -38,13 +38,26 @@ test("dynamic job store sends exact private create and typed claim RPCs", async const now = new Date("2026-07-18T08:00:00.000Z"); const option = persistedQuestion.options.find((candidate) => candidate.kind === "primary"); if (!option) throw new Error("missing primary option"); - const pending = answerTransition({ + const transitioned = answerTransition({ stored: freshCase(), option, answeredAt: now.toISOString(), jobId, nextVersion: 8, }); + const pending = { + ...transitioned, + dynamicControl: { + ...transitioned.dynamicControl, + lastActionReceipt: { + actionId, + kind: "answer_choice" as const, + turnVersion: 7, + questionId: persistedQuestion.questionId, + optionId: option.optionId, + }, + }, + }; const spec = createDynamicScoringJobSpec(jobId, pending.choiceEvidence, now); let loaded = freshCase(); const calls: { readonly name: string; readonly args: Readonly> }[] = []; diff --git a/frontend/tests/birth-time-journey-memory-store.ts b/frontend/tests/birth-time-journey-memory-store.ts index 0415a68f..0efe7440 100644 --- a/frontend/tests/birth-time-journey-memory-store.ts +++ b/frontend/tests/birth-time-journey-memory-store.ts @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from "node:util"; +import { samePersistedDynamicReceipt } from "../src/lib/birth-time-dynamic-action-replay.ts"; import type { BirthTimeJourneyStore, DynamicScoringJobCommand, @@ -130,7 +131,10 @@ export function memoryStore( const receipts = savedCase.processedActionIds ?? []; if (receipts.includes(receipt)) { if (!savedDynamicCase) throw new MissingTestCaseError(); - return savedDynamicCase; + if (samePersistedDynamicReceipt(value, savedDynamicCase, receipt, expectedVersion)) { + return savedDynamicCase; + } + throw new StaleJourneyTurnError(value.id, expectedVersion, savedDynamicCase.turnVersion); } if (savedCase.turnVersion !== expectedVersion) { throw new StaleJourneyTurnError(savedCase.id, expectedVersion, savedCase.turnVersion ?? 0); diff --git a/tests/test_birth_time_dynamic_action_receipt_contract.py b/tests/test_birth_time_dynamic_action_receipt_contract.py new file mode 100644 index 00000000..89319c9e --- /dev/null +++ b/tests/test_birth_time_dynamic_action_receipt_contract.py @@ -0,0 +1,51 @@ +from pathlib import Path + + +MIGRATION = ( + Path(__file__).resolve().parents[1] + / "frontend" + / "supabase" + / "migrations" + / "20260718095000_dynamic_choice_exact_action_receipts.sql" +) + + +def _sql() -> str: + assert MIGRATION.exists() + return " ".join(MIGRATION.read_text(encoding="utf-8").lower().split()) + + +def test_duplicate_turn_replay_requires_exact_private_receipt() -> None: + sql = _sql() + for invariant in ( + "create or replace function public.save_birth_time_dynamic_turn", + "p_action_id = any(v_case.processed_action_ids)", + "v_case.turn_version is distinct from p_expected_version + 1", + "v_private.dynamic_control -> 'lastactionreceipt' is distinct from v_receipt", + "v_private.dynamic_control #>> '{lastactionreceipt,actionid}'", + "is distinct from p_action_id::text", + "raise exception 'stale_birth_time_dynamic_turn'", + ): + assert invariant in sql + + +def test_turn_save_locks_case_then_private_state() -> None: + sql = _sql() + case_lock = sql.index("from public.birth_time_rectification_cases c") + private_lock = sql.index("from public.birth_time_rectification_dynamic_state s") + assert case_lock < private_lock + assert "for update" in sql[case_lock:private_lock] + assert "for update" in sql[private_lock:] + + +def test_receipt_replacement_is_private_and_service_role_only() -> None: + sql = _sql() + assert "p_private_state #> '{dynamiccontrol,lastactionreceipt}'" in sql + assert "jsonb_typeof(v_receipt) is distinct from 'object'" in sql + assert "revoke all on function public.save_birth_time_dynamic_turn" in sql + assert "grant execute on function public.save_birth_time_dynamic_turn" in sql + pure_lines = [ + line for line in MIGRATION.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("--") + ] + assert len(pure_lines) <= 250 diff --git a/tests/test_birth_time_dynamic_scoring_job_contract.py b/tests/test_birth_time_dynamic_scoring_job_contract.py index 71b905bb..77131514 100644 --- a/tests/test_birth_time_dynamic_scoring_job_contract.py +++ b/tests/test_birth_time_dynamic_scoring_job_contract.py @@ -40,6 +40,8 @@ def test_dynamic_job_creation_is_atomic_private_and_replay_safe() -> None: "insert into public.birth_time_rectification_scoring_jobs", "update public.birth_time_rectification_cases", "perform public.persist_birth_time_dynamic_private_state", + "lastactionreceipt", + "v_private.dynamic_control -> 'lastactionreceipt' is distinct from v_receipt", ): assert invariant in body assert "processed_action_ids" in body