diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md index 26964f32..f019fd7d 100644 --- a/.superpowers/sdd/task-6-report.md +++ b/.superpowers/sdd/task-6-report.md @@ -89,12 +89,17 @@ result/action coherence. 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 review RED: a generic transport error after a committed dynamic turn reloaded state but + skipped receipt classification unless the error text itself said stale. The adapter now treats + an exact committed receipt as lost-response replay, a processed conflicting receipt as stale, + and only an uncommitted generic error as a store failure. Scoring-job creation already used + these semantics; a generic-error regression test locks that parity. Final verification: -- Focused Task 6 TypeScript: **43/43 passed**. +- Focused Task 6 TypeScript: **47/47 passed**. - Route/telemetry regression subset after v2 assessment routing: **28/28 passed**. -- Full frontend TypeScript tests: **358/358 passed**. +- Full frontend TypeScript tests: **362/362 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. @@ -121,3 +126,5 @@ 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` + +Lost-response fix commit message: `fix: recover committed dynamic transport errors` diff --git a/frontend/src/lib/birth-time-journey-dynamic-persistence.ts b/frontend/src/lib/birth-time-journey-dynamic-persistence.ts index 1d8f4ae5..e7e53f2e 100644 --- a/frontend/src/lib/birth-time-journey-dynamic-persistence.ts +++ b/frontend/src/lib/birth-time-journey-dynamic-persistence.ts @@ -144,7 +144,7 @@ export function createDynamicTurnPersistence( }); if (result.error) { const current = await loadCase(value.userId, value.id); - if (isStaleRpc(result.error) && current?.journeyProtocol === "dynamic-choice-v2" + if (current?.journeyProtocol === "dynamic-choice-v2" && current.processedActionIds.includes(receipt)) { if (samePersistedDynamicReceipt(value, current, receipt, expectedVersion)) return current; throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion); diff --git a/frontend/tests/birth-time-dynamic-rpc-replay.test.ts b/frontend/tests/birth-time-dynamic-rpc-replay.test.ts index 9600dc77..bf13a979 100644 --- a/frontend/tests/birth-time-dynamic-rpc-replay.test.ts +++ b/frontend/tests/birth-time-dynamic-rpc-replay.test.ts @@ -3,7 +3,10 @@ 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 { + BirthTimeJourneyStoreError, + 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"; @@ -55,6 +58,43 @@ test("a concurrent different action cannot use a successful RPC as replay", asyn ); }); +test("a transport error returns an exact committed dynamic turn", async () => { + const loaded = savedTurn(); + const proposed = { ...loaded, turnVersion: 7 }; + const persistence = createDynamicTurnPersistence({ + async rpc() { return { data: null, error: { message: "connection reset" } }; }, + }, async () => loaded, () => "2026-07-18"); + + const replay = await persistence.saveDynamicTurn(proposed, 7, actionId); + + assert.equal(replay, loaded); +}); + +test("a transport error maps a conflicting committed receipt to stale", async () => { + const proposed = { ...savedTurn(), turnVersion: 7 }; + const concurrent = savedTurn("finish"); + const persistence = createDynamicTurnPersistence({ + async rpc() { return { data: null, error: { message: "connection reset" } }; }, + }, async () => concurrent, () => "2026-07-18"); + + await assert.rejects( + persistence.saveDynamicTurn(proposed, 7, actionId), + StaleJourneyTurnError, + ); +}); + +test("a transport error remains a store error when nothing committed", async () => { + const proposed = dynamicCase(); + const persistence = createDynamicTurnPersistence({ + async rpc() { return { data: null, error: { message: "connection reset" } }; }, + }, async () => proposed, () => "2026-07-18"); + + await assert.rejects( + persistence.saveDynamicTurn(proposed, 7, actionId), + BirthTimeJourneyStoreError, + ); +}); + function pendingTurn(): DynamicStoredRectificationCase { const stored = dynamicCase(); const option = persistedQuestion.options.find((item) => item.kind === "primary"); @@ -120,3 +160,27 @@ test("dynamic scoring creation accepts only exact duplicate-success receipts", a StaleJourneyTurnError, ); }); + +test("dynamic scoring transport errors return an exact committed receipt", async () => { + const pending = pendingTurn(); + const spec = createDynamicScoringJobSpec( + "85b22d7e-3adc-473d-81e1-6ad29e9b06f4", + pending.choiceEvidence, + new Date("2026-07-18T08:00:00.000Z"), + ); + const loaded = { + ...pending, + turnVersion: 8, + dynamicTurnState: { ...pending.dynamicTurnState, turnVersion: 8 }, + processedActionIds: [actionId], + }; + const store = createDynamicScoringJobStore({ + async rpc() { return { data: null, error: { message: "connection reset" } }; }, + }, async () => loaded); + + const replay = await store.createDynamicScoringJob( + pending, 7, actionId, persistedQuestion.questionId, spec, + ); + + assert.equal(replay, loaded); +});