diff --git a/apps/api/src/routes/assembled-exam-run-routes.test.ts b/apps/api/src/routes/assembled-exam-run-routes.test.ts index 981e870b..f283bcaa 100644 --- a/apps/api/src/routes/assembled-exam-run-routes.test.ts +++ b/apps/api/src/routes/assembled-exam-run-routes.test.ts @@ -1,16 +1,16 @@ -import { describe, expect, it } from "vitest"; import { DEFAULT_DEV_AUTH_SECRET, signAuthToken, } from "@openclinxr/auth"; import type { ExamForm, ExamTimingPlan } from "@openclinxr/exam-assembly"; import { assembledExamOrchestratorClaimBoundary } from "@openclinxr/scenario-runtime"; +import { describe, expect, it } from "vitest"; import { ApiApplication } from "../api-application.js"; -import { createApiApp } from "../app.js"; import type { ApiPersistenceSink } from "../api-types.js"; +import { createApiApp } from "../app.js"; import { - assembledExamRunNotEvidenceFor, type ApiAssembledExamRunRecord, + assembledExamRunNotEvidenceFor, } from "../runtime-durable-store.js"; import { ASSEMBLED_EXAM_RUNS_PATH, @@ -166,6 +166,23 @@ describe("assembled-exam run API", () => { expect(body.action).toBe("resume_station"); expect(body.examEquivalenceGate).toBe(false); expect(body.claimBoundary).toBe(assembledExamOrchestratorClaimBoundary); + expect(body.orderedStations).toEqual([ + { + stationOrder: 1, + slotId: "slot_a", + stationRunId: `${EXAM_RUN_ID}:station:1`, + scenarioId: SCENARIO_A, + scenarioVersion: 1, + }, + { + stationOrder: 2, + slotId: "slot_b", + stationRunId: `${EXAM_RUN_ID}:station:2`, + scenarioId: SCENARIO_B, + scenarioVersion: 1, + }, + ]); + expect(body.admittedPhaseEvents).toEqual([]); const currentStation = body.currentStation as { stationOrder: number; scenarioId: string; assembledStation: unknown }; expect(currentStation.stationOrder).toBe(1); expect(currentStation.scenarioId).toBe(SCENARIO_A); @@ -206,6 +223,10 @@ describe("assembled-exam run API", () => { }), }); expect(admitted.status).toBe(201); + const admittedContract = await json(admitted); + const admittedEvents = admittedContract.admittedPhaseEvents as Array<{ recordedAtIso: string }>; + const recordedAtIso = admittedEvents[0]?.recordedAtIso; + expect(Number.isFinite(Date.parse(recordedAtIso ?? ""))).toBe(true); const restarted = compose(sink); restarted.context.assembledExamRuns.clear(); @@ -219,6 +240,16 @@ describe("assembled-exam run API", () => { expect(body.stationRunId).toBe(`${EXAM_RUN_ID}:station:1`); const currentStation = body.currentStation as { lifecycle: { lastAdmittedEventType: string } }; expect(currentStation.lifecycle.lastAdmittedEventType).toBe("encounter.started"); + expect(body.orderedStations).toHaveLength(2); + expect(body.admittedPhaseEvents).toEqual([ + expect.objectContaining({ + stationRunId: `${EXAM_RUN_ID}:station:1`, + eventType: "encounter.started", + sequence: 0, + formAtSecond: 60, + recordedAtIso, + }), + ]); }); it("rejects learner, form, station-order, sequence, and durable-reference mismatches", async () => { diff --git a/apps/api/src/routes/assembled-exam-run-routes.ts b/apps/api/src/routes/assembled-exam-run-routes.ts index 39dca214..d7be4d47 100644 --- a/apps/api/src/routes/assembled-exam-run-routes.ts +++ b/apps/api/src/routes/assembled-exam-run-routes.ts @@ -1,4 +1,3 @@ -import type { Hono } from "hono"; import { resolveSessionLearnerId } from "@openclinxr/auth"; import type { ExamForm, ExamTimingPlan } from "@openclinxr/exam-assembly"; import { @@ -7,23 +6,24 @@ import { type AssembledExamPhaseTransitionType, } from "@openclinxr/review-workflow"; import { - resumeAssembledExam, type AssembledExamAdmittedPhaseEvent, type AssembledExamLedgerResumeProjection, type AssembledExamResumeDecision, + resumeAssembledExam, } from "@openclinxr/scenario-runtime"; +import type { Hono } from "hono"; import type { ApiAppContext } from "../api-app-context.js"; import { denyIfCannotReadStationRun, isExamForm } from "../api-route-support.js"; import { isRecord } from "../api-support.js"; import type { ApiAppVariables } from "../api-types.js"; import { - assembledExamRunClaimBoundary, - assembledExamRunNotEvidenceFor, - createScenarioRuntimeDurableStoreFromApiPersistence, type ApiAssembledExamAdmittedPhaseEvent, type ApiAssembledExamRunRecord, type ApiAssembledExamStationBinding, type ApiRuntimeDurableStore, + assembledExamRunClaimBoundary, + assembledExamRunNotEvidenceFor, + createScenarioRuntimeDurableStoreFromApiPersistence, } from "../runtime-durable-store.js"; export const ASSEMBLED_EXAM_RUNS_PATH = "/exam-runs"; @@ -89,7 +89,7 @@ export function registerAssembledExamRunRoutes( if (mismatch) { return staleIdentity(context, mismatch); } - return context.json(toContract(decide(existing)), existing.admittedPhaseEvents.length === 0 ? 201 : 200); + return context.json(toContract(decide(existing), existing), existing.admittedPhaseEvents.length === 0 ? 201 : 200); } const record: ApiAssembledExamRunRecord = { @@ -107,7 +107,7 @@ export function registerAssembledExamRunRoutes( }; try { await persistAssembledExamRun(durable, assembledExamRuns, examRunOwners, sessionOwners, record); - return context.json(toContract(decide(record)), 201); + return context.json(toContract(decide(record), record), 201); } catch (error) { return assembledExamRunError(context, error); } @@ -128,7 +128,7 @@ export function registerAssembledExamRunRoutes( return context.json(ownershipDenied.body, ownershipDenied.status); } try { - return context.json(toContract(decide(record))); + return context.json(toContract(decide(record), record)); } catch (error) { return assembledExamRunError(context, error); } @@ -180,7 +180,7 @@ export function registerAssembledExamRunRoutes( try { const next = admitPhaseEvent(record, admitted.event); await persistAssembledExamRun(durable, assembledExamRuns, examRunOwners, sessionOwners, next); - return context.json(toContract(decide(next)), 201); + return context.json(toContract(decide(next), next), 201); } catch (error) { return assembledExamRunError(context, error); } @@ -225,7 +225,11 @@ function toOrchestratorEvent(event: ApiAssembledExamAdmittedPhaseEvent): Assembl }; } -function toContract(decision: AssembledExamResumeDecision) { +function toContract(decision: AssembledExamResumeDecision, record: ApiAssembledExamRunRecord) { + const orderedStations = [...record.orderedStations].sort((left, right) => left.stationOrder - right.stationOrder); + const admittedPhaseEvents = [...record.admittedPhaseEvents].sort( + (left, right) => left.stationOrder - right.stationOrder || left.sequence - right.sequence, + ); return { examRunId: decision.examRunId, stationRunId: decision.selectedStation?.stationRunId ?? null, @@ -238,6 +242,8 @@ function toContract(decision: AssembledExamResumeDecision) { claimBoundary: decision.claimBoundary, notEvidenceFor: decision.notEvidenceFor, examEquivalenceGate: false as const, + orderedStations, + admittedPhaseEvents, }; } @@ -399,6 +405,7 @@ function parsePhaseAdmission( durableEventRef: expectedRef, phase: ASSEMBLED_EXAM_PHASE_BY_TYPE[eventType], source: typeof body.source === "string" && body.source.trim().length > 0 ? body.source : "system", + recordedAtIso: new Date().toISOString(), ...(eventType === "station.advanced" ? { advanceReason: String(body.advanceReason).trim() } : {}), }, }; diff --git a/apps/api/src/runtime-durable-store.ts b/apps/api/src/runtime-durable-store.ts index 593857e6..281e9c83 100644 --- a/apps/api/src/runtime-durable-store.ts +++ b/apps/api/src/runtime-durable-store.ts @@ -39,6 +39,8 @@ export type ApiAssembledExamAdmittedPhaseEvent = { durableEventRef: string; phase: AssembledExamPhase; source: string; + /** Durable admission time. Older stored aggregates may omit it and must not be used to invent UI outcome time. */ + recordedAtIso?: string; advanceReason?: string; }; diff --git a/apps/ui-xr/src/learner-assembled-exam-run-source.ts b/apps/ui-xr/src/learner-assembled-exam-run-source.ts new file mode 100644 index 00000000..76db5718 --- /dev/null +++ b/apps/ui-xr/src/learner-assembled-exam-run-source.ts @@ -0,0 +1,414 @@ +import { LEARNER_CANONICAL_PHASE_TYPES } from "./runtime-state.js"; + +type TextSink = { textContent: string | null }; +type FormWindow = { startsAtSecond: number; endsAtSecond: number }; + +export const LEARNER_ASSEMBLED_EXAM_RUN_ACTIONS = ["resume_station", "advance_station", "exam_complete"] as const; +export type LearnerAssembledExamRunAction = (typeof LEARNER_ASSEMBLED_EXAM_RUN_ACTIONS)[number]; +export type LearnerAssembledExamStationBinding = { + stationOrder: number; + slotId: string; + stationRunId: string; + scenarioId: string; + scenarioVersion: number; +}; +export type LearnerAssembledExamAdmittedPhaseEvent = { + examRunId: string; + stationRunId: string; + sequence: number; + eventType: (typeof LEARNER_CANONICAL_PHASE_TYPES)[number]; + atSecond: number; + formAtSecond: number; + scenarioId: string; + stationOrder: number; + durableEventRef: string; + phase: "encounter" | "note" | "complete"; + source: string; + recordedAtIso: string; + advanceReason?: string; +}; +export type LearnerAssembledExamLifecycle = { + lastAdmittedEventType: string | null; + nextExpectedEventType: string | null; + admittedEventTypes: string[]; + durableEventRefs: string[]; + noteSubmitted: boolean; + phase: string; +}; +export type LearnerAssembledExamCurrentStation = { + stationOrder: number; + scenarioId: string; + stationRunId: string; + slotId: string; + assembledStation: { + examRunId: string; + scenarioId: string; + stationOrder: number; + formTiming: { doorway?: FormWindow; encounter: FormWindow; note: FormWindow }; + }; + lifecycle: LearnerAssembledExamLifecycle; +}; +export type LearnerAssembledExamRunAggregate = { + examRunId: string; + stationRunId: string | null; + examFormId: string; + blueprintId: string; + action: LearnerAssembledExamRunAction; + currentStation: LearnerAssembledExamCurrentStation | null; + orderedStations: LearnerAssembledExamStationBinding[]; + admittedPhaseEvents: LearnerAssembledExamAdmittedPhaseEvent[]; + durableEventRefs: string[]; + omissions: unknown[]; + claimBoundary: string; + notEvidenceFor: string[]; + examEquivalenceGate: false; +}; + +export class BlockedLearnerExamResumeError extends Error { + readonly reason: string; + + constructor(reason: string) { + super(`blocked_resume: ${reason}`); + this.name = "BlockedLearnerExamResumeError"; + this.reason = reason; + } +} + +export function applyLearnerExamResumeBlockedPresentation(input: { + reason: string; + sink: TextSink; +}): void { + const reason = input.reason.trim().length > 0 ? input.reason.trim() : "durable_identity_mismatch"; + input.sink.textContent = `Exam resume blocked: ${reason.replaceAll("_", " ")}`; +} + +/** Only an explicit 404 permits fresh creation; every other GET failure blocks resume. */ +export async function fetchLearnerAssembledExamRunAggregate(input: { + baseUrl: string; + examRunId: string; + fetch?: typeof fetch; +}): Promise<{ found: false } | { found: true; aggregate: LearnerAssembledExamRunAggregate }> { + const examRunId = input.examRunId.trim(); + if (examRunId.length === 0) throw new BlockedLearnerExamResumeError("exam_run_id_missing"); + const url = `${input.baseUrl.replace(/\/$/, "")}/exam-runs/${encodeURIComponent(examRunId)}`; + let response: Response; + try { + response = await (input.fetch ?? globalThis.fetch)(url, { method: "GET" }); + } catch { + throw new BlockedLearnerExamResumeError("exam_run_unreachable"); + } + if (response.status === 404) return { found: false }; + if (response.status === 409) { + const body = await readJsonBody(response); + const reason = isRecord(body) && typeof body.reason === "string" && body.reason.length > 0 + ? body.reason + : "stale_identity"; + throw new BlockedLearnerExamResumeError(reason); + } + if (!response.ok) throw new BlockedLearnerExamResumeError(`exam_run_get_failed_${response.status}`); + let body: unknown; + try { + body = await response.json(); + } catch { + throw new BlockedLearnerExamResumeError("exam_run_payload_malformed"); + } + return { found: true, aggregate: parseLearnerAssembledExamRunAggregate(body, examRunId) }; +} + +export function parseLearnerAssembledExamRunAggregate( + body: unknown, + expectedExamRunId: string, +): LearnerAssembledExamRunAggregate { + if (!isRecord(body)) throw new BlockedLearnerExamResumeError("exam_run_payload_malformed"); + if (body.examEquivalenceGate !== false) throw new BlockedLearnerExamResumeError("exam_equivalence_gate"); + const examRunId = requiredString(body.examRunId, "examRunId"); + if (examRunId !== expectedExamRunId) throw new BlockedLearnerExamResumeError("exam_run_mismatch"); + const action = body.action; + if (typeof action !== "string" || !(LEARNER_ASSEMBLED_EXAM_RUN_ACTIONS as readonly string[]).includes(action)) { + throw new BlockedLearnerExamResumeError("exam_run_payload_malformed"); + } + const stationRunId = body.stationRunId === null || body.stationRunId === undefined + ? null + : requiredString(body.stationRunId, "stationRunId"); + const currentStation = parseCurrentStation(body.currentStation, examRunId); + if (action !== "exam_complete" && currentStation === null) { + throw new BlockedLearnerExamResumeError("current_station_missing"); + } + if ((currentStation && currentStation.stationRunId !== stationRunId) || (!currentStation && stationRunId !== null)) { + throw new BlockedLearnerExamResumeError("station_run_mismatch"); + } + const orderedStations = parseOrderedStations(body.orderedStations); + const admittedPhaseEvents = parseAdmittedPhaseEvents(body.admittedPhaseEvents, examRunId, orderedStations); + validateAggregateProgress(action as LearnerAssembledExamRunAction, currentStation, orderedStations, admittedPhaseEvents); + const durableEventRefs = requiredStringArray(body.durableEventRefs, "durableEventRefs"); + if (durableEventRefs.join("\0") !== admittedPhaseEvents.map((event) => event.durableEventRef).join("\0")) { + throw new BlockedLearnerExamResumeError("durable_reference_mismatch"); + } + const notEvidenceFor = requiredStringArray(body.notEvidenceFor, "notEvidenceFor"); + if (!notEvidenceFor.includes("exam_equivalence")) { + throw new BlockedLearnerExamResumeError("exam_equivalence_claim_boundary"); + } + return { + examRunId, + stationRunId, + examFormId: requiredString(body.examFormId, "examFormId"), + blueprintId: requiredString(body.blueprintId, "blueprintId"), + action: action as LearnerAssembledExamRunAction, + currentStation, + orderedStations, + admittedPhaseEvents, + durableEventRefs, + omissions: Array.isArray(body.omissions) ? body.omissions : [], + claimBoundary: requiredString(body.claimBoundary, "claimBoundary"), + notEvidenceFor, + examEquivalenceGate: false, + }; +} + +const PHASE_BY_EVENT_TYPE: Record< + LearnerAssembledExamAdmittedPhaseEvent["eventType"], + LearnerAssembledExamAdmittedPhaseEvent["phase"] +> = { + "encounter.started": "encounter", + "encounter.ended": "encounter", + "note.started": "note", + "note.submitted": "note", + "station.advanced": "complete", +}; + +function parseOrderedStations(value: unknown): LearnerAssembledExamStationBinding[] { + if (!Array.isArray(value) || value.length === 0) { + throw new BlockedLearnerExamResumeError("ordered_stations_missing"); + } + const stations = value.map((item) => { + if (!isRecord(item)) throw new BlockedLearnerExamResumeError("ordered_station_malformed"); + return { + stationOrder: requiredPositiveInt(item.stationOrder, "orderedStation.stationOrder"), + slotId: requiredString(item.slotId, "orderedStation.slotId"), + stationRunId: requiredString(item.stationRunId, "orderedStation.stationRunId"), + scenarioId: requiredString(item.scenarioId, "orderedStation.scenarioId"), + scenarioVersion: requiredPositiveInt(item.scenarioVersion, "orderedStation.scenarioVersion"), + }; + }); + const seenOrders = new Set(); + const seenRuns = new Set(); + for (let index = 0; index < stations.length; index += 1) { + const station = stations[index]; + const previous = stations[index - 1]; + if (!station || seenOrders.has(station.stationOrder) || seenRuns.has(station.stationRunId) + || (previous && station.stationOrder <= previous.stationOrder)) { + throw new BlockedLearnerExamResumeError("ordered_station_identity_mismatch"); + } + seenOrders.add(station.stationOrder); + seenRuns.add(station.stationRunId); + } + return stations; +} + +function parseAdmittedPhaseEvents( + value: unknown, + examRunId: string, + stations: readonly LearnerAssembledExamStationBinding[], +): LearnerAssembledExamAdmittedPhaseEvent[] { + if (!Array.isArray(value)) throw new BlockedLearnerExamResumeError("admitted_phase_events_missing"); + const stationByRun = new Map(stations.map((station) => [station.stationRunId, station])); + const previousByRun = new Map(); + let previousStationOrder = -1; + const events: LearnerAssembledExamAdmittedPhaseEvent[] = []; + for (const item of value) { + if (!isRecord(item)) throw new BlockedLearnerExamResumeError("admitted_phase_event_malformed"); + const eventType = requiredString(item.eventType, "admittedPhaseEvent.eventType"); + if (!(LEARNER_CANONICAL_PHASE_TYPES as readonly string[]).includes(eventType)) { + throw new BlockedLearnerExamResumeError("admitted_phase_event_type_mismatch"); + } + const typedEventType = eventType as LearnerAssembledExamAdmittedPhaseEvent["eventType"]; + const stationRunId = requiredString(item.stationRunId, "admittedPhaseEvent.stationRunId"); + const stationOrder = requiredPositiveInt(item.stationOrder, "admittedPhaseEvent.stationOrder"); + const scenarioId = requiredString(item.scenarioId, "admittedPhaseEvent.scenarioId"); + const station = stationByRun.get(stationRunId); + if (!station || station.stationOrder !== stationOrder || station.scenarioId !== scenarioId) { + throw new BlockedLearnerExamResumeError("admitted_phase_event_identity_mismatch"); + } + const sequence = requiredNonnegativeInt(item.sequence, "admittedPhaseEvent.sequence"); + const atSecond = requiredNonnegativeInt(item.atSecond, "admittedPhaseEvent.atSecond"); + const formAtSecond = requiredNonnegativeInt(item.formAtSecond, "admittedPhaseEvent.formAtSecond"); + const durableEventRef = requiredString(item.durableEventRef, "admittedPhaseEvent.durableEventRef"); + if (durableEventRef !== `durable://station-runs/${stationRunId}/events/${sequence}`) { + throw new BlockedLearnerExamResumeError("durable_reference_mismatch"); + } + const phase = requiredString(item.phase, "admittedPhaseEvent.phase"); + if (phase !== PHASE_BY_EVENT_TYPE[typedEventType]) { + throw new BlockedLearnerExamResumeError("admitted_phase_event_phase_mismatch"); + } + const recordedAtIso = requiredString(item.recordedAtIso, "admittedPhaseEvent.recordedAtIso"); + if (!Number.isFinite(Date.parse(recordedAtIso))) { + throw new BlockedLearnerExamResumeError("admitted_phase_event_timestamp_malformed"); + } + const previous = previousByRun.get(stationRunId); + const expectedSequence = previous ? previous.sequence + 1 : 0; + if (sequence !== expectedSequence || typedEventType !== LEARNER_CANONICAL_PHASE_TYPES[expectedSequence] + || stationOrder < previousStationOrder || (previous && (atSecond < previous.atSecond + || formAtSecond < previous.formAtSecond || Date.parse(recordedAtIso) < Date.parse(previous.recordedAtIso)))) { + throw new BlockedLearnerExamResumeError("sequence_mismatch"); + } + const advanceReason = item.advanceReason; + if ((typedEventType === "station.advanced" && (typeof advanceReason !== "string" || !advanceReason.trim())) + || (typedEventType !== "station.advanced" && advanceReason !== undefined)) { + throw new BlockedLearnerExamResumeError("advance_reason_mismatch"); + } + const event: LearnerAssembledExamAdmittedPhaseEvent = { + examRunId: requiredString(item.examRunId, "admittedPhaseEvent.examRunId"), + stationRunId, + sequence, + eventType: typedEventType, + atSecond, + formAtSecond, + scenarioId, + stationOrder, + durableEventRef, + phase: phase as LearnerAssembledExamAdmittedPhaseEvent["phase"], + source: requiredString(item.source, "admittedPhaseEvent.source"), + recordedAtIso, + ...(typeof advanceReason === "string" ? { advanceReason: advanceReason.trim() } : {}), + }; + if (event.examRunId !== examRunId) throw new BlockedLearnerExamResumeError("exam_run_mismatch"); + events.push(event); + previousByRun.set(stationRunId, event); + previousStationOrder = stationOrder; + } + return events; +} + +function validateAggregateProgress( + action: LearnerAssembledExamRunAction, + current: LearnerAssembledExamCurrentStation | null, + stations: readonly LearnerAssembledExamStationBinding[], + events: readonly LearnerAssembledExamAdmittedPhaseEvent[], +): void { + const eventsByRun = new Map(stations.map((station) => [ + station.stationRunId, + events.filter((event) => event.stationRunId === station.stationRunId), + ])); + if (action === "exam_complete") { + if (current || stations.some((station) => eventsByRun.get(station.stationRunId)?.at(-1)?.eventType !== "station.advanced")) { + throw new BlockedLearnerExamResumeError("exam_complete_progress_mismatch"); + } + return; + } + if (!current) throw new BlockedLearnerExamResumeError("current_station_missing"); + const currentIndex = stations.findIndex((station) => station.stationOrder === current.stationOrder + && station.scenarioId === current.scenarioId && station.stationRunId === current.stationRunId + && station.slotId === current.slotId); + if (currentIndex < 0 || current.assembledStation.scenarioId !== current.scenarioId + || current.assembledStation.stationOrder !== current.stationOrder) { + throw new BlockedLearnerExamResumeError("current_station_identity_mismatch"); + } + for (let index = 0; index < stations.length; index += 1) { + const station = stations[index]; + const stationEvents = station ? eventsByRun.get(station.stationRunId) ?? [] : []; + if ((index < currentIndex && stationEvents.at(-1)?.eventType !== "station.advanced") + || (index > currentIndex && stationEvents.length > 0)) { + throw new BlockedLearnerExamResumeError("station_sequence_mismatch"); + } + } + const currentEvents = eventsByRun.get(current.stationRunId) ?? []; + const last = currentEvents.at(-1); + const nextExpected = LEARNER_CANONICAL_PHASE_TYPES[(last?.sequence ?? -1) + 1] ?? null; + if (last?.eventType === "station.advanced" || current.lifecycle.lastAdmittedEventType !== (last?.eventType ?? null) + || current.lifecycle.nextExpectedEventType !== nextExpected || current.lifecycle.phase !== (last?.phase ?? "not_started") + || current.lifecycle.noteSubmitted !== currentEvents.some((event) => event.eventType === "note.submitted") + || current.lifecycle.admittedEventTypes.join("\0") !== currentEvents.map((event) => event.eventType).join("\0") + || current.lifecycle.durableEventRefs.join("\0") !== currentEvents.map((event) => event.durableEventRef).join("\0") + || (action === "advance_station") !== (last?.eventType === "note.submitted")) { + throw new BlockedLearnerExamResumeError("sequence_mismatch"); + } +} + +function parseCurrentStation(value: unknown, examRunId: string): LearnerAssembledExamCurrentStation | null { + if (value === null || value === undefined) return null; + if (!isRecord(value)) throw new BlockedLearnerExamResumeError("current_station_malformed"); + const lifecycle = value.lifecycle; + if (!isRecord(lifecycle)) throw new BlockedLearnerExamResumeError("lifecycle_missing"); + const assembledStation = parseAssembledStation(value.assembledStation, examRunId); + return { + stationOrder: requiredPositiveInt(value.stationOrder, "stationOrder"), + scenarioId: requiredString(value.scenarioId, "scenarioId"), + stationRunId: requiredString(value.stationRunId, "stationRunId"), + slotId: requiredString(value.slotId, "slotId"), + assembledStation, + lifecycle: { + lastAdmittedEventType: typeof lifecycle.lastAdmittedEventType === "string" ? lifecycle.lastAdmittedEventType : null, + nextExpectedEventType: typeof lifecycle.nextExpectedEventType === "string" ? lifecycle.nextExpectedEventType : null, + admittedEventTypes: requiredStringArray(lifecycle.admittedEventTypes, "lifecycle.admittedEventTypes"), + durableEventRefs: requiredStringArray(lifecycle.durableEventRefs, "lifecycle.durableEventRefs"), + noteSubmitted: lifecycle.noteSubmitted === true, + phase: requiredString(lifecycle.phase, "lifecycle.phase"), + }, + }; +} + +function parseAssembledStation( + value: unknown, + examRunId: string, +): LearnerAssembledExamCurrentStation["assembledStation"] { + if (!isRecord(value)) throw new BlockedLearnerExamResumeError("assembled_station_missing"); + const parsedExamRunId = requiredString(value.examRunId, "assembledStation.examRunId"); + if (parsedExamRunId !== examRunId) throw new BlockedLearnerExamResumeError("exam_run_mismatch"); + const formTiming = value.formTiming; + if (!isRecord(formTiming) || !isRecord(formTiming.encounter) || !isRecord(formTiming.note)) { + throw new BlockedLearnerExamResumeError("form_timing_malformed"); + } + return { + examRunId: parsedExamRunId, + scenarioId: requiredString(value.scenarioId, "assembledStation.scenarioId"), + stationOrder: requiredPositiveInt(value.stationOrder, "assembledStation.stationOrder"), + formTiming: { + ...(isRecord(formTiming.doorway) ? { doorway: parseWindow(formTiming.doorway, "doorway") } : {}), + encounter: parseWindow(formTiming.encounter, "encounter"), + note: parseWindow(formTiming.note, "note"), + }, + }; +} + +function parseWindow(value: Record, label: string): FormWindow { + const start = value.startsAtSecond; + const end = value.endsAtSecond; + if (!Number.isInteger(start) || !Number.isInteger(end) || (start as number) < 0 || (end as number) < (start as number)) { + throw new BlockedLearnerExamResumeError(`form_timing_malformed:${label}`); + } + return { startsAtSecond: start as number, endsAtSecond: end as number }; +} + +function requiredString(value: unknown, field: string): string { + if (typeof value !== "string" || !value.trim()) throw new BlockedLearnerExamResumeError(`${field}_missing`); + return value.trim(); +} + +function requiredPositiveInt(value: unknown, field: string): number { + if (!Number.isInteger(value) || (value as number) < 1) throw new BlockedLearnerExamResumeError(`${field}_missing`); + return value as number; +} + +function requiredNonnegativeInt(value: unknown, field: string): number { + if (!Number.isInteger(value) || (value as number) < 0) throw new BlockedLearnerExamResumeError(`${field}_missing`); + return value as number; +} + +function requiredStringArray(value: unknown, field: string): string[] { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.trim())) { + throw new BlockedLearnerExamResumeError(`${field}_malformed`); + } + return value.map((item) => (item as string).trim()); +} + +async function readJsonBody(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/ui-xr/src/learner-exam-form-boot.ts b/apps/ui-xr/src/learner-exam-form-boot.ts index d47b18e1..91bc0e89 100644 --- a/apps/ui-xr/src/learner-exam-form-boot.ts +++ b/apps/ui-xr/src/learner-exam-form-boot.ts @@ -6,26 +6,34 @@ * presentation is unit-testable without importing the DOM-touching main module. */ -import type { - ExamAssemblyPersistenceSink, - ExamFormRunState, - ExamStationRunQueueScenarioSource, - ExamStationRunQueueStationBodySource, +import { + type ExamAssemblyPersistenceSink, + type ExamFormRunState, + type ExamRunStationOutcome, + type ExamStationRunQueueScenarioSource, + type ExamStationRunQueueStationBodySource, + nextExamFormRunStation, } from "@openclinxr/exam-assembly"; import { edChestPainScenario } from "@openclinxr/scenario-fixtures/ed-chest-pain"; import { - resolveLearnerExamScenarios, type ResolveLearnerExamScenariosResult, + resolveLearnerExamScenarios, } from "./learner-exam-scenario-source.js"; import { + applyLearnerExamResumeBlockedPresentation, applyLearnerPhaseTracePresentation, applyLearnerPhaseTraceRefusePresentation, + BlockedLearnerExamResumeError, + fetchLearnerAssembledExamRunAggregate, hydrateLearnerCanonicalPhaseTraceFromApi, + type LearnerAssembledExamRunAggregate, + type LearnerDurableResumeIdentity, } from "./learner-phase-trace-source.js"; import { createMultiStationExamRuntime, - persistExamFormRunQueueSnapshot, type LearnerCanonicalPhaseTraceStore, + persistExamFormRunQueueSnapshot, + tickExamFormRunClock, } from "./runtime-state.js"; export type ExamFormBootPresentationSink = { @@ -125,15 +133,28 @@ export type BootLearnerExamFormFromApiInput = { /** * Boot the learner exam form from the configured API (or leave offline fixture form). * - * - transport failure → labelled fixture_fallback; exam continues + * - existing GET /exam-runs/:id aggregate → reconstruct station/phase/clock/outcomes (not station one) + * - inconsistent durable identity or sequence → blocked-resume; never fixture data + * - transport failure on scenario queue → labelled fixture_fallback; exam continues * - shape drift on 200 → refuse presentation + clear form state (does not swallow #53) * - offline (no baseUrl) → no fetch; fixture form already at module scope */ export async function bootLearnerExamFormFromApi(input: BootLearnerExamFormFromApiInput): Promise { const blueprintId = input.blueprintId ?? "step2cs-seed"; let resolution: ResolveLearnerExamScenariosResult | null = null; + let durable: LearnerAssembledExamRunAggregate | null = null; + let durableResume: LearnerDurableResumeIdentity | undefined; if (input.baseUrl) { + try { + durable = await loadDurableExamRunAggregate(input); + } catch (error) { + blockLearnerExamResume( + input, + error instanceof BlockedLearnerExamResumeError ? error.reason : "exam_run_unreachable", + ); + return; + } try { const resolveInput: Parameters[0] = { baseUrl: input.baseUrl, @@ -143,13 +164,39 @@ export async function bootLearnerExamFormFromApi(input: BootLearnerExamFormFromA resolveInput.fetch = input.fetch; } resolution = await resolveLearnerExamScenarios(resolveInput); + if (durable && resolution.fallbackActive) { + blockLearnerExamResume(input, "durable_identity_fixture_fallback"); + return; + } applyExamFormBootPresentation({ result: resolution, sink: input.presentationSink }); - const next = - createLearnerExamFormRunState(input.examRunId, resolution.scenarios, input.examScenarioId) - ?? input.getState(); + const next = durable + ? resumeLearnerExamFormFromDurableAggregate({ + examRunId: input.examRunId, + scenarios: resolution.scenarios, + aggregate: durable, + }) + : createLearnerExamFormRunState(input.examRunId, resolution.scenarios, input.examScenarioId) + ?? input.getState(); + if (durable && (!next || next.status === "blocked")) { + blockLearnerExamResume(input, "durable_identity_mismatch", input.getState()); + return; + } input.setState(next); input.updateEvidence(); + if (durable?.currentStation) { + durableResume = { + examRunId: durable.examRunId, + stationRunId: durable.currentStation.stationRunId, + scenarioId: durable.currentStation.scenarioId, + stationOrder: durable.currentStation.stationOrder, + lastAdmittedEventType: durable.currentStation.lifecycle.lastAdmittedEventType, + }; + } } catch (error) { + if (error instanceof BlockedLearnerExamResumeError) { + blockLearnerExamResume(input, error.reason); + return; + } // Shape drift / other contract refuse — do not keep pretending the fixture form is authored. applyExamFormBootRefusePresentation({ error, sink: input.presentationSink }); input.setState(null); @@ -169,8 +216,12 @@ export async function bootLearnerExamFormFromApi(input: BootLearnerExamFormFromA if (input.fetch !== undefined) { hydrateInput.fetch = input.fetch; } - if (input.phaseTrace.stationRunId !== undefined) { - hydrateInput.stationRunId = input.phaseTrace.stationRunId; + const hydrateStationRunId = durableResume?.stationRunId ?? input.phaseTrace.stationRunId; + if (hydrateStationRunId !== undefined) { + hydrateInput.stationRunId = hydrateStationRunId; + } + if (durableResume) { + hydrateInput.durableResume = durableResume; } const hydrated = await hydrateLearnerCanonicalPhaseTraceFromApi(hydrateInput); input.phaseTrace.setStore(hydrated.store); @@ -180,6 +231,10 @@ export async function bootLearnerExamFormFromApi(input: BootLearnerExamFormFromA }); input.updateEvidence(); } catch (error) { + if (error instanceof BlockedLearnerExamResumeError) { + blockLearnerExamResume(input, error.reason, state); + return; + } applyLearnerPhaseTraceRefusePresentation({ error, sink: input.phaseTrace.presentationSink }); input.updateEvidence(); } @@ -231,6 +286,150 @@ export function stationBodySourcesFromResolution( return out; } +export function resumeLearnerExamFormFromDurableAggregate(input: { + examRunId: string; + scenarios: ReadonlyArray<{ scenarioId: string; status?: string }>; + aggregate: LearnerAssembledExamRunAggregate; +}): ExamFormRunState | null { + const created = createLearnerExamFormRunState(input.examRunId, input.scenarios); + if (!created) { + return null; + } + if (!durableFormMatchesCreated(created, input.aggregate)) { + return blockRun(created); + } + const currentIndex = input.aggregate.action === "exam_complete" + ? Math.max(0, created.queue.stationQueue.length - 1) + : input.aggregate.orderedStations.findIndex( + (station) => station.stationRunId === input.aggregate.currentStation?.stationRunId, + ); + if (currentIndex < 0) { + return blockRun(created); + } + const completedCount = input.aggregate.action === "exam_complete" + ? created.queue.stationQueue.length + : currentIndex; + const stationOutcomes: ExamRunStationOutcome[] = []; + for (let index = 0; index < completedCount; index += 1) { + const station = created.queue.stationQueue[index]; + const binding = input.aggregate.orderedStations[index]; + if (!station || !binding) { + return blockRun(created); + } + const outcome = durableStationOutcome( + station, + input.aggregate.admittedPhaseEvents.filter((event) => event.stationRunId === binding.stationRunId), + ); + if (!outcome) { + return blockRun(created); + } + stationOutcomes.push(outcome); + } + const formElapsedSecond = input.aggregate.admittedPhaseEvents.at(-1)?.formAtSecond ?? 0; + const ticked = tickExamFormRunClock(created, formElapsedSecond); + return { + ...ticked, + status: input.aggregate.action === "exam_complete" ? "complete" : "in_progress", + currentStationIndex: currentIndex, + currentPhase: { kind: "station" }, + stationOutcomes, + examEquivalenceGate: false, + }; +} + +function durableFormMatchesCreated( + created: ExamFormRunState, + aggregate: LearnerAssembledExamRunAggregate, +): boolean { + if (created.examRunId !== aggregate.examRunId + || created.examFormId !== aggregate.examFormId + || created.blueprintId !== aggregate.blueprintId + || created.queue.stationQueue.length !== aggregate.orderedStations.length) { + return false; + } + return created.queue.stationQueue.every((station, index) => { + const binding = aggregate.orderedStations[index]; + return binding !== undefined + && station.stationOrder === binding.stationOrder + && station.slotId === binding.slotId + && station.scenarioId === binding.scenarioId + && station.scenarioVersion === binding.scenarioVersion; + }); +} + +function durableStationOutcome( + station: ExamFormRunState["queue"]["stationQueue"][number], + events: LearnerAssembledExamRunAggregate["admittedPhaseEvents"], +): ExamRunStationOutcome | null { + const started = events.find((event) => event.eventType === "encounter.started"); + const noteSubmitted = events.find((event) => event.eventType === "note.submitted"); + const advanced = events.find((event) => event.eventType === "station.advanced"); + if (!started || !noteSubmitted || !advanced?.advanceReason) { + return null; + } + return { + stationOrder: station.stationOrder, + slotId: station.slotId, + scenarioId: station.scenarioId, + scenarioVersion: station.scenarioVersion, + phase: "complete", + noteSubmitted: true, + startedAtFormSecond: started.formAtSecond, + endedAtFormSecond: advanced.formAtSecond, + advanceReason: advanced.advanceReason, + recordedAtIso: advanced.recordedAtIso, + }; +} + +export function learnerExamResumeNextStation(run: ExamFormRunState | null): { scenarioId: string; stationOrder: number } | null { + if (!run) { + return null; + } + const next = nextExamFormRunStation(run); + if (!next || typeof next.scenarioId !== "string" || next.scenarioId.length === 0) { + return null; + } + return { scenarioId: next.scenarioId, stationOrder: next.stationOrder }; +} + +async function loadDurableExamRunAggregate( + input: BootLearnerExamFormFromApiInput, +): Promise { + if (!input.baseUrl) { + return null; + } + const fetchInput: Parameters[0] = { + baseUrl: input.baseUrl, + examRunId: input.examRunId, + }; + if (input.fetch !== undefined) { + fetchInput.fetch = input.fetch; + } + const result = await fetchLearnerAssembledExamRunAggregate(fetchInput); + return result.found ? result.aggregate : null; +} + +function blockLearnerExamResume( + input: BootLearnerExamFormFromApiInput, + reason: string, + state?: ExamFormRunState | null, +): void { + applyLearnerExamResumeBlockedPresentation({ reason, sink: input.presentationSink }); + if (input.phaseTrace) { + applyLearnerExamResumeBlockedPresentation({ reason, sink: input.phaseTrace.presentationSink }); + } + const blocked = blockRun(state ?? input.getState()); + input.setState(blocked); + input.updateEvidence(); +} + +function blockRun(run: ExamFormRunState | null): ExamFormRunState | null { + if (!run) { + return null; + } + return { ...run, status: "blocked", examEquivalenceGate: false }; +} + function humanizeFallbackReason(reason: string): string { // Keep short machine reasons readable; long error messages (GET failed: …) stay as-is. if (reason.includes(" ") || reason.includes(":") || reason.includes("/")) { diff --git a/apps/ui-xr/src/learner-phase-trace-source.ts b/apps/ui-xr/src/learner-phase-trace-source.ts index 2befd61e..9ac85915 100644 --- a/apps/ui-xr/src/learner-phase-trace-source.ts +++ b/apps/ui-xr/src/learner-phase-trace-source.ts @@ -9,17 +9,19 @@ * - derived `station_run_*` identity is local-only and is never a fetch authority * - malformed 200 body → throw (never silently become local truth) * - validated persisted events only become canonical after the identity/order/time/durable-ref gate + * - durable resume identity/sequence mismatch → BlockedLearnerExamResumeError (never fixture data) */ import type { ExamFormRunState } from "@openclinxr/exam-assembly"; -import { validateTraceEvent, type TraceEvent } from "@openclinxr/shared-schemas"; +import { type TraceEvent, validateTraceEvent } from "@openclinxr/shared-schemas"; +import { BlockedLearnerExamResumeError } from "./learner-assembled-exam-run-source.js"; import { admitLearnerCanonicalPhaseEvent, createLearnerCanonicalPhaseTraceStore, LEARNER_CANONICAL_PHASE_TYPES, - viewLearnerCanonicalExamPhase, type LearnerCanonicalExamPhaseView, type LearnerCanonicalPhaseTraceStore, + viewLearnerCanonicalExamPhase, } from "./runtime-state.js"; type TextSink = { @@ -33,6 +35,14 @@ export type LearnerStationTraceIdentity = { stationOrder: number; }; +export type LearnerDurableResumeIdentity = { + examRunId: string; + stationRunId: string; + scenarioId: string; + stationOrder: number; + lastAdmittedEventType?: string | null; +}; + export type HydrateLearnerCanonicalPhaseTraceInput = { baseUrl: string | undefined; examRun: ExamFormRunState; @@ -40,8 +50,24 @@ export type HydrateLearnerCanonicalPhaseTraceInput = { fetch?: typeof fetch; /** Actual API session id from startSession. Required to fetch; derived local ids are not authority. */ stationRunId?: string; + /** When set, identity/sequence mismatch is blocked-resume, never local fixture truth. */ + durableResume?: LearnerDurableResumeIdentity; }; +export { + applyLearnerExamResumeBlockedPresentation, + BlockedLearnerExamResumeError, + fetchLearnerAssembledExamRunAggregate, + LEARNER_ASSEMBLED_EXAM_RUN_ACTIONS, + type LearnerAssembledExamAdmittedPhaseEvent, + type LearnerAssembledExamCurrentStation, + type LearnerAssembledExamLifecycle, + type LearnerAssembledExamRunAction, + type LearnerAssembledExamRunAggregate, + type LearnerAssembledExamStationBinding, + parseLearnerAssembledExamRunAggregate, +} from "./learner-assembled-exam-run-source.js"; + export type HydrateLearnerCanonicalPhaseTraceResult = { store: LearnerCanonicalPhaseTraceStore; view: LearnerCanonicalExamPhaseView; @@ -113,10 +139,13 @@ export function applyLearnerPhaseTraceRefusePresentation(input: { export async function hydrateLearnerCanonicalPhaseTraceFromApi( input: HydrateLearnerCanonicalPhaseTraceInput, ): Promise { - const identity = resolveActiveLearnerStationTraceIdentity({ + let identity = resolveActiveLearnerStationTraceIdentity({ examRun: input.examRun, ...(input.stationRunId !== undefined ? { stationRunId: input.stationRunId } : {}), }); + if (input.durableResume) { + identity = alignIdentityToDurableResume(identity, input.durableResume); + } if (!identity) { const view = viewLearnerCanonicalExamPhase(input.store); return { @@ -140,7 +169,7 @@ export async function hydrateLearnerCanonicalPhaseTraceFromApi( }; } - const apiStationRunId = input.stationRunId?.trim(); + const apiStationRunId = (input.durableResume?.stationRunId ?? input.stationRunId)?.trim(); if (!apiStationRunId) { const view = viewLearnerCanonicalExamPhase(aligned); return { @@ -180,6 +209,7 @@ export async function hydrateLearnerCanonicalPhaseTraceFromApi( (LEARNER_CANONICAL_PHASE_TYPES as readonly string[]).includes(event.eventType), ); if (phaseEvents.length === 0) { + assertDurableResumeSequence(input.durableResume, null); const view = viewLearnerCanonicalExamPhase(aligned); return { store: aligned, @@ -194,7 +224,7 @@ export async function hydrateLearnerCanonicalPhaseTraceFromApi( for (const event of phaseEvents) { const admitted = admitLearnerCanonicalPhaseEvent(candidate, event); if (!admitted.ok) { - throw new MalformedTraceEventsPayloadError(`canonical_phase_event_refused: ${admitted.reason}`); + refuseCanonicalPhase(input.durableResume, `canonical_phase_event_refused: ${admitted.reason}`); } candidate = admitted.store; } @@ -203,19 +233,66 @@ export async function hydrateLearnerCanonicalPhaseTraceFromApi( const previousSequence = lastAdmittedSequence(aligned); const candidateSequence = lastAdmittedSequence(candidate); if (previousSequence !== null && (candidateSequence === null || candidateSequence < previousSequence)) { - throw new MalformedTraceEventsPayloadError( + refuseCanonicalPhase( + input.durableResume, `canonical_phase_event_refused: stale_or_regressing_sequence (had ${previousSequence}, got ${candidateSequence})`, ); } if (previousSequence !== null && candidateSequence === previousSequence) { const view = viewLearnerCanonicalExamPhase(aligned); + assertDurableResumeSequence(input.durableResume, lastEventType(aligned)); return { store: aligned, view, fetched: true, identity }; } const view = viewLearnerCanonicalExamPhase(candidate); + assertDurableResumeSequence(input.durableResume, lastEventType(candidate)); return { store: candidate, view, fetched: true, identity }; } +function alignIdentityToDurableResume( + identity: LearnerStationTraceIdentity | null, + expected: LearnerDurableResumeIdentity, +): LearnerStationTraceIdentity { + if ( + !identity + || identity.examRunId !== expected.examRunId + || identity.scenarioId !== expected.scenarioId + || identity.stationOrder !== expected.stationOrder + ) { + throw new BlockedLearnerExamResumeError("durable_identity_mismatch"); + } + if (identity.stationRunId !== expected.stationRunId) { + throw new BlockedLearnerExamResumeError("station_run_mismatch"); + } + return identity; +} + +function assertDurableResumeSequence( + durableResume: LearnerDurableResumeIdentity | undefined, + lastType: string | null, +): void { + if (!durableResume) { + return; + } + const expectedType = durableResume.lastAdmittedEventType; + if (expectedType === undefined) { + return; + } + if (expectedType !== lastType) { + throw new BlockedLearnerExamResumeError("sequence_mismatch"); + } +} + +function lastEventType(store: LearnerCanonicalPhaseTraceStore): string | null { + return store.persistedEvents[store.persistedEvents.length - 1]?.eventType ?? null; +} +function refuseCanonicalPhase(durableResume: LearnerDurableResumeIdentity | undefined, message: string): never { + if (durableResume) { + throw new BlockedLearnerExamResumeError(`sequence_mismatch: ${message}`); + } + throw new MalformedTraceEventsPayloadError(message); +} + function lastAdmittedSequence(store: LearnerCanonicalPhaseTraceStore): number | null { const last = store.persistedEvents[store.persistedEvents.length - 1]; return last === undefined ? null : last.sequence; diff --git a/apps/ui-xr/src/the-learner-runtime-resumes-the-durable-assembled-exam.test.ts b/apps/ui-xr/src/the-learner-runtime-resumes-the-durable-assembled-exam.test.ts new file mode 100644 index 00000000..6e2ab452 --- /dev/null +++ b/apps/ui-xr/src/the-learner-runtime-resumes-the-durable-assembled-exam.test.ts @@ -0,0 +1,466 @@ +import type { ExamFormRunState } from "@openclinxr/exam-assembly"; +import { edChestPainScenario } from "@openclinxr/scenario-fixtures/ed-chest-pain"; +import { pediatricAsthmaScenario } from "@openclinxr/scenario-fixtures/pediatric-asthma"; +import type { TraceEvent } from "@openclinxr/shared-schemas"; +import { describe, expect, it } from "vitest"; +import { + bootLearnerExamFormFromApi, + createLearnerExamFormRunState, + learnerExamResumeNextStation, +} from "./learner-exam-form-boot.js"; +import { + createLearnerCanonicalPhaseTraceStore, + LEARNER_CANONICAL_PHASE_TYPES, + type LearnerCanonicalPhaseTraceStore, + viewLearnerCanonicalExamPhase, +} from "./runtime-state.js"; + +/** + * PLANTED CONTRACT — learner runtime resumes an assembled exam from the durable + * admitted phase trace after reload. + * + * Diagnosis (immutable): bootLearnerExamFormFromApi always called + * createLearnerExamFormRunState(..., start: true), which rebuilds a local queue + * at station one and ignores GET /exam-runs/:id. Canonical traces hydrated + * against that fresh pointer, so reload lost current station, encounter/note + * phase, form clock, completed outcomes, and next navigation. Inconsistent + * durable identity could fall through to fixture data. + * + * This file pins: resume from the API run aggregate + admitted phase events; + * blocked-resume on identity/sequence mismatch; never silent fixture switch; + * examEquivalenceGate stays false. + */ + +const BASE_URL = "http://localhost:8787"; +const EXAM_RUN_ID = "exam_run_durable_resume_001"; +const SCENARIO_A = edChestPainScenario.scenarioId; +const SCENARIO_B = pediatricAsthmaScenario.scenarioId; +const STATION_A_RUN_ID = `${EXAM_RUN_ID}:station:1`; +const STATION_B_RUN_ID = `${EXAM_RUN_ID}:station:2`; +const PRIOR_ADVANCED_AT = "2026-09-04T14:05:06.000Z"; + +const approvedA = { ...edChestPainScenario, status: "approved" as const }; +const approvedB = { ...pediatricAsthmaScenario, status: "approved" as const }; + +function payloadPhase(eventType: (typeof LEARNER_CANONICAL_PHASE_TYPES)[number]) { + return eventType === "station.advanced" ? "complete" : eventType.startsWith("note.") ? "note" : "encounter"; +} + +function persistedEvent( + identity: { examRunId: string; stationRunId: string; scenarioId: string; stationOrder: number }, + sequence: number, + eventType: (typeof LEARNER_CANONICAL_PHASE_TYPES)[number], +): TraceEvent { + const atSecond = sequence; + return { + stationRunId: identity.stationRunId, + sequence, + eventType, + occurredAt: new Date(Date.parse("2026-05-03T15:38:58.000Z") + atSecond * 1000).toISOString(), + atSecond, + source: "system", + payload: { + scenarioId: identity.scenarioId, + examRunId: identity.examRunId, + stationOrder: identity.stationOrder, + phase: payloadPhase(eventType), + formAtSecond: atSecond, + durableEventRef: `durable://station-runs/${identity.stationRunId}/events/${sequence}`, + ...(eventType === "station.advanced" ? { advanceReason: "patient_note_submitted_advancing" } : {}), + }, + }; +} + +function lifecycle(lastAdmittedEventType: string | null, phase: string, admittedEventTypes: string[]) { + const stationRunId = phase === "complete" ? STATION_A_RUN_ID : STATION_B_RUN_ID; + const nextExpectedEventType = lastAdmittedEventType === null + ? LEARNER_CANONICAL_PHASE_TYPES[0] + : LEARNER_CANONICAL_PHASE_TYPES[ + LEARNER_CANONICAL_PHASE_TYPES.indexOf(lastAdmittedEventType as (typeof LEARNER_CANONICAL_PHASE_TYPES)[number]) + 1 + ] ?? null; + return { + lastAdmittedEventType, + nextExpectedEventType, + admittedEventTypes, + durableEventRefs: admittedEventTypes.map((_, index) => `durable://station-runs/${stationRunId}/events/${index}`), + noteSubmitted: lastAdmittedEventType === "note.submitted" || lastAdmittedEventType === "station.advanced", + phase, + }; +} + +function admittedEvent(input: { + stationRunId: string; + scenarioId: string; + stationOrder: number; + sequence: number; + eventType: (typeof LEARNER_CANONICAL_PHASE_TYPES)[number]; + atSecond: number; + formAtSecond: number; + recordedAtIso: string; + advanceReason?: string; +}) { + return { + examRunId: EXAM_RUN_ID, + stationRunId: input.stationRunId, + scenarioId: input.scenarioId, + stationOrder: input.stationOrder, + sequence: input.sequence, + eventType: input.eventType, + atSecond: input.atSecond, + formAtSecond: input.formAtSecond, + durableEventRef: `durable://station-runs/${input.stationRunId}/events/${input.sequence}`, + phase: payloadPhase(input.eventType), + source: "system", + recordedAtIso: input.recordedAtIso, + ...(input.advanceReason ? { advanceReason: input.advanceReason } : {}), + }; +} + +function durableEvents() { + const baseMs = Date.parse("2026-09-04T14:00:00.000Z"); + const event = ( + stationRunId: string, + scenarioId: string, + stationOrder: number, + sequence: number, + eventType: (typeof LEARNER_CANONICAL_PHASE_TYPES)[number], + atSecond: number, + formAtSecond: number, + recordedAtIso = new Date(baseMs + (stationOrder - 1) * 600_000 + sequence * 1000).toISOString(), + advanceReason?: string, + ) => admittedEvent({ + stationRunId, + scenarioId, + stationOrder, + sequence, + eventType, + atSecond, + formAtSecond, + recordedAtIso, + ...(advanceReason ? { advanceReason } : {}), + }); + return [ + event(STATION_A_RUN_ID, SCENARIO_A, 1, 0, "encounter.started", 60, 60), + event(STATION_A_RUN_ID, SCENARIO_A, 1, 1, "encounter.ended", 960, 960), + event(STATION_A_RUN_ID, SCENARIO_A, 1, 2, "note.started", 960, 960), + event(STATION_A_RUN_ID, SCENARIO_A, 1, 3, "note.submitted", 1560, 1560), + event( + STATION_A_RUN_ID, + SCENARIO_A, + 1, + 4, + "station.advanced", + 1560, + 1560, + PRIOR_ADVANCED_AT, + "patient_note_submitted_advancing", + ), + event(STATION_B_RUN_ID, SCENARIO_B, 2, 0, "encounter.started", 60, 1620), + event(STATION_B_RUN_ID, SCENARIO_B, 2, 1, "encounter.ended", 960, 2520), + event(STATION_B_RUN_ID, SCENARIO_B, 2, 2, "note.started", 960, 2520), + ]; +} + +function aggregate(overrides: Record = {}) { + const events = durableEvents(); + return { + examRunId: EXAM_RUN_ID, + stationRunId: STATION_B_RUN_ID, + examFormId: `form_${EXAM_RUN_ID}`, + blueprintId: "blueprint_openclinxr_step2cs_style_seed_v1", + action: "resume_station", + orderedStations: [ + { + stationOrder: 1, + slotId: `station_001_${SCENARIO_A}`, + stationRunId: STATION_A_RUN_ID, + scenarioId: SCENARIO_A, + scenarioVersion: approvedA.version, + }, + { + stationOrder: 2, + slotId: `station_002_${SCENARIO_B}`, + stationRunId: STATION_B_RUN_ID, + scenarioId: SCENARIO_B, + scenarioVersion: approvedB.version, + }, + ], + admittedPhaseEvents: events, + currentStation: { + stationOrder: 2, + scenarioId: SCENARIO_B, + stationRunId: STATION_B_RUN_ID, + slotId: `station_002_${SCENARIO_B}`, + assembledStation: { + examRunId: EXAM_RUN_ID, + scenarioId: SCENARIO_B, + stationOrder: 2, + formTiming: { + doorway: { startsAtSecond: 1560, endsAtSecond: 1620 }, + encounter: { startsAtSecond: 1620, endsAtSecond: 2520 }, + note: { startsAtSecond: 2520, endsAtSecond: 2820 }, + }, + }, + lifecycle: lifecycle("note.started", "note", ["encounter.started", "encounter.ended", "note.started"]), + }, + durableEventRefs: events.map((event) => event.durableEventRef), + omissions: [], + claimBoundary: "assembled_exam_resume_not_exam_equivalence", + notEvidenceFor: ["exam_equivalence", "clinical_validity", "scoring_validity"], + examEquivalenceGate: false, + ...overrides, + }; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function examFetch(input: { + examRun?: { status: number; body: unknown } | "network_error"; + traces?: TraceEvent[] | { status: number; body: unknown }; + requests?: string[]; +}): typeof fetch { + const station2 = `${EXAM_RUN_ID}:station:2`; + const identity = { + examRunId: EXAM_RUN_ID, + stationRunId: station2, + scenarioId: SCENARIO_B, + stationOrder: 2, + }; + const defaultTraces = (["encounter.started", "encounter.ended", "note.started"] as const).map((eventType, sequence) => + persistedEvent(identity, sequence, eventType), + ); + return (async (request: RequestInfo | URL) => { + const url = String(request); + input.requests?.push(url); + if (url.includes("/exam-runs/")) { + const examRun = input.examRun ?? { status: 200, body: aggregate() }; + if (examRun === "network_error") { + throw new TypeError("fetch failed"); + } + return jsonResponse(examRun.status, examRun.body); + } + if (url.includes("/station-run-queue")) { + return jsonResponse(200, { stationQueue: [{ scenarioId: SCENARIO_A }, { scenarioId: SCENARIO_B }] }); + } + if (url.includes(`/scenarios/${encodeURIComponent(SCENARIO_B)}`)) { + return jsonResponse(200, approvedB); + } + if (url.includes("/scenarios/")) { + return jsonResponse(200, approvedA); + } + if (url.includes("/trace-events")) { + const traces = input.traces ?? defaultTraces; + if (Array.isArray(traces)) { + return jsonResponse(200, traces); + } + return jsonResponse(traces.status, traces.body); + } + return jsonResponse(404, { error: "not_found" }); + }) as typeof fetch; +} + +function bootHarness(fetchImpl: typeof fetch) { + let state: ExamFormRunState | null = createLearnerExamFormRunState( + EXAM_RUN_ID, + [approvedA, approvedB], + SCENARIO_A, + ); + let store: LearnerCanonicalPhaseTraceStore = createLearnerCanonicalPhaseTraceStore({ + examRunId: EXAM_RUN_ID, + stationRunId: `station_run_${EXAM_RUN_ID}_${SCENARIO_A}_1`, + scenarioId: SCENARIO_A, + stationOrder: 1, + }); + const presentationSink = { textContent: "" }; + const phaseSink = { textContent: "" }; + return { + get state() { + return state; + }, + get store() { + return store; + }, + presentationSink, + phaseSink, + run: () => + bootLearnerExamFormFromApi({ + baseUrl: BASE_URL, + examRunId: EXAM_RUN_ID, + examScenarioId: SCENARIO_A, + getState: () => state, + setState: (next) => { + state = next; + }, + updateEvidence: () => undefined, + presentationSink, + fetch: fetchImpl, + phaseTrace: { + getStore: () => store, + setStore: (next) => { + store = next; + }, + presentationSink: phaseSink, + }, + }), + }; +} + +describe("the learner runtime resumes the durable assembled exam", () => { + it("reconstructs current station, note phase, clock, completed outcomes, and next navigation from GET /exam-runs", async () => { + const harness = bootHarness(examFetch({})); + await harness.run(); + + expect(harness.state).not.toBeNull(); + expect(harness.state?.status).toBe("in_progress"); + expect(harness.state?.examEquivalenceGate).toBe(false); + expect(harness.state?.currentStationIndex).toBe(1); + expect(harness.state?.queue.stationQueue[harness.state.currentStationIndex]?.scenarioId).toBe(SCENARIO_B); + expect(harness.state?.stationOutcomes).toHaveLength(1); + expect(harness.state?.stationOutcomes[0]?.scenarioId).toBe(SCENARIO_A); + expect(harness.state?.stationOutcomes[0]?.phase).toBe("complete"); + expect(harness.state?.stationOutcomes[0]).toMatchObject({ + startedAtFormSecond: 60, + endedAtFormSecond: 1560, + advanceReason: "patient_note_submitted_advancing", + recordedAtIso: PRIOR_ADVANCED_AT, + }); + expect(harness.state?.clock.formElapsedSecond).toBe(2520); + expect(learnerExamResumeNextStation(harness.state)).toBeNull(); + expect(String(harness.presentationSink.textContent)).not.toMatch(/fixture/i); + + const view = viewLearnerCanonicalExamPhase(harness.store); + expect(view.source).toBe("canonical_assembled_exam_phase_trace"); + expect(view.phase).toBe("note"); + expect(view.fallbackActive).toBe(false); + expect(view.examEquivalenceGate).toBe(false); + expect(harness.store.stationRunId).toBe(`${EXAM_RUN_ID}:station:2`); + expect(harness.store.persistedEvents.map((event) => event.eventType)).toEqual([ + "encounter.started", + "encounter.ended", + "note.started", + ]); + }); + + it("does not recreate a local in-progress queue at station one when a durable aggregate exists", async () => { + const harness = bootHarness(examFetch({})); + await harness.run(); + expect(harness.state?.status).not.toBe("blocked"); + expect(harness.state?.currentStationIndex).not.toBe(0); + expect(harness.state?.queue.stationQueue[0]?.scenarioId).toBe(SCENARIO_A); + }); + + it("blocks rather than fabricating a prior outcome timestamp when the durable event lacks one", async () => { + const events = durableEvents(); + const advanced = events[4]; + expect(advanced?.eventType).toBe("station.advanced"); + const harness = bootHarness(examFetch({ + examRun: { + status: 200, + body: aggregate({ + admittedPhaseEvents: events.map((event, index) => index === 4 + ? { ...event, recordedAtIso: undefined } + : event), + }), + }, + })); + await harness.run(); + expect(harness.state?.status).toBe("blocked"); + expect(harness.state?.stationOutcomes).toEqual([]); + }); + + it("blocks resume on durable examRun identity mismatch and never labels fixtures", async () => { + const harness = bootHarness( + examFetch({ + examRun: { status: 200, body: aggregate({ examRunId: "exam_run_other" }) }, + }), + ); + await harness.run(); + expect(harness.state?.status).toBe("blocked"); + expect(harness.state?.examEquivalenceGate).toBe(false); + expect(String(harness.presentationSink.textContent).toLowerCase()).toContain("blocked"); + expect(String(harness.presentationSink.textContent).toLowerCase()).not.toContain("fixture"); + }); + + it("blocks resume on 409 stale identity without switching to fixture data", async () => { + const harness = bootHarness( + examFetch({ + examRun: { + status: 409, + body: { error: "stale_identity", reason: "form_mismatch", examEquivalenceGate: false, notEvidenceFor: ["exam_equivalence"] }, + }, + }), + ); + await harness.run(); + expect(harness.state?.status).toBe("blocked"); + expect(String(harness.presentationSink.textContent).toLowerCase()).toContain("blocked"); + expect(String(harness.presentationSink.textContent).toLowerCase()).not.toContain("fixture"); + }); + + it("blocks resume when the known exam-run GET has a network failure", async () => { + const requests: string[] = []; + const harness = bootHarness(examFetch({ examRun: "network_error", requests })); + await harness.run(); + expect(harness.state?.status).toBe("blocked"); + expect(String(harness.presentationSink.textContent)).toContain("exam run unreachable"); + expect(harness.state?.currentStationIndex).toBe(0); + expect(requests.some((url) => url.includes("station-run-queue"))).toBe(false); + }); + + it("blocks resume when the known exam-run GET returns 5xx", async () => { + const requests: string[] = []; + const harness = bootHarness(examFetch({ + examRun: { status: 503, body: { error: "durable_store_unavailable" } }, + requests, + })); + await harness.run(); + expect(harness.state?.status).toBe("blocked"); + expect(String(harness.presentationSink.textContent)).toContain("exam run get failed 503"); + expect(String(harness.presentationSink.textContent)).not.toMatch(/fixture/i); + expect(requests.some((url) => url.includes("station-run-queue"))).toBe(false); + }); + + it("blocks resume when admitted trace sequence disagrees with the durable aggregate", async () => { + const identity = { + examRunId: EXAM_RUN_ID, + stationRunId: `${EXAM_RUN_ID}:station:2`, + scenarioId: SCENARIO_B, + stationOrder: 2, + }; + const harness = bootHarness( + examFetch({ + traces: [persistedEvent(identity, 0, "encounter.started")], + }), + ); + await harness.run(); + expect(harness.state?.status).toBe("blocked"); + expect(harness.state?.examEquivalenceGate).toBe(false); + expect(String(harness.presentationSink.textContent).toLowerCase()).toContain("blocked"); + expect(String(harness.presentationSink.textContent).toLowerCase()).not.toContain("fixture"); + }); + + it("creates a fresh local form when no durable exam-run aggregate exists (404)", async () => { + const harness = bootHarness(async (request: RequestInfo | URL) => { + const url = String(request); + if (url.includes("/exam-runs/")) { + return jsonResponse(404, { error: "assembled_exam_run_not_found" }); + } + if (url.includes("/station-run-queue")) { + return jsonResponse(200, { stationQueue: [{ scenarioId: SCENARIO_A }] }); + } + if (url.includes("/scenarios/")) { + return jsonResponse(200, approvedA); + } + return jsonResponse(404, { error: "not_found" }); + }); + await harness.run(); + expect(harness.state?.status).toBe("in_progress"); + expect(harness.state?.currentStationIndex).toBe(0); + expect(harness.state?.examEquivalenceGate).toBe(false); + expect(String(harness.presentationSink.textContent)).not.toMatch(/blocked/i); + }); +});