fix: expose dynamic scoring persistence

This commit is contained in:
Jesse_Chen
2026-07-19 07:50:23 +08:00
parent a8629043d8
commit 45b63d5cef
6 changed files with 430 additions and 7 deletions
+15 -5
View File
@@ -8,6 +8,8 @@ New assessments initialize the public case, private state, and profile pointer i
The acceptance follow-up preserves an existing chart time when rectification starts, rejects split public/JSON turn versions, and routes v2 resume before any legacy scoring normalization. Legacy store inputs now accept only the strict legacy arm. Direct legacy updates include an atomic protocol predicate, while ordered service-role RPC wrappers lock the case and require `legacy-guided-v1` before invoking the former scoring or candidate transaction. Dynamic action receipts are canonicalized to lowercase in both production and the shared memory fake.
The scoring-persistence follow-up adds typed completion and failure commands that call the exact service-role RPCs, validate the returned version, reload the owner-scoped stored case, and preserve idempotent replay and stale-write behavior. It deliberately does not add stop policy, transition routing, or Task 6 orchestration.
## Files
- `frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql`
@@ -33,6 +35,8 @@ The acceptance follow-up preserves an existing chart time when rectification sta
- `frontend/tests/birth-time-dynamic-persistence-fixture.ts`
- `frontend/tests/birth-time-dynamic-persistence.test.ts`
- `frontend/tests/birth-time-dynamic-resume.test.ts`
- `frontend/tests/birth-time-dynamic-scoring-memory-store.test.ts`
- `frontend/tests/birth-time-dynamic-scoring-persistence.test.ts`
- `frontend/tests/birth-time-journey-memory-store.ts`
- `frontend/tests/birth-time-journey-legacy-isolation.test.ts`
- `tests/test_birth_time_dynamic_persistence_contract.py`
@@ -56,14 +60,18 @@ The acceptance follow-up preserves an existing chart time when rectification sta
- Legacy isolation and replay GREEN: `.omo/evidence/task-5-memory-and-isolation-green.log`
- Profile preservation GREEN: `.omo/evidence/task-5-profile-preservation-ts-green.log`, `.omo/evidence/task-5-profile-preservation-python-green.log`
The additional regressions prove that a missing current scoring action cannot pass a SQL `NOT IN` guard through three-valued `NULL` logic, that the supported unknown-time assessment initializes a valid full-day dynamic range, that SQL/public JSON turn versions cannot disagree, that v2 resume performs zero legacy writes, that an upgrade race cannot cross a legacy protocol predicate, and that uppercase UUID retries replay the stored advanced dynamic state.
The scoring-persistence follow-up began RED with all 3 executable tests failing because the completion and failure methods did not exist. After adding only the typed persistence wrappers and memory-fake support, the same 3 tests passed GREEN.
Fresh review then reproduced a shared-fake replay mismatch: completion of one job could be returned for a different failure command at the same expected version. The fake now records the endpoint kind, canonical job identity, fingerprint, algorithm, failure code or candidate result, and returns a replay only for an equivalent operation. Two executable regressions prove exact completion/failure replay and reject changed job, endpoint, fingerprint, algorithm, failure code, or result.
The additional regressions prove that a missing current scoring action cannot pass a SQL `NOT IN` guard through three-valued `NULL` logic, that the supported unknown-time assessment initializes a valid full-day dynamic range, that SQL/public JSON turn versions cannot disagree, that v2 resume performs zero legacy writes, that an upgrade race cannot cross a legacy protocol predicate, and that uppercase UUID retries replay the stored advanced dynamic state. Executable scoring tests additionally prove exact completion/failure RPC names and payloads, returned-version parsing, owner reload, replay, stale and unknown-error propagation, and the public/private payload split without exposing an active or birth time.
## Verification
- Focused persistence, resume, and upgrade-race TypeScript: 28/28 passed.
- Focused persistence, resume, scoring-job, and upgrade-race TypeScript: 33/33 passed.
- Relevant Python persistence, engine, and scoring contracts: 43/43 passed.
- Full birth-time frontend suite: 259/259 passed.
- Full frontend suite: 334/334 passed.
- Full birth-time frontend suite: 264/264 passed.
- Full frontend suite: 339/339 passed.
- Changed-file ESLint: passed.
- Changed-file Ruff: passed.
- `git diff --check`: passed.
@@ -83,7 +91,7 @@ Evidence:
## Review
Fresh follow-up review against the current acceptance-fix diff: **CLEAR / APPROVE**, with no blockers. Artifact: `.omo/evidence/task-5-code-review.md`.
Final cross-review after the identity-faithful memory replay fix: **CLEAR / APPROVE**, with no remaining blocker. The earlier untracked review artifact records the reproduced mismatch that prompted this final fix and is superseded by the passing cross-review.
Live PostgreSQL execution was not available: Docker CLI is installed, but the daemon socket does not exist. `.omo/evidence/task-5-live-postgres-unavailable.log` records the exact failure. SQL checks are therefore described only as static migration contracts; TypeScript fakes execute the RPC boundary and failure/replay semantics without claiming database execution.
@@ -94,3 +102,5 @@ Task 6 remains responsible for routing public `assess`/`resume` responses and dy
Commit message: `feat: persist dynamic rectification turns`
Follow-up commit message: `fix: isolate dynamic rectification persistence`
Scoring-persistence follow-up commit message: `fix: expose dynamic scoring persistence`
@@ -5,6 +5,8 @@ import {
} from "./birth-time-journey-turn-protocol.ts";
import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts";
import type {
DynamicScoringJobCommand,
DynamicScoringJobFailureCommand,
DynamicStoredRectificationCase,
LegacyStoredRectificationCase,
StoredRectificationCase,
@@ -68,6 +70,7 @@ function privateState(value: DynamicStoredRectificationCase): DynamicPrivateJour
function isStaleRpc(error: RpcError): boolean {
return error.message.includes("stale_birth_time_dynamic_turn")
|| error.message.includes("stale_birth_time_dynamic_scoring_job")
|| error.message.includes("stale_birth_time_legacy_upgrade");
}
@@ -84,6 +87,43 @@ export function createDynamicTurnPersistence(
return loaded;
}
async function savedDynamicScoring(
value: DynamicStoredRectificationCase,
expectedVersion: number,
result: RpcResult,
): Promise<DynamicStoredRectificationCase> {
if (result.error) {
if (isStaleRpc(result.error)) {
const current = await loadCase(value.userId, value.id);
throw new StaleJourneyTurnError(
value.id,
expectedVersion,
current?.turnVersion ?? 0,
);
}
throw new BirthTimeJourneyStoreError("update_case");
}
const version = rpcVersionSchema.safeParse(result.data);
if (!version.success || version.data !== expectedVersion + 1) {
throw new BirthTimeJourneyStoreError("update_case");
}
const loaded = await loadedDynamic(value.userId, value.id);
if (
loaded.turnVersion !== version.data
|| loaded.dynamicTurnState.turnVersion !== version.data
) throw new BirthTimeJourneyStoreError("load_case");
return loaded;
}
function scoringIdentity(command: DynamicScoringJobCommand) {
return {
p_job_id: command.jobId,
p_expected_version: command.expectedVersion,
p_evidence_fingerprint: command.evidenceFingerprint,
p_algorithm_version: command.algorithmVersion,
};
}
return {
async saveDynamicTurn(
value: DynamicStoredRectificationCase,
@@ -119,6 +159,38 @@ export function createDynamicTurnPersistence(
return loadedDynamic(value.userId, value.id);
},
async completeDynamicScoringJob(
value: DynamicStoredRectificationCase,
command: DynamicScoringJobCommand,
): Promise<DynamicStoredRectificationCase> {
if (!value.candidateResult) throw new BirthTimeJourneyStoreError("update_case");
const result = await client.rpc("complete_birth_time_dynamic_scoring_job", {
p_user_id: value.userId,
p_case_id: value.id,
...scoringIdentity(command),
p_public_turn_state: publicTurn(value, command.expectedVersion + 1),
p_snapshot: value.snapshot,
p_candidate_result: value.candidateResult,
p_private_state: privateState(value),
});
return savedDynamicScoring(value, command.expectedVersion, result);
},
async failDynamicScoringJob(
value: DynamicStoredRectificationCase,
command: DynamicScoringJobFailureCommand,
): Promise<DynamicStoredRectificationCase> {
const result = await client.rpc("fail_birth_time_dynamic_scoring_job", {
p_user_id: value.userId,
p_case_id: value.id,
...scoringIdentity(command),
p_failure_code: command.failureCode,
p_public_turn_state: publicTurn(value, command.expectedVersion + 1),
p_private_state: privateState(value),
});
return savedDynamicScoring(value, command.expectedVersion, result);
},
async upgradeLegacyActiveCase(
value: LegacyStoredRectificationCase,
): Promise<StoredRectificationCase> {
+13 -2
View File
@@ -133,16 +133,27 @@ export type LegacyStoredRectificationCase = StoredRectificationCaseBase
export type DynamicStoredRectificationCase = StoredRectificationCaseBase
& DynamicStoredFields;
export type StoredRectificationCase =
| LegacyStoredRectificationCase
export type StoredRectificationCase = LegacyStoredRectificationCase
| DynamicStoredRectificationCase;
export type DynamicScoringJobCommand = {
readonly expectedVersion: number;
readonly jobId: string;
readonly evidenceFingerprint: string;
readonly algorithmVersion: string;
};
export type DynamicScoringJobFailureCommand = DynamicScoringJobCommand
& { readonly failureCode: string };
export interface BirthTimeJourneyStore {
saveAssessment(value: PersistedJourneyAssessment): Promise<string>;
loadCase(userId: string, caseId: string): Promise<StoredRectificationCase | null>;
saveScoring(value: LegacyStoredRectificationCase): Promise<void>;
saveTurn(value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string): Promise<StoredRectificationCase>;
saveDynamicTurn(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string): Promise<DynamicStoredRectificationCase>;
completeDynamicScoringJob(value: DynamicStoredRectificationCase, command: DynamicScoringJobCommand): Promise<DynamicStoredRectificationCase>;
failDynamicScoringJob(value: DynamicStoredRectificationCase, command: DynamicScoringJobFailureCommand): Promise<DynamicStoredRectificationCase>;
upgradeLegacyActiveCase(value: LegacyStoredRectificationCase): Promise<StoredRectificationCase>;
createScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string, job: ScoringJobSpec): Promise<StoredRectificationCase>;
claimScoringJob(identity: ScoringJobIdentity): Promise<ScoringJobClaim>;
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
import { dynamicCase } from "./birth-time-dynamic-persistence-fixture.ts";
import { memoryStore } from "./birth-time-journey-memory-store.ts";
import { lowCandidate } from "./birth-time-journey-test-support.ts";
const jobA = "8c9d09e8-91b6-4335-b891-122f205a050c";
const jobB = "dc6f3fdc-b679-4878-a3f4-1037fd1ababb";
const baseCommand = {
expectedVersion: 7,
jobId: jobA,
evidenceFingerprint: "evidence-fingerprint",
algorithmVersion: "birth-time-event-scoring-v1",
};
function completingCase() {
const stored = dynamicCase();
return { ...stored, candidateResult: lowCandidate };
}
test("shared memory store replays only the identical scoring completion", async () => {
const value = completingCase();
const memory = memoryStore(dynamicCase());
const first = await memory.store.completeDynamicScoringJob(value, baseCommand);
assert.equal(await memory.store.completeDynamicScoringJob(value, baseCommand), first);
for (const command of [
{ ...baseCommand, evidenceFingerprint: "changed" },
{ ...baseCommand, algorithmVersion: "birth-time-event-scoring-v2" },
]) {
await assert.rejects(
memory.store.completeDynamicScoringJob(value, command),
StaleJourneyTurnError,
);
}
await assert.rejects(
memory.store.completeDynamicScoringJob({
...value,
candidateResult: { ...lowCandidate, topScore: 9 },
}, baseCommand),
StaleJourneyTurnError,
);
await assert.rejects(
memory.store.failDynamicScoringJob(dynamicCase(), {
...baseCommand,
jobId: jobB,
failureCode: "engine_unavailable",
}),
StaleJourneyTurnError,
);
});
test("shared memory store replays only the identical scoring failure", async () => {
const value = dynamicCase();
const memory = memoryStore(dynamicCase());
const command = { ...baseCommand, failureCode: "engine_unavailable" };
const first = await memory.store.failDynamicScoringJob(value, command);
assert.equal(await memory.store.failDynamicScoringJob(value, command), first);
await assert.rejects(
memory.store.failDynamicScoringJob(value, { ...command, failureCode: "timeout" }),
StaleJourneyTurnError,
);
});
@@ -0,0 +1,205 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createDynamicTurnPersistence } from "../src/lib/birth-time-journey-dynamic-persistence.ts";
import {
BirthTimeJourneyStoreError,
StaleJourneyTurnError,
} from "../src/lib/birth-time-journey-turn-persistence.ts";
import type { DynamicStoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
import {
caseId,
dynamicCase,
ownerId,
} from "./birth-time-dynamic-persistence-fixture.ts";
import { lowCandidate } from "./birth-time-journey-test-support.ts";
const jobId = "8c9d09e8-91b6-4335-b891-122f205a050c";
const fingerprint = "evidence-fingerprint";
const algorithmVersion = "birth-time-event-scoring-v1";
function scoringCase(
action: "complete" | "fail",
): DynamicStoredRectificationCase {
const stored = dynamicCase();
return {
...stored,
candidateResult: action === "complete" ? lowCandidate : null,
dynamicTurnState: {
...stored.dynamicTurnState,
nextAction: action === "complete"
? { kind: "present_low_result", resultId: lowCandidate.resultId }
: { kind: "retry_scoring", jobId },
progress: { ...stored.dynamicTurnState.progress, phase: action === "complete" ? "result" : "scoring" },
},
};
}
function successfulRpc(target: DynamicStoredRectificationCase) {
let stored = dynamicCase();
let committed = false;
const calls: { readonly name: string; readonly args: Readonly<Record<string, unknown>> }[] = [];
const persistence = createDynamicTurnPersistence({
async rpc(name, args) {
calls.push({ name, args });
if (!committed) {
stored = {
...target,
turnVersion: 8,
dynamicTurnState: { ...target.dynamicTurnState, turnVersion: 8 },
};
committed = true;
}
return { data: 8, error: null };
},
}, async () => stored, () => "2026-07-18");
return { calls, persistence };
}
function assertNoPublicLeak(args: Readonly<Record<string, unknown>>) {
const publicPayload = JSON.stringify({
turn: args.p_public_turn_state,
snapshot: args.p_snapshot,
candidate: args.p_candidate_result,
});
for (const forbidden of [
"partitionId",
"candidateScores",
"agentContext",
"active_birth_time",
"birth_time",
"activeBirthTime",
"birthTime",
]) assert.equal(publicPayload.includes(forbidden), false);
}
test("dynamic scoring completion calls the exact RPC and replays the stored turn", async () => {
const value = scoringCase("complete");
const fake = successfulRpc(value);
const command = { expectedVersion: 7, jobId, evidenceFingerprint: fingerprint, algorithmVersion };
const first = await fake.persistence.completeDynamicScoringJob(value, command);
const replay = await fake.persistence.completeDynamicScoringJob(value, command);
assert.equal(first.turnVersion, 8);
assert.equal(replay, first);
assert.deepEqual(fake.calls.map((call) => call.name), [
"complete_birth_time_dynamic_scoring_job",
"complete_birth_time_dynamic_scoring_job",
]);
assert.deepEqual(fake.calls[0]?.args, {
p_user_id: ownerId,
p_case_id: caseId,
p_job_id: jobId,
p_expected_version: 7,
p_evidence_fingerprint: fingerprint,
p_algorithm_version: algorithmVersion,
p_public_turn_state: { ...value.dynamicTurnState, turnVersion: 8 },
p_snapshot: value.snapshot,
p_candidate_result: lowCandidate,
p_private_state: {
candidateModel: value.candidateModel,
currentChoiceQuestion: value.currentChoiceQuestion,
choiceAnswers: value.choiceAnswers,
choiceEvidence: value.choiceEvidence,
dynamicControl: value.dynamicControl,
agentContext: value.agentContext,
},
});
assertNoPublicLeak(fake.calls[0]?.args ?? {});
});
test("dynamic scoring failure calls the exact RPC and replays the stored turn", async () => {
const value = scoringCase("fail");
const fake = successfulRpc(value);
const command = {
expectedVersion: 7,
jobId,
evidenceFingerprint: fingerprint,
algorithmVersion,
failureCode: "engine_unavailable",
};
const first = await fake.persistence.failDynamicScoringJob(value, command);
const replay = await fake.persistence.failDynamicScoringJob(value, command);
assert.equal(first.turnVersion, 8);
assert.equal(replay, first);
assert.deepEqual(fake.calls.map((call) => call.name), [
"fail_birth_time_dynamic_scoring_job",
"fail_birth_time_dynamic_scoring_job",
]);
assert.deepEqual(fake.calls[0], {
name: "fail_birth_time_dynamic_scoring_job",
args: {
p_user_id: ownerId,
p_case_id: caseId,
p_job_id: jobId,
p_expected_version: 7,
p_evidence_fingerprint: fingerprint,
p_algorithm_version: algorithmVersion,
p_failure_code: "engine_unavailable",
p_public_turn_state: { ...value.dynamicTurnState, turnVersion: 8 },
p_private_state: {
candidateModel: value.candidateModel,
currentChoiceQuestion: value.currentChoiceQuestion,
choiceAnswers: value.choiceAnswers,
choiceEvidence: value.choiceEvidence,
dynamicControl: value.dynamicControl,
agentContext: value.agentContext,
},
},
});
assertNoPublicLeak(fake.calls[0]?.args ?? {});
});
test("dynamic scoring persistence rejects malformed versions and maps RPC errors", async () => {
const value = scoringCase("fail");
let currentVersion = 9;
let completionCalls = 0;
const persistence = createDynamicTurnPersistence({
async rpc(name) {
if (name === "complete_birth_time_dynamic_scoring_job") {
completionCalls += 1;
return { data: completionCalls === 1 ? "8" : 9, error: null };
}
if (name === "fail_birth_time_dynamic_scoring_job" && currentVersion === 9) {
return { data: null, error: { message: "stale_birth_time_dynamic_scoring_job" } };
}
return { data: null, error: { message: "database unavailable" } };
},
}, async () => ({
...dynamicCase(),
turnVersion: currentVersion,
dynamicTurnState: { ...dynamicCase().dynamicTurnState, turnVersion: currentVersion },
}), () => "2026-07-18");
await assert.rejects(
persistence.completeDynamicScoringJob({ ...value, candidateResult: lowCandidate }, {
expectedVersion: 7, jobId, evidenceFingerprint: fingerprint, algorithmVersion,
}),
BirthTimeJourneyStoreError,
);
await assert.rejects(
persistence.completeDynamicScoringJob({ ...value, candidateResult: lowCandidate }, {
expectedVersion: 7, jobId, evidenceFingerprint: fingerprint, algorithmVersion,
}),
BirthTimeJourneyStoreError,
);
await assert.rejects(
persistence.failDynamicScoringJob(value, {
expectedVersion: 7, jobId, evidenceFingerprint: fingerprint,
algorithmVersion, failureCode: "engine_unavailable",
}),
(error) => error instanceof StaleJourneyTurnError
&& error.expectedVersion === 7
&& error.currentVersion === 9,
);
currentVersion = 10;
await assert.rejects(
persistence.failDynamicScoringJob(value, {
expectedVersion: 7, jobId, evidenceFingerprint: fingerprint,
algorithmVersion, failureCode: "engine_unavailable",
}),
BirthTimeJourneyStoreError,
);
});
@@ -1,5 +1,8 @@
import { isDeepStrictEqual } from "node:util";
import type {
BirthTimeJourneyStore,
DynamicScoringJobCommand,
DynamicScoringJobFailureCommand,
DynamicStoredRectificationCase,
PersistedJourneyAssessment,
StoredRectificationCase,
@@ -17,6 +20,30 @@ class MissingTestCaseError extends Error {
export const journeyCaseId = "7299894c-10a8-4b45-91d1-339007282c50";
type DynamicScoringReceipt = {
readonly kind: "complete" | "fail";
readonly command: DynamicScoringJobCommand | DynamicScoringJobFailureCommand;
readonly result: DynamicStoredRectificationCase["candidateResult"];
};
function sameScoringOperation(
receipt: DynamicScoringReceipt,
next: DynamicScoringReceipt,
): boolean {
const prior = receipt.command;
const command = next.command;
return receipt.kind === next.kind
&& prior.expectedVersion === command.expectedVersion
&& prior.jobId.toLowerCase() === command.jobId.toLowerCase()
&& prior.evidenceFingerprint === command.evidenceFingerprint
&& prior.algorithmVersion === command.algorithmVersion
&& (receipt.kind !== "fail" || (
"failureCode" in prior && "failureCode" in command
&& prior.failureCode === command.failureCode
))
&& (receipt.kind !== "complete" || isDeepStrictEqual(receipt.result, next.result));
}
export function memoryStore(
initialCase?: StoredRectificationCase,
asOfDate = "2026-07-18",
@@ -28,6 +55,7 @@ export function memoryStore(
let committedTurnWrites = 0;
let legacyWrites = 0;
let guidedCandidateWrites = 0;
let dynamicScoringReceipt: DynamicScoringReceipt | null = null;
const scoringJobs = createMemoryScoringJobs({
read: () => savedCase,
write: (value) => {
@@ -37,6 +65,31 @@ export function memoryStore(
committedTurnWrites += 1;
},
});
function persistDynamicScoring(
value: DynamicStoredRectificationCase,
receipt: DynamicScoringReceipt,
): DynamicStoredRectificationCase {
if (!savedDynamicCase) throw new MissingTestCaseError();
const expectedVersion = receipt.command.expectedVersion;
if (savedDynamicCase.turnVersion === expectedVersion + 1) {
if (dynamicScoringReceipt && sameScoringOperation(dynamicScoringReceipt, receipt)) {
return savedDynamicCase;
}
throw new StaleJourneyTurnError(value.id, expectedVersion, savedDynamicCase.turnVersion);
}
if (savedDynamicCase.turnVersion !== expectedVersion) {
throw new StaleJourneyTurnError(value.id, expectedVersion, savedDynamicCase.turnVersion);
}
const saved = {
...value,
turnVersion: expectedVersion + 1,
dynamicTurnState: { ...value.dynamicTurnState, turnVersion: expectedVersion + 1 },
};
savedCase = saved;
savedDynamicCase = saved;
dynamicScoringReceipt = receipt;
return saved;
}
const store: BirthTimeJourneyStore = {
async saveAssessment(value) {
savedAssessment = value;
@@ -93,6 +146,12 @@ export function memoryStore(
committedTurnWrites += 1;
return savedDynamic;
},
async completeDynamicScoringJob(value, command) {
return persistDynamicScoring(value, { kind: "complete", command, result: value.candidateResult });
},
async failDynamicScoringJob(value, command) {
return persistDynamicScoring(value, { kind: "fail", command, result: null });
},
async upgradeLegacyActiveCase(value) {
if (isTerminalLegacyCase(value)) {
return value;
@@ -161,6 +220,7 @@ export function memoryStore(
replaceCase: (value: StoredRectificationCase) => {
savedCase = value;
savedDynamicCase = value.journeyProtocol === "dynamic-choice-v2" ? value : null;
dynamicScoringReceipt = null;
},
};
}