fix: close dynamic receipt replay gaps
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user