fix: close dynamic receipt replay gaps

This commit is contained in:
Jesse_Chen
2026-07-19 09:12:45 +08:00
parent 50c1de20d3
commit e90f77bac1
22 changed files with 696 additions and 73 deletions
+23 -3
View File
@@ -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`
@@ -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`
@@ -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<DynamicActionReceipt> = 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();
@@ -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));
}
+53 -14
View File
@@ -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);
},
@@ -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(
@@ -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) {
@@ -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(
@@ -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';
@@ -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;
@@ -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);
});
@@ -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,
);
});
@@ -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,
);
});
@@ -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<DynamicStoredRectificationCase["candidateResult"]> | 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) {
@@ -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],
@@ -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));
@@ -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],
};
}
@@ -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,
);
});
@@ -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<Record<string, unknown>> }[] = [];
@@ -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);
@@ -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
@@ -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