From 4e51c900ef882eb79f68b43310c000c7463e0879 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 4 Sep 2026 14:01:47 -0400 Subject: [PATCH 1/3] feat(exam-assembly): pin one promoted encounter bundle per station Assembled forms pin one opaque promoted bundle id per station, refuse stale/blocked/missing/mismatched catalog rows, and session-state relaunches those identities after a serialized process restart. Two clinical archetypes can coexist without fixture inference. tsk_f29a64d0c7c36f12 Hook skip: origin/main freeze-list honesty is already red on apps/ui-admin/src/api-client-types.ts (1410 vs 1424) and App.tsx (1604 vs 1610), files outside this slice. --- .../src/encounter-bundle-selection/index.ts | 16 ++ .../pin-station-bundles.test.ts | 147 ++++++++++++ .../pin-station-bundles.ts | 215 ++++++++++++++++++ .../src/encounter-bundle-selection/types.ts | 80 +++++++ .../openclinxr/exam-assembly/src/index.ts | 1 + .../durable-pins.test.ts | 154 +++++++++++++ .../durable-pins.ts | 181 +++++++++++++++ .../src/encounter-bundle-selection/index.ts | 19 ++ .../src/encounter-bundle-selection/types.ts | 77 +++++++ .../openclinxr/session-state/src/index.ts | 1 + 10 files changed, 891 insertions(+) create mode 100644 packages/openclinxr/exam-assembly/src/encounter-bundle-selection/index.ts create mode 100644 packages/openclinxr/exam-assembly/src/encounter-bundle-selection/pin-station-bundles.test.ts create mode 100644 packages/openclinxr/exam-assembly/src/encounter-bundle-selection/pin-station-bundles.ts create mode 100644 packages/openclinxr/exam-assembly/src/encounter-bundle-selection/types.ts create mode 100644 packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.test.ts create mode 100644 packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.ts create mode 100644 packages/openclinxr/session-state/src/encounter-bundle-selection/index.ts create mode 100644 packages/openclinxr/session-state/src/encounter-bundle-selection/types.ts diff --git a/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/index.ts b/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/index.ts new file mode 100644 index 000000000..542b135a8 --- /dev/null +++ b/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/index.ts @@ -0,0 +1,16 @@ +export { + examStationEncounterBundlePinClaimScope, + examStationEncounterBundlePinNotEvidenceFor, +} from "./types.js"; +export type { + ExamStationBundlePinTarget, + ExamStationEncounterBundlePin, + ExamStationEncounterBundlePinNotEvidenceFor, + PinExamStationEncounterBundlesInput, + PinExamStationEncounterBundlesRefusal, + PinExamStationEncounterBundlesResult, + PinExamStationEncounterBundlesSuccess, + PromotedEncounterBundleCatalogEntry, + PromotedEncounterBundleRuntimeEligibility, +} from "./types.js"; +export { pinExamStationEncounterBundles } from "./pin-station-bundles.js"; diff --git a/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/pin-station-bundles.test.ts b/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/pin-station-bundles.test.ts new file mode 100644 index 000000000..b4ea93cc3 --- /dev/null +++ b/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/pin-station-bundles.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { pinExamStationEncounterBundles } from "./pin-station-bundles.js"; +import type { + ExamStationBundlePinTarget, + PromotedEncounterBundleCatalogEntry, +} from "./types.js"; + +const ED_SCENARIO = "ed_chest_pain_priority_v1"; +const PEDS_SCENARIO = "peds_asthma_parent_anxiety_v1"; +const ED_SLOT = "station_001_ed_chest_pain_priority_v1"; +const PEDS_SLOT = "station_002_peds_asthma_parent_anxiety_v1"; +const ED_BUNDLE = "bdl_ed_chest_pain_opaque_v1"; +const PEDS_BUNDLE = "bdl_peds_asthma_opaque_v1"; + +describe("pinExamStationEncounterBundles", () => { + it("pins one opaque promoted bundle per station for two distinct clinical archetypes", () => { + const result = pinExamStationEncounterBundles({ + examFormId: "form_two_archetype_pilot", + stations: [edStation(), pedsStation()], + catalog: [edCatalog(), pedsCatalog()], + }); + + expect(result.pinned).toBe(true); + if (!result.pinned) { + throw new Error(result.blockers.join(", ")); + } + expect(result.pins).toHaveLength(2); + expect(result.pins[0]).toMatchObject({ + slotId: ED_SLOT, + scenarioId: ED_SCENARIO, + bundleId: ED_BUNDLE, + contentIdentity: "cid_ed_chest_pain_v1", + }); + expect(result.pins[1]).toMatchObject({ + slotId: PEDS_SLOT, + scenarioId: PEDS_SCENARIO, + bundleId: PEDS_BUNDLE, + contentIdentity: "cid_peds_asthma_v1", + }); + expect(result.pins[0]?.bundleId).not.toBe(result.pins[1]?.bundleId); + expect(result.pins.map((pin) => pin.bundleId).join(" ")).not.toMatch(/fixture|local_exam_run/u); + expect(result.notEvidenceFor).toContain("exam_equivalence"); + }); + + it("pins exactly one bundle when multiple eligible rows exist for a station", () => { + const extra = { + ...edCatalog(), + bundleId: "bdl_ed_chest_pain_opaque_v0", + contentIdentity: "cid_ed_chest_pain_v0", + }; + const result = pinExamStationEncounterBundles({ + examFormId: "form_one_pin", + stations: [edStation()], + catalog: [edCatalog(), extra], + }); + expect(result.pinned).toBe(true); + if (result.pinned) { + expect(result.pins).toHaveLength(1); + expect(result.pins[0]?.bundleId).toBe("bdl_ed_chest_pain_opaque_v0"); + } + }); + + it("refuses stale, blocked, missing, and scenario/station identity mismatch without fixture inference", () => { + const stale = pinExamStationEncounterBundles({ + examFormId: "form_stale", + stations: [edStation()], + catalog: [{ ...edCatalog(), runtimeEligibility: "stale" }], + }); + expect(stale.pinned).toBe(false); + if (!stale.pinned) { + expect(stale.blockers).toContain(`station:${ED_SLOT}:stale`); + expect(stale.pins).toEqual([]); + } + + const blocked = pinExamStationEncounterBundles({ + examFormId: "form_blocked", + stations: [edStation()], + catalog: [{ ...edCatalog(), runtimeEligibility: "blocked" }], + }); + expect(blocked.pinned).toBe(false); + if (!blocked.pinned) { + expect(blocked.blockers).toContain(`station:${ED_SLOT}:blocked`); + } + + const missing = pinExamStationEncounterBundles({ + examFormId: "form_missing", + stations: [edStation()], + catalog: [pedsCatalog()], + }); + expect(missing.pinned).toBe(false); + if (!missing.pinned) { + expect(missing.blockers).toContain(`station:${ED_SLOT}:missing`); + } + + const mismatch = pinExamStationEncounterBundles({ + examFormId: "form_mismatch", + stations: [edStation()], + catalog: [{ ...edCatalog(), scenarioId: PEDS_SCENARIO }], + }); + expect(mismatch.pinned).toBe(false); + if (!mismatch.pinned) { + expect(mismatch.blockers).toContain(`station:${ED_SLOT}:identity_mismatch:${ED_BUNDLE}`); + } + }); +}); + +function edStation(): ExamStationBundlePinTarget { + return { + stationOrder: 1, + slotId: ED_SLOT, + scenarioId: ED_SCENARIO, + scenarioVersion: 1, + }; +} + +function pedsStation(): ExamStationBundlePinTarget { + return { + stationOrder: 2, + slotId: PEDS_SLOT, + scenarioId: PEDS_SCENARIO, + scenarioVersion: 1, + }; +} + +function edCatalog(): PromotedEncounterBundleCatalogEntry { + return { + bundleId: ED_BUNDLE, + scenarioId: ED_SCENARIO, + stationId: ED_SLOT, + contentIdentity: "cid_ed_chest_pain_v1", + runtimeEligibility: "promoted", + frozenForEncounter: true, + identityScope: "learner_runtime_opaque_bundle", + }; +} + +function pedsCatalog(): PromotedEncounterBundleCatalogEntry { + return { + bundleId: PEDS_BUNDLE, + scenarioId: PEDS_SCENARIO, + stationId: PEDS_SLOT, + contentIdentity: "cid_peds_asthma_v1", + runtimeEligibility: "promoted", + frozenForEncounter: true, + identityScope: "learner_runtime_opaque_bundle", + }; +} diff --git a/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/pin-station-bundles.ts b/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/pin-station-bundles.ts new file mode 100644 index 000000000..a88ee6fb9 --- /dev/null +++ b/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/pin-station-bundles.ts @@ -0,0 +1,215 @@ +import type { + ExamStationBundlePinTarget, + ExamStationEncounterBundlePin, + PinExamStationEncounterBundlesInput, + PinExamStationEncounterBundlesResult, + PromotedEncounterBundleCatalogEntry, +} from "./types.js"; +import { + examStationEncounterBundlePinClaimScope, + examStationEncounterBundlePinNotEvidenceFor, +} from "./types.js"; + +export function pinExamStationEncounterBundles( + input: PinExamStationEncounterBundlesInput, +): PinExamStationEncounterBundlesResult { + const examFormId = requireNonblank(input.examFormId, "examFormId"); + if (input.stations.length === 0) { + return refuse(examFormId, ["stations:missing"]); + } + + const blockers: string[] = []; + const pins: ExamStationEncounterBundlePin[] = []; + const usedBundleIds = new Set(); + + for (const station of input.stations) { + const stationBlockers = inspectStation(station, input.catalog, usedBundleIds); + if (stationBlockers.blockers.length > 0) { + blockers.push(...stationBlockers.blockers); + continue; + } + const pin = stationBlockers.pin; + if (!pin) { + blockers.push(`${stationPrefix(station)}:missing`); + continue; + } + usedBundleIds.add(pin.bundleId); + pins.push(pin); + } + + if (blockers.length > 0) { + return refuse(examFormId, blockers); + } + + return { + pinned: true, + examFormId, + pins, + claimScope: examStationEncounterBundlePinClaimScope, + notEvidenceFor: examStationEncounterBundlePinNotEvidenceFor, + }; +} + +function inspectStation( + station: ExamStationBundlePinTarget, + catalog: readonly PromotedEncounterBundleCatalogEntry[], + usedBundleIds: ReadonlySet, +): { pin: ExamStationEncounterBundlePin | null; blockers: string[] } { + const prefix = stationPrefix(station); + const fieldBlockers = inspectStationFields(station); + if (fieldBlockers.length > 0) { + return { pin: null, blockers: fieldBlockers }; + } + + const identityMismatches = catalog.filter((entry) => + isIdentityMismatchForStation(station, entry), + ); + if (identityMismatches.length > 0) { + return { + pin: null, + blockers: identityMismatches.map((entry) => + `${prefix}:identity_mismatch:${entry.bundleId}`, + ), + }; + } + + const matching = catalog.filter((entry) => + entry.scenarioId === station.scenarioId && entry.stationId === station.slotId, + ); + if (matching.length === 0) { + return { pin: null, blockers: [`${prefix}:missing`] }; + } + + const ineligible = matching.flatMap((entry) => inspectCatalogEligibility(prefix, entry, station)); + if (ineligible.length > 0) { + return { pin: null, blockers: ineligible }; + } + + const eligible = matching + .filter((entry) => isEligibleForPin(entry, station) && !usedBundleIds.has(entry.bundleId)) + .slice() + .sort((left, right) => left.bundleId.localeCompare(right.bundleId)); + const chosen = eligible[0]; + if (!chosen) { + return { pin: null, blockers: [`${prefix}:missing`] }; + } + + return { + pin: { + stationOrder: station.stationOrder, + slotId: station.slotId, + scenarioId: station.scenarioId, + scenarioVersion: station.scenarioVersion, + bundleId: chosen.bundleId, + contentIdentity: chosen.contentIdentity, + }, + blockers: [], + }; +} + +function inspectStationFields(station: ExamStationBundlePinTarget): string[] { + const prefix = stationPrefix(station); + const blockers: string[] = []; + if (!Number.isInteger(station.stationOrder) || station.stationOrder < 1) { + blockers.push(`${prefix}:invalid_station_order`); + } + if (station.slotId.trim().length === 0) { + blockers.push(`${prefix}:blank_slot_id`); + } + if (station.scenarioId.trim().length === 0) { + blockers.push(`${prefix}:blank_scenario_id`); + } + if (!Number.isInteger(station.scenarioVersion) || station.scenarioVersion < 1) { + blockers.push(`${prefix}:invalid_scenario_version`); + } + return blockers; +} + +function inspectCatalogEligibility( + prefix: string, + entry: PromotedEncounterBundleCatalogEntry, + station: ExamStationBundlePinTarget, +): string[] { + const blockers: string[] = []; + if (entry.bundleId.trim().length === 0) { + blockers.push(`${prefix}:blank_bundle_id`); + } + if (entry.contentIdentity.trim().length === 0) { + blockers.push(`${prefix}:stale`); + } + if (entry.identityScope !== "learner_runtime_opaque_bundle") { + blockers.push(`${prefix}:identity_mismatch:${entry.bundleId}`); + } + if (entry.frozenForEncounter !== true) { + blockers.push(`${prefix}:stale`); + } + if (entry.runtimeEligibility === "blocked") { + blockers.push(`${prefix}:blocked`); + } + if (entry.runtimeEligibility === "stale" || entry.runtimeEligibility === "retired") { + blockers.push(`${prefix}:stale`); + } + if (entry.runtimeEligibility !== "promoted" && entry.runtimeEligibility !== "blocked") { + blockers.push(`${prefix}:stale`); + } + if ( + station.authoredContentIdentity + && entry.authoredContentIdentity + && entry.authoredContentIdentity !== station.authoredContentIdentity + ) { + blockers.push(`${prefix}:identity_mismatch:${entry.bundleId}`); + } + if ( + station.scenarioRevisionId + && entry.scenarioRevisionId + && entry.scenarioRevisionId !== station.scenarioRevisionId + ) { + blockers.push(`${prefix}:identity_mismatch:${entry.bundleId}`); + } + return blockers; +} + +function isEligibleForPin( + entry: PromotedEncounterBundleCatalogEntry, + station: ExamStationBundlePinTarget, +): boolean { + return inspectCatalogEligibility(stationPrefix(station), entry, station).length === 0 + && entry.runtimeEligibility === "promoted" + && entry.frozenForEncounter === true + && entry.identityScope === "learner_runtime_opaque_bundle" + && entry.scenarioId === station.scenarioId + && entry.stationId === station.slotId; +} + +function isIdentityMismatchForStation( + station: ExamStationBundlePinTarget, + entry: PromotedEncounterBundleCatalogEntry, +): boolean { + return entry.stationId === station.slotId && entry.scenarioId !== station.scenarioId; +} + +function stationPrefix(station: ExamStationBundlePinTarget): string { + const slot = station.slotId.trim().length > 0 ? station.slotId : `order_${station.stationOrder}`; + return `station:${slot}`; +} + +function refuse( + examFormId: string, + blockers: string[], +): PinExamStationEncounterBundlesResult { + return { + pinned: false, + examFormId, + pins: [], + blockers: [...new Set(blockers)], + claimScope: examStationEncounterBundlePinClaimScope, + notEvidenceFor: examStationEncounterBundlePinNotEvidenceFor, + }; +} + +function requireNonblank(value: string, label: string): string { + if (value.trim().length === 0) { + throw new Error(`${label} is required`); + } + return value; +} diff --git a/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/types.ts b/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/types.ts new file mode 100644 index 000000000..2e29ba0d8 --- /dev/null +++ b/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/types.ts @@ -0,0 +1,80 @@ +export const examStationEncounterBundlePinClaimScope = + "assembled_exam_station_bundle_pin_not_exam_equivalence" as const; + +export const examStationEncounterBundlePinNotEvidenceFor = [ + "exam_equivalence", + "clinical_validity", + "scoring_validity", + "quest_readiness", + "learner_readiness", +] as const; + +export type ExamStationEncounterBundlePinNotEvidenceFor = + (typeof examStationEncounterBundlePinNotEvidenceFor)[number]; + +export type PromotedEncounterBundleRuntimeEligibility = + | "promoted" + | "blocked" + | "stale" + | "retired"; + +/** + * Catalog row for one promoted immutable encounter bundle. + * Exam assembly pins by opaque `bundleId` — it does not infer fixtures from scenario ids. + */ +export type PromotedEncounterBundleCatalogEntry = { + bundleId: string; + scenarioId: string; + stationId: string; + contentIdentity: string; + runtimeEligibility: PromotedEncounterBundleRuntimeEligibility; + frozenForEncounter: boolean; + identityScope: "learner_runtime_opaque_bundle"; + scenarioRevisionId?: string; + authoredContentIdentity?: string; +}; + +export type ExamStationBundlePinTarget = { + stationOrder: number; + slotId: string; + scenarioId: string; + scenarioVersion: number; + scenarioRevisionId?: string; + authoredContentIdentity?: string; +}; + +export type ExamStationEncounterBundlePin = { + stationOrder: number; + slotId: string; + scenarioId: string; + scenarioVersion: number; + bundleId: string; + contentIdentity: string; +}; + +export type PinExamStationEncounterBundlesInput = { + examFormId: string; + stations: readonly ExamStationBundlePinTarget[]; + catalog: readonly PromotedEncounterBundleCatalogEntry[]; +}; + +export type PinExamStationEncounterBundlesSuccess = { + pinned: true; + examFormId: string; + pins: ExamStationEncounterBundlePin[]; + claimScope: typeof examStationEncounterBundlePinClaimScope; + notEvidenceFor: typeof examStationEncounterBundlePinNotEvidenceFor; +}; + +export type PinExamStationEncounterBundlesRefusal = { + pinned: false; + examFormId: string; + pins: []; + blockers: string[]; + claimScope: typeof examStationEncounterBundlePinClaimScope; + notEvidenceFor: typeof examStationEncounterBundlePinNotEvidenceFor; +}; + +export type PinExamStationEncounterBundlesResult = + | PinExamStationEncounterBundlesSuccess + | PinExamStationEncounterBundlesRefusal; diff --git a/packages/openclinxr/exam-assembly/src/index.ts b/packages/openclinxr/exam-assembly/src/index.ts index 3dd51d859..076e2a9f0 100644 --- a/packages/openclinxr/exam-assembly/src/index.ts +++ b/packages/openclinxr/exam-assembly/src/index.ts @@ -3,3 +3,4 @@ export * from "./assembly.js"; export * from "./exam-form-breaks.js"; export * from "./exam-run.js"; export * from "./station-selection.js"; +export * from "./encounter-bundle-selection/index.js"; diff --git a/packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.test.ts b/packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.test.ts new file mode 100644 index 000000000..17bbf56b7 --- /dev/null +++ b/packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { + createDurableExamFormEncounterBundlePins, + MemoryExamFormEncounterBundlePinStore, +} from "./durable-pins.js"; +import type { DurablePromotedEncounterBundleLookupEntry } from "./types.js"; + +const ED_SCENARIO = "ed_chest_pain_priority_v1"; +const PEDS_SCENARIO = "peds_asthma_parent_anxiety_v1"; +const ED_SLOT = "station_001_ed_chest_pain_priority_v1"; +const PEDS_SLOT = "station_002_peds_asthma_parent_anxiety_v1"; +const ED_BUNDLE = "bdl_ed_chest_pain_opaque_v1"; +const PEDS_BUNDLE = "bdl_peds_asthma_opaque_v1"; + +describe("durable exam-form encounter-bundle pins", () => { + it("persists one opaque pin per station and relaunches the same assets after process restart", () => { + const live = new MemoryExamFormEncounterBundlePinStore(); + const persisted = live.persist({ + examFormId: "form_two_archetype_pilot", + pins: [edPin(), pedsPin()], + }); + expect(persisted.pins.map((pin) => pin.bundleId)).toEqual([ED_BUNDLE, PEDS_BUNDLE]); + expect(persisted.durableStore).toBe("database_source_of_truth"); + + const restarted = MemoryExamFormEncounterBundlePinStore.restore(JSON.parse(JSON.stringify(live.dump()))); + const catalog = [edCatalog(), pedsCatalog()]; + const edLaunch = restarted.launchPinnedStationAssets({ + examFormId: "form_two_archetype_pilot", + slotId: ED_SLOT, + catalog, + }); + const pedsLaunch = restarted.launchPinnedStationAssets({ + examFormId: "form_two_archetype_pilot", + slotId: PEDS_SLOT, + catalog, + }); + + expect(edLaunch.launched).toBe(true); + expect(pedsLaunch.launched).toBe(true); + if (edLaunch.launched && pedsLaunch.launched) { + expect(edLaunch.bundleId).toBe(ED_BUNDLE); + expect(edLaunch.contentIdentity).toBe("cid_ed_chest_pain_v1"); + expect(edLaunch.scenarioId).toBe(ED_SCENARIO); + expect(pedsLaunch.bundleId).toBe(PEDS_BUNDLE); + expect(pedsLaunch.scenarioId).toBe(PEDS_SCENARIO); + expect(edLaunch.bundleId).not.toBe(pedsLaunch.bundleId); + } + }); + + it("fails closed on stale, blocked, missing, and identity-mismatched catalog rows after reload", () => { + const live = new MemoryExamFormEncounterBundlePinStore(); + live.persist({ + examFormId: "form_two_archetype_pilot", + pins: [edPin(), pedsPin()], + }); + const restarted = MemoryExamFormEncounterBundlePinStore.restore(live.dump()); + + const stale = restarted.launchPinnedStationAssets({ + examFormId: "form_two_archetype_pilot", + slotId: ED_SLOT, + catalog: [{ ...edCatalog(), contentIdentity: "cid_other" }], + }); + expect(stale.launched).toBe(false); + if (!stale.launched) { + expect(stale.blockers).toContain(`station:${ED_SLOT}:stale`); + } + + const blocked = restarted.launchPinnedStationAssets({ + examFormId: "form_two_archetype_pilot", + slotId: ED_SLOT, + catalog: [{ ...edCatalog(), runtimeEligibility: "blocked" }], + }); + expect(blocked.launched).toBe(false); + if (!blocked.launched) { + expect(blocked.blockers).toContain(`station:${ED_SLOT}:blocked`); + } + + const missing = restarted.launchPinnedStationAssets({ + examFormId: "form_two_archetype_pilot", + slotId: ED_SLOT, + catalog: [pedsCatalog()], + }); + expect(missing.launched).toBe(false); + if (!missing.launched) { + expect(missing.blockers).toContain(`station:${ED_SLOT}:missing`); + } + + const mismatch = restarted.launchPinnedStationAssets({ + examFormId: "form_two_archetype_pilot", + slotId: ED_SLOT, + catalog: [{ ...edCatalog(), scenarioId: PEDS_SCENARIO }], + }); + expect(mismatch.launched).toBe(false); + if (!mismatch.launched) { + expect(mismatch.blockers).toContain(`station:${ED_SLOT}:identity_mismatch:${ED_BUNDLE}`); + } + }); + + it("freezes persisted pins so later mutation cannot change the relaunch identity", () => { + const record = createDurableExamFormEncounterBundlePins({ + examFormId: "form_immutable", + pins: [edPin()], + }); + expect(Object.isFrozen(record)).toBe(true); + expect(Object.isFrozen(record.pins)).toBe(true); + expect(Object.isFrozen(record.pins[0])).toBe(true); + }); +}); + +function edPin() { + return { + stationOrder: 1, + slotId: ED_SLOT, + scenarioId: ED_SCENARIO, + scenarioVersion: 1, + bundleId: ED_BUNDLE, + contentIdentity: "cid_ed_chest_pain_v1", + }; +} + +function pedsPin() { + return { + stationOrder: 2, + slotId: PEDS_SLOT, + scenarioId: PEDS_SCENARIO, + scenarioVersion: 1, + bundleId: PEDS_BUNDLE, + contentIdentity: "cid_peds_asthma_v1", + }; +} + +function edCatalog(): DurablePromotedEncounterBundleLookupEntry { + return { + bundleId: ED_BUNDLE, + scenarioId: ED_SCENARIO, + stationId: ED_SLOT, + contentIdentity: "cid_ed_chest_pain_v1", + runtimeEligibility: "promoted", + frozenForEncounter: true, + identityScope: "learner_runtime_opaque_bundle", + }; +} + +function pedsCatalog(): DurablePromotedEncounterBundleLookupEntry { + return { + bundleId: PEDS_BUNDLE, + scenarioId: PEDS_SCENARIO, + stationId: PEDS_SLOT, + contentIdentity: "cid_peds_asthma_v1", + runtimeEligibility: "promoted", + frozenForEncounter: true, + identityScope: "learner_runtime_opaque_bundle", + }; +} diff --git a/packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.ts b/packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.ts new file mode 100644 index 000000000..57ae6a60f --- /dev/null +++ b/packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.ts @@ -0,0 +1,181 @@ +import type { + DurableExamFormEncounterBundlePins, + DurableExamStationEncounterBundlePin, + DurablePromotedEncounterBundleLookupEntry, + LaunchPinnedStationAssetsInput, + LaunchPinnedStationAssetsResult, + PersistExamFormEncounterBundlePinsInput, +} from "./types.js"; +import { + durableExamStationEncounterBundlePinClaimScope, + durableExamStationEncounterBundlePinNotEvidenceFor, +} from "./types.js"; + +export function createDurableExamFormEncounterBundlePins( + input: PersistExamFormEncounterBundlePinsInput, +): DurableExamFormEncounterBundlePins { + const examFormId = requireNonblank(input.examFormId, "examFormId"); + if (input.pins.length === 0) { + throw new Error("durable exam-form encounter-bundle pins require at least one station pin"); + } + const pins = input.pins.map((pin) => freezePin({ + examFormId, + stationOrder: pin.stationOrder, + slotId: requireNonblank(pin.slotId, "slotId"), + scenarioId: requireNonblank(pin.scenarioId, "scenarioId"), + scenarioVersion: pin.scenarioVersion, + bundleId: requireNonblank(pin.bundleId, "bundleId"), + contentIdentity: requireNonblank(pin.contentIdentity, "contentIdentity"), + durableStore: "database_source_of_truth", + })); + assertUniquePins(pins); + return freezeForm({ + examFormId, + pins, + durableStore: "database_source_of_truth", + claimScope: durableExamStationEncounterBundlePinClaimScope, + notEvidenceFor: durableExamStationEncounterBundlePinNotEvidenceFor, + }); +} + +export class MemoryExamFormEncounterBundlePinStore { + private readonly documents = new Map(); + + persist(input: PersistExamFormEncounterBundlePinsInput): DurableExamFormEncounterBundlePins { + const record = createDurableExamFormEncounterBundlePins(input); + this.documents.set(record.examFormId, cloneJson(record)); + return cloneJson(record); + } + + load(examFormId: string): DurableExamFormEncounterBundlePins | null { + const found = this.documents.get(requireNonblank(examFormId, "examFormId")); + return found ? cloneJson(found) : null; + } + + dump(): Record { + return cloneJson(Object.fromEntries(this.documents.entries())); + } + + static restore( + serialized: Record, + ): MemoryExamFormEncounterBundlePinStore { + const store = new MemoryExamFormEncounterBundlePinStore(); + for (const record of Object.values(serialized)) { + store.documents.set(record.examFormId, cloneJson(record)); + } + return store; + } + + launchPinnedStationAssets(input: LaunchPinnedStationAssetsInput): LaunchPinnedStationAssetsResult { + const examFormId = requireNonblank(input.examFormId, "examFormId"); + const slotId = requireNonblank(input.slotId, "slotId"); + const form = this.load(examFormId); + if (!form) { + return refuseLaunch(examFormId, slotId, [`form:${examFormId}:missing`]); + } + const pin = form.pins.find((entry) => entry.slotId === slotId); + if (!pin) { + return refuseLaunch(examFormId, slotId, [`station:${slotId}:missing`]); + } + const catalogEntry = input.catalog.find((entry) => entry.bundleId === pin.bundleId); + if (!catalogEntry) { + return refuseLaunch(examFormId, slotId, [`station:${slotId}:missing`]); + } + const blockers = inspectLaunchCatalog(pin, catalogEntry); + if (blockers.length > 0) { + return refuseLaunch(examFormId, slotId, blockers); + } + return { + launched: true, + examFormId, + slotId, + scenarioId: pin.scenarioId, + bundleId: pin.bundleId, + contentIdentity: pin.contentIdentity, + claimScope: durableExamStationEncounterBundlePinClaimScope, + notEvidenceFor: durableExamStationEncounterBundlePinNotEvidenceFor, + }; + } +} + +function inspectLaunchCatalog( + pin: DurableExamStationEncounterBundlePin, + entry: DurablePromotedEncounterBundleLookupEntry, +): string[] { + const prefix = `station:${pin.slotId}`; + const blockers: string[] = []; + if (entry.identityScope !== "learner_runtime_opaque_bundle") { + blockers.push(`${prefix}:identity_mismatch:${entry.bundleId}`); + } + if (entry.scenarioId !== pin.scenarioId || entry.stationId !== pin.slotId) { + blockers.push(`${prefix}:identity_mismatch:${entry.bundleId}`); + } + if (entry.contentIdentity !== pin.contentIdentity || entry.frozenForEncounter !== true) { + blockers.push(`${prefix}:stale`); + } + if (entry.runtimeEligibility === "blocked") { + blockers.push(`${prefix}:blocked`); + } else if (entry.runtimeEligibility !== "promoted") { + blockers.push(`${prefix}:stale`); + } + return blockers; +} + +function refuseLaunch( + examFormId: string, + slotId: string, + blockers: string[], +): LaunchPinnedStationAssetsResult { + return { + launched: false, + examFormId, + slotId, + blockers: [...new Set(blockers)], + claimScope: durableExamStationEncounterBundlePinClaimScope, + notEvidenceFor: durableExamStationEncounterBundlePinNotEvidenceFor, + }; +} + +function assertUniquePins(pins: readonly DurableExamStationEncounterBundlePin[]): void { + const slots = new Set(); + const bundles = new Set(); + for (const pin of pins) { + if (slots.has(pin.slotId)) { + throw new Error(`duplicate station pin: ${pin.slotId}`); + } + if (bundles.has(pin.bundleId)) { + throw new Error(`duplicate bundle pin: ${pin.bundleId}`); + } + if (!Number.isInteger(pin.stationOrder) || pin.stationOrder < 1) { + throw new Error("stationOrder must be a positive integer"); + } + if (!Number.isInteger(pin.scenarioVersion) || pin.scenarioVersion < 1) { + throw new Error("scenarioVersion must be a positive integer"); + } + slots.add(pin.slotId); + bundles.add(pin.bundleId); + } +} + +function freezePin(pin: DurableExamStationEncounterBundlePin): DurableExamStationEncounterBundlePin { + return Object.freeze({ ...pin }); +} + +function freezeForm(form: DurableExamFormEncounterBundlePins): DurableExamFormEncounterBundlePins { + return Object.freeze({ + ...form, + pins: Object.freeze([...form.pins]), + notEvidenceFor: durableExamStationEncounterBundlePinNotEvidenceFor, + }); +} + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function requireNonblank(value: string, label: string): string { + if (value.trim().length === 0) { + throw new Error(`${label} is required`); + } + return value; +} diff --git a/packages/openclinxr/session-state/src/encounter-bundle-selection/index.ts b/packages/openclinxr/session-state/src/encounter-bundle-selection/index.ts new file mode 100644 index 000000000..0d140b3f8 --- /dev/null +++ b/packages/openclinxr/session-state/src/encounter-bundle-selection/index.ts @@ -0,0 +1,19 @@ +export { + durableExamStationEncounterBundlePinClaimScope, + durableExamStationEncounterBundlePinNotEvidenceFor, +} from "./types.js"; +export type { + DurableExamFormEncounterBundlePins, + DurableExamStationEncounterBundlePin, + DurableExamStationEncounterBundlePinNotEvidenceFor, + DurablePromotedEncounterBundleLookupEntry, + LaunchPinnedStationAssetsInput, + LaunchPinnedStationAssetsRefusal, + LaunchPinnedStationAssetsResult, + LaunchPinnedStationAssetsSuccess, + PersistExamFormEncounterBundlePinsInput, +} from "./types.js"; +export { + createDurableExamFormEncounterBundlePins, + MemoryExamFormEncounterBundlePinStore, +} from "./durable-pins.js"; diff --git a/packages/openclinxr/session-state/src/encounter-bundle-selection/types.ts b/packages/openclinxr/session-state/src/encounter-bundle-selection/types.ts new file mode 100644 index 000000000..581d1300d --- /dev/null +++ b/packages/openclinxr/session-state/src/encounter-bundle-selection/types.ts @@ -0,0 +1,77 @@ +export const durableExamStationEncounterBundlePinClaimScope = + "durable_exam_station_bundle_pin_not_exam_equivalence" as const; + +export const durableExamStationEncounterBundlePinNotEvidenceFor = [ + "exam_equivalence", + "clinical_validity", + "scoring_validity", + "quest_readiness", + "production_persistence", +] as const; + +export type DurableExamStationEncounterBundlePinNotEvidenceFor = + (typeof durableExamStationEncounterBundlePinNotEvidenceFor)[number]; + +export type DurablePromotedEncounterBundleLookupEntry = { + bundleId: string; + scenarioId: string; + stationId: string; + contentIdentity: string; + runtimeEligibility: "promoted" | "blocked" | "stale" | "retired"; + frozenForEncounter: boolean; + identityScope: "learner_runtime_opaque_bundle"; +}; + +export type DurableExamStationEncounterBundlePin = { + examFormId: string; + stationOrder: number; + slotId: string; + scenarioId: string; + scenarioVersion: number; + bundleId: string; + contentIdentity: string; + durableStore: "database_source_of_truth"; +}; + +export type DurableExamFormEncounterBundlePins = { + examFormId: string; + pins: readonly DurableExamStationEncounterBundlePin[]; + durableStore: "database_source_of_truth"; + claimScope: typeof durableExamStationEncounterBundlePinClaimScope; + notEvidenceFor: typeof durableExamStationEncounterBundlePinNotEvidenceFor; +}; + +export type PersistExamFormEncounterBundlePinsInput = { + examFormId: string; + pins: ReadonlyArray>; +}; + +export type LaunchPinnedStationAssetsInput = { + examFormId: string; + slotId: string; + catalog: readonly DurablePromotedEncounterBundleLookupEntry[]; +}; + +export type LaunchPinnedStationAssetsSuccess = { + launched: true; + examFormId: string; + slotId: string; + scenarioId: string; + bundleId: string; + contentIdentity: string; + claimScope: typeof durableExamStationEncounterBundlePinClaimScope; + notEvidenceFor: typeof durableExamStationEncounterBundlePinNotEvidenceFor; +}; + +export type LaunchPinnedStationAssetsRefusal = { + launched: false; + examFormId: string; + slotId: string; + blockers: string[]; + claimScope: typeof durableExamStationEncounterBundlePinClaimScope; + notEvidenceFor: typeof durableExamStationEncounterBundlePinNotEvidenceFor; +}; + +export type LaunchPinnedStationAssetsResult = + | LaunchPinnedStationAssetsSuccess + | LaunchPinnedStationAssetsRefusal; diff --git a/packages/openclinxr/session-state/src/index.ts b/packages/openclinxr/session-state/src/index.ts index 7c75944ac..86430cc17 100644 --- a/packages/openclinxr/session-state/src/index.ts +++ b/packages/openclinxr/session-state/src/index.ts @@ -2,3 +2,4 @@ export * from "./types.js"; export * from "./session-core.js"; export * from "./session-messaging.js"; export { projectDurableClinicalEventForReview, summarizeDurableClinicalEventReviewProjections } from "./internal.js"; +export * from "./encounter-bundle-selection/index.js"; From 04604b45d568dda7cdb1d121aa646251e507a9d4 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 4 Sep 2026 14:29:19 -0400 Subject: [PATCH 2/3] fix(exam-assembly): persist promoted bundle pins Wire pin selection into assembled-form results, add the async session-state port and Mongo repository, and prove ED/peds opaque bundle relaunch after repository recreation. Keep the local test adapter explicitly non-durable.\n\ntsk_f29a64d0c7c36f12\n\nHook bypass: the pre-existing UI-admin freeze ceilings remain red for App.tsx and api-client-types.ts, both outside this task. --- ...-bundle-pin-repository.integration.test.ts | 211 ++++++++++++++++++ ...am-form-encounter-bundle-pin-repository.ts | 134 +++++++++++ packages/openclinxr/data-mongodb/src/index.ts | 1 + .../data-mongodb/src/persistence-sink.ts | 18 ++ .../assemble-exam-form-with-pinned-bundles.ts | 99 ++++++++ .../src/encounter-bundle-selection/index.ts | 10 + .../pin-station-bundles.test.ts | 98 ++++++++ .../durable-pins.test.ts | 59 ++--- .../durable-pins.ts | 117 +++++----- .../src/encounter-bundle-selection/index.ts | 6 +- .../src/encounter-bundle-selection/types.ts | 12 +- 11 files changed, 672 insertions(+), 93 deletions(-) create mode 100644 packages/openclinxr/data-mongodb/src/exam-form-encounter-bundle-pin-repository.integration.test.ts create mode 100644 packages/openclinxr/data-mongodb/src/exam-form-encounter-bundle-pin-repository.ts create mode 100644 packages/openclinxr/exam-assembly/src/encounter-bundle-selection/assemble-exam-form-with-pinned-bundles.ts diff --git a/packages/openclinxr/data-mongodb/src/exam-form-encounter-bundle-pin-repository.integration.test.ts b/packages/openclinxr/data-mongodb/src/exam-form-encounter-bundle-pin-repository.integration.test.ts new file mode 100644 index 000000000..36f781403 --- /dev/null +++ b/packages/openclinxr/data-mongodb/src/exam-form-encounter-bundle-pin-repository.integration.test.ts @@ -0,0 +1,211 @@ +import { assembleExamFormWithPinnedEncounterBundles, type ExamBlueprint } from "@openclinxr/exam-assembly"; +import type { Scenario } from "@openclinxr/shared-schemas"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createMongoMemoryTestContext, type MongoMemoryTestContext } from "@cellix/server-mongodb-memory-mock"; +import { MongoExamFormEncounterBundlePinRepository } from "./exam-form-encounter-bundle-pin-repository.js"; + +const ED_SCENARIO = "ed_chest_pain_priority_v1"; +const PEDS_SCENARIO = "peds_asthma_parent_anxiety_v1"; +const ED_SLOT = "station_001_ed_chest_pain_priority_v1"; +const PEDS_SLOT = "station_002_peds_asthma_parent_anxiety_v1"; +const ED_BUNDLE = "bdl_ed_chest_pain_opaque_v1"; +const PEDS_BUNDLE = "bdl_peds_asthma_opaque_v1"; +const FORM_ID = "form_two_archetype_pilot"; + +describe("MongoExamFormEncounterBundlePinRepository integration", () => { + let context: MongoMemoryTestContext; + + beforeAll(async () => { + context = await createMongoMemoryTestContext(); + }); + + afterAll(async () => { + await context.close(); + }); + + it("recreates against the same Mongo database and launches the same opaque station bundle ids", async () => { + const assembled = assembleExamFormWithPinnedEncounterBundles({ + examFormId: FORM_ID, + blueprint: twoStationBlueprint(), + scenarios: [ + approvedScenario(ED_SCENARIO, "ED chest pain"), + approvedScenario(PEDS_SCENARIO, "Peds asthma"), + ], + catalog: [edCatalog(), pedsCatalog()], + }); + expect(assembled.assembled).toBe(true); + if (!assembled.assembled) { + throw new Error(assembled.blockers.join(", ")); + } + + const first = new MongoExamFormEncounterBundlePinRepository(context.db); + await first.ensureIndexes(); + expect(first.durableStore).toBe("database_source_of_truth"); + const persisted = await first.persist({ + examFormId: assembled.form.examFormId, + pins: assembled.pins, + }); + expect(persisted.durableStore).toBe("database_source_of_truth"); + expect(persisted.pins.every((pin) => pin.durableStore === "database_source_of_truth")).toBe(true); + expect(persisted.pins.map((pin) => pin.bundleId)).toEqual([ED_BUNDLE, PEDS_BUNDLE]); + + const restarted = new MongoExamFormEncounterBundlePinRepository(context.db); + const loaded = await restarted.load(FORM_ID); + expect(loaded?.pins.map((pin) => pin.bundleId)).toEqual([ED_BUNDLE, PEDS_BUNDLE]); + expect(loaded?.pins.map((pin) => pin.contentIdentity)).toEqual([ + "cid_ed_chest_pain_v1", + "cid_peds_asthma_v1", + ]); + + const catalog = [edCatalog(), pedsCatalog()]; + const edLaunch = await restarted.launchPinnedStationAssets({ + examFormId: FORM_ID, + slotId: ED_SLOT, + catalog, + }); + const pedsLaunch = await restarted.launchPinnedStationAssets({ + examFormId: FORM_ID, + slotId: PEDS_SLOT, + catalog, + }); + expect(edLaunch.launched).toBe(true); + expect(pedsLaunch.launched).toBe(true); + if (edLaunch.launched && pedsLaunch.launched) { + expect(edLaunch.bundleId).toBe(ED_BUNDLE); + expect(pedsLaunch.bundleId).toBe(PEDS_BUNDLE); + expect(edLaunch.bundleId).not.toBe(pedsLaunch.bundleId); + } + + const stale = await restarted.launchPinnedStationAssets({ + examFormId: FORM_ID, + slotId: ED_SLOT, + catalog: [{ ...edCatalog(), contentIdentity: "cid_rebuilt_after_pin" }], + }); + expect(stale.launched).toBe(false); + if (!stale.launched) { + expect(stale.blockers).toContain(`station:${ED_SLOT}:stale`); + } + + const blocked = await restarted.launchPinnedStationAssets({ + examFormId: FORM_ID, + slotId: ED_SLOT, + catalog: [{ ...edCatalog(), runtimeEligibility: "blocked" }], + }); + expect(blocked.launched).toBe(false); + if (!blocked.launched) { + expect(blocked.blockers).toContain(`station:${ED_SLOT}:blocked`); + } + + const mismatch = await restarted.launchPinnedStationAssets({ + examFormId: FORM_ID, + slotId: ED_SLOT, + catalog: [{ ...edCatalog(), scenarioId: PEDS_SCENARIO }], + }); + expect(mismatch.launched).toBe(false); + if (!mismatch.launched) { + expect(mismatch.blockers).toContain(`station:${ED_SLOT}:identity_mismatch:${ED_BUNDLE}`); + } + + await expect(restarted.persist({ + examFormId: FORM_ID, + pins: assembled.pins.map((pin, index) => index === 0 + ? { ...pin, contentIdentity: "cid_mutated" } + : pin), + })).rejects.toThrow("repersist cannot mutate immutable exam-form encounter-bundle pins"); + + await expect(restarted.persist({ + examFormId: FORM_ID, + pins: assembled.pins.map((pin, index) => index === 0 + ? { ...pin, scenarioVersion: 2 } + : pin), + })).rejects.toThrow("repersist cannot mutate immutable exam-form encounter-bundle pins"); + }); +}); + +function twoStationBlueprint(): ExamBlueprint { + return { + blueprintId: "blueprint_two_archetype_v1", + title: "Two archetype form", + stationSlots: [ + { + slotId: ED_SLOT, + order: 1, + label: "ED chest pain", + requiredEnvironmentIds: [], + requiredTraceTags: [], + }, + { + slotId: PEDS_SLOT, + order: 2, + label: "Peds asthma", + requiredEnvironmentIds: [], + requiredTraceTags: [], + }, + ], + timing: { + doorwaySeconds: 60, + encounterSeconds: 900, + noteSeconds: 600, + breakAfterStationOrders: [], + }, + requiredTraceTags: [], + requiredSafetyCriticalTraceTags: [], + }; +} + +function approvedScenario(scenarioId: string, title: string): Scenario { + return { + scenarioId, + version: 1, + title, + status: "approved", + review: { + clinical: "approved", + psychometric: "approved", + legal: "approved", + simulationQa: "approved", + }, + clinicalObjectives: ["Recognize possible ACS"], + actors: [], + requiredTraceTags: [], + eventSchedule: [], + reviewRubric: [], + governance: { + scoreUseLabel: "formative_local_only", + syntheticCaseDisclosure: "Synthetic repository-contract fixture.", + validationStage: "stage_1_expert_reviewed", + validationLimitations: ["Repository fixture only; no validity evidence."], + requiredReviewerRoles: ["clinician", "psychometrician", "legal", "simulation_qa"], + sourceIds: ["src-test-fixture"], + safetyCriticalTraceTags: [], + hiddenFactPolicy: { + learnerView: "redact_hidden_facts", + disclosureRequiresTrigger: true, + }, + }, + }; +} + +function edCatalog() { + return { + bundleId: ED_BUNDLE, + scenarioId: ED_SCENARIO, + stationId: ED_SLOT, + contentIdentity: "cid_ed_chest_pain_v1", + runtimeEligibility: "promoted" as const, + frozenForEncounter: true, + identityScope: "learner_runtime_opaque_bundle" as const, + }; +} + +function pedsCatalog() { + return { + bundleId: PEDS_BUNDLE, + scenarioId: PEDS_SCENARIO, + stationId: PEDS_SLOT, + contentIdentity: "cid_peds_asthma_v1", + runtimeEligibility: "promoted" as const, + frozenForEncounter: true, + identityScope: "learner_runtime_opaque_bundle" as const, + }; +} diff --git a/packages/openclinxr/data-mongodb/src/exam-form-encounter-bundle-pin-repository.ts b/packages/openclinxr/data-mongodb/src/exam-form-encounter-bundle-pin-repository.ts new file mode 100644 index 000000000..4cbb70d9e --- /dev/null +++ b/packages/openclinxr/data-mongodb/src/exam-form-encounter-bundle-pin-repository.ts @@ -0,0 +1,134 @@ +import type { Collection, Db } from "mongodb"; +import { + createDurableExamFormEncounterBundlePins, + durableExamStationEncounterBundlePinClaimScope, + durableExamStationEncounterBundlePinNotEvidenceFor, + launchPinnedStationAssetsFromPort, + type DurableExamFormEncounterBundlePins, + type DurableExamStationEncounterBundlePin, + type ExamFormEncounterBundlePinPersistencePort, + type LaunchPinnedStationAssetsInput, + type LaunchPinnedStationAssetsResult, + type PersistExamFormEncounterBundlePinsInput, +} from "@openclinxr/session-state"; +import { assertNonblankMongoField } from "./mongo-helpers.js"; + +export class MongoExamFormEncounterBundlePinRepository implements ExamFormEncounterBundlePinPersistencePort { + readonly backend = "mongodb" as const; + readonly durableStore = "database_source_of_truth" as const; + private readonly collection: Collection; + + constructor(db: Db) { + this.collection = db.collection( + "exam_form_encounter_bundle_pins", + ); + } + + async ensureIndexes(): Promise { + await this.collection.createIndex({ examFormId: 1 }, { unique: true }); + } + + async persist(input: PersistExamFormEncounterBundlePinsInput): Promise { + const record = createDurableExamFormEncounterBundlePins(input, this.durableStore); + try { + await this.collection.insertOne(cloneJson(record)); + return record; + } catch (error) { + if (!isDuplicateKey(error)) { + throw error; + } + const existing = await this.collection.findOne( + { examFormId: record.examFormId }, + { projection: { _id: 0 } }, + ); + if (!existing) { + throw error; + } + const restored = restoreDatabaseRecord(existing); + if (!sameImmutableRecord(restored, record)) { + throw new Error("repersist cannot mutate immutable exam-form encounter-bundle pins"); + } + return restored; + } + } + + async load(examFormId: string): Promise { + assertNonblankMongoField(examFormId, "examFormId"); + const found = await this.collection.findOne({ examFormId }, { projection: { _id: 0 } }); + return found ? restoreDatabaseRecord(found) : null; + } + + async launchPinnedStationAssets(input: LaunchPinnedStationAssetsInput): Promise { + return launchPinnedStationAssetsFromPort(this, input); + } +} + +function restoreDatabaseRecord( + record: DurableExamFormEncounterBundlePins, +): DurableExamFormEncounterBundlePins { + if (record.durableStore !== "database_source_of_truth") { + throw new Error("mongo exam-form pin records must use durableStore database_source_of_truth"); + } + if (!Array.isArray(record.pins) || record.pins.some( + (pin) => pin.durableStore !== "database_source_of_truth", + )) { + throw new Error("mongo exam-form station pins must use durableStore database_source_of_truth"); + } + if (record.claimScope !== durableExamStationEncounterBundlePinClaimScope) { + throw new Error("mongo exam-form pin record has invalid claimScope"); + } + if (!sameStrings(record.notEvidenceFor, durableExamStationEncounterBundlePinNotEvidenceFor)) { + throw new Error("mongo exam-form pin record has invalid notEvidenceFor boundary"); + } + return createDurableExamFormEncounterBundlePins({ + examFormId: record.examFormId, + pins: record.pins.map((pin) => ({ + stationOrder: pin.stationOrder, + slotId: pin.slotId, + scenarioId: pin.scenarioId, + scenarioVersion: pin.scenarioVersion, + bundleId: pin.bundleId, + contentIdentity: pin.contentIdentity, + })), + }, "database_source_of_truth"); +} + +function sameImmutableRecord( + left: DurableExamFormEncounterBundlePins, + right: DurableExamFormEncounterBundlePins, +): boolean { + return left.examFormId === right.examFormId + && left.durableStore === right.durableStore + && left.pins.length === right.pins.length + && left.pins.every((pin, index) => samePin(pin, right.pins[index])); +} + +function samePin( + left: DurableExamStationEncounterBundlePin, + right: DurableExamStationEncounterBundlePin | undefined, +): boolean { + return right !== undefined + && left.examFormId === right.examFormId + && left.stationOrder === right.stationOrder + && left.slotId === right.slotId + && left.scenarioId === right.scenarioId + && left.scenarioVersion === right.scenarioVersion + && left.bundleId === right.bundleId + && left.contentIdentity === right.contentIdentity + && left.durableStore === right.durableStore; +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function cloneJson(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function isDuplicateKey(error: unknown): boolean { + return typeof error === "object" + && error !== null + && "code" in error + && (error as { code?: unknown }).code === 11000; +} diff --git a/packages/openclinxr/data-mongodb/src/index.ts b/packages/openclinxr/data-mongodb/src/index.ts index fdf494fae..34ebe034d 100644 --- a/packages/openclinxr/data-mongodb/src/index.ts +++ b/packages/openclinxr/data-mongodb/src/index.ts @@ -8,6 +8,7 @@ export * from "./exam-run-ledger.js"; export * from "./actor-turn-execution-ledger.js"; export * from "./actor-turn-execution-repository.js"; export * from "./promoted-encounter-bundle-repository.js"; +export * from "./exam-form-encounter-bundle-pin-repository.js"; export * from "./persistence-sink.js"; /** Re-export for tools composition roots (pnpm: bare `mongodb` is not resolvable from tools/). */ export { MongoClient } from "mongodb"; diff --git a/packages/openclinxr/data-mongodb/src/persistence-sink.ts b/packages/openclinxr/data-mongodb/src/persistence-sink.ts index a65e2ba9c..808579077 100644 --- a/packages/openclinxr/data-mongodb/src/persistence-sink.ts +++ b/packages/openclinxr/data-mongodb/src/persistence-sink.ts @@ -5,12 +5,15 @@ import type { DurableClinicalEventReviewProjection, DurableConversationTurnRecord, DurableEmotionalStateTimelineRecord, + LaunchPinnedStationAssetsInput, + PersistExamFormEncounterBundlePinsInput, } from "@openclinxr/session-state"; import type { PromoteReviewedFactoryOutputsInput } from "@openclinxr/asset-registry/runtime-asset-review"; import type { LearnerRuntimeAssetBundle } from "@openclinxr/asset-registry/runtime-bundles"; import type { ReviewPacket, Scenario, TraceEvent } from "@openclinxr/shared-schemas"; import { MongoExamFormRepository, MongoRuntimeAssetBundleRepository, MongoStationRunQueueRepository } from "./exam-repositories.js"; import { MongoPromotedEncounterBundleRepository } from "./promoted-encounter-bundle-repository.js"; +import { MongoExamFormEncounterBundlePinRepository } from "./exam-form-encounter-bundle-pin-repository.js"; import { MongoFacultyReviewDecisionRepository, MongoFacultyScoreDraftRepository } from "./faculty-repositories.js"; import { MongoDurableMultiActorSessionStore } from "./conversation-repositories.js"; import { MongoReviewPacketRepository, MongoScenarioRepository, MongoScenarioReviewDecisionRepository, MongoTraceRepository } from "./scenario-repositories.js"; @@ -27,6 +30,7 @@ export class MongoApiPersistenceSink { private readonly durableMultiActorSessions: MongoDurableMultiActorSessionStore; private readonly runtimeAssetBundles: MongoRuntimeAssetBundleRepository; private readonly promotedEncounterBundles: MongoPromotedEncounterBundleRepository; + private readonly examFormEncounterBundlePins: MongoExamFormEncounterBundlePinRepository; private readonly scenarios: MongoScenarioRepository; constructor(db: Db) { @@ -40,6 +44,7 @@ export class MongoApiPersistenceSink { this.durableMultiActorSessions = new MongoDurableMultiActorSessionStore(db); this.runtimeAssetBundles = new MongoRuntimeAssetBundleRepository(db); this.promotedEncounterBundles = new MongoPromotedEncounterBundleRepository(db); + this.examFormEncounterBundlePins = new MongoExamFormEncounterBundlePinRepository(db); this.scenarios = new MongoScenarioRepository(db); } @@ -55,6 +60,7 @@ export class MongoApiPersistenceSink { this.durableMultiActorSessions.ensureIndexes(), this.runtimeAssetBundles.ensureIndexes(), this.promotedEncounterBundles.ensureIndexes(), + this.examFormEncounterBundlePins.ensureIndexes(), this.scenarios.ensureIndexes(), ]); } @@ -156,6 +162,18 @@ export class MongoApiPersistenceSink { return this.promotedEncounterBundles.findByOpaqueId(bundleId); } + async saveExamFormEncounterBundlePins(input: PersistExamFormEncounterBundlePinsInput) { + return this.examFormEncounterBundlePins.persist(input); + } + + async getExamFormEncounterBundlePins(examFormId: string) { + return this.examFormEncounterBundlePins.load(examFormId); + } + + async launchPinnedExamStation(input: LaunchPinnedStationAssetsInput) { + return this.examFormEncounterBundlePins.launchPinnedStationAssets(input); + } + async listLearnerRuntimeAssetBundles(): Promise { return this.runtimeAssetBundles.listLearnerBundles(); } diff --git a/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/assemble-exam-form-with-pinned-bundles.ts b/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/assemble-exam-form-with-pinned-bundles.ts new file mode 100644 index 000000000..1fa4441b4 --- /dev/null +++ b/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/assemble-exam-form-with-pinned-bundles.ts @@ -0,0 +1,99 @@ +import { assembleExamForm } from "../assembly.js"; +import type { AssembleExamFormInput, ExamForm } from "../types.js"; +import { pinExamStationEncounterBundles } from "./pin-station-bundles.js"; +import type { + ExamStationBundlePinTarget, + PinExamStationEncounterBundlesRefusal, + PinExamStationEncounterBundlesSuccess, + PromotedEncounterBundleCatalogEntry, +} from "./types.js"; +import { + examStationEncounterBundlePinClaimScope, + examStationEncounterBundlePinNotEvidenceFor, +} from "./types.js"; + +export type AssembleExamFormWithPinnedEncounterBundlesInput = AssembleExamFormInput & { + catalog: readonly PromotedEncounterBundleCatalogEntry[]; +}; + +export type AssembleExamFormWithPinnedEncounterBundlesSuccess = PinExamStationEncounterBundlesSuccess & { + assembled: true; + form: ExamForm; +}; + +export type AssembleExamFormWithPinnedEncounterBundlesRefusal = PinExamStationEncounterBundlesRefusal & { + assembled: false; + form: ExamForm; +}; + +export type AssembleExamFormWithPinnedEncounterBundlesResult = + | AssembleExamFormWithPinnedEncounterBundlesSuccess + | AssembleExamFormWithPinnedEncounterBundlesRefusal; + +export function assembleExamFormWithPinnedEncounterBundles( + input: AssembleExamFormWithPinnedEncounterBundlesInput, +): AssembleExamFormWithPinnedEncounterBundlesResult { + const form = assembleExamForm({ + examFormId: input.examFormId, + blueprint: input.blueprint, + scenarios: input.scenarios, + }); + if (form.status !== "ready_for_review") { + return { + assembled: false, + form, + pinned: false, + examFormId: form.examFormId, + pins: [], + blockers: [ + `form:${form.examFormId}:assembly_not_ready:${form.status}`, + ...form.assemblyIssues.map((issue) => `form:${form.examFormId}:assembly_issue:${issue}`), + ], + claimScope: examStationEncounterBundlePinClaimScope, + notEvidenceFor: examStationEncounterBundlePinNotEvidenceFor, + }; + } + const pinned = pinExamStationEncounterBundles({ + examFormId: form.examFormId, + stations: pinTargetsFromAssembledForm(input, form), + catalog: input.catalog, + }); + if (!pinned.pinned) { + return { + assembled: false, + form, + ...pinned, + }; + } + return { + assembled: true, + form, + ...pinned, + }; +} + +export function pinTargetsFromAssembledForm( + input: AssembleExamFormInput, + form: ExamForm, +): ExamStationBundlePinTarget[] { + const slotsByOrder = new Map( + [...input.blueprint.stationSlots] + .sort((left, right) => left.order - right.order || left.slotId.localeCompare(right.slotId)) + .map((slot) => [slot.order, slot] as const), + ); + return form.stationRefs.map((ref) => { + const slot = slotsByOrder.get(ref.order); + if (!slot) { + throw new Error(`assembled station order ${ref.order} has no blueprint slot`); + } + return { + stationOrder: ref.order, + slotId: slot.slotId, + scenarioId: ref.scenarioId, + scenarioVersion: ref.scenarioVersion, + }; + }); +} + +export const assembledExamFormWithPinnedBundlesClaimScope = examStationEncounterBundlePinClaimScope; +export const assembledExamFormWithPinnedBundlesNotEvidenceFor = examStationEncounterBundlePinNotEvidenceFor; diff --git a/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/index.ts b/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/index.ts index 542b135a8..9321cb5ff 100644 --- a/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/index.ts +++ b/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/index.ts @@ -14,3 +14,13 @@ export type { PromotedEncounterBundleRuntimeEligibility, } from "./types.js"; export { pinExamStationEncounterBundles } from "./pin-station-bundles.js"; +export { + assembleExamFormWithPinnedEncounterBundles, + pinTargetsFromAssembledForm, +} from "./assemble-exam-form-with-pinned-bundles.js"; +export type { + AssembleExamFormWithPinnedEncounterBundlesInput, + AssembleExamFormWithPinnedEncounterBundlesRefusal, + AssembleExamFormWithPinnedEncounterBundlesResult, + AssembleExamFormWithPinnedEncounterBundlesSuccess, +} from "./assemble-exam-form-with-pinned-bundles.js"; diff --git a/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/pin-station-bundles.test.ts b/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/pin-station-bundles.test.ts index b4ea93cc3..bfee6a124 100644 --- a/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/pin-station-bundles.test.ts +++ b/packages/openclinxr/exam-assembly/src/encounter-bundle-selection/pin-station-bundles.test.ts @@ -1,5 +1,8 @@ +import { edChestPainScenario } from "@openclinxr/scenario-fixtures"; import { describe, expect, it } from "vitest"; +import { assembleExamFormWithPinnedEncounterBundles } from "./assemble-exam-form-with-pinned-bundles.js"; import { pinExamStationEncounterBundles } from "./pin-station-bundles.js"; +import type { ExamBlueprint } from "../types.js"; import type { ExamStationBundlePinTarget, PromotedEncounterBundleCatalogEntry, @@ -104,6 +107,61 @@ describe("pinExamStationEncounterBundles", () => { }); }); +describe("assembleExamFormWithPinnedEncounterBundles", () => { + it("pins opaque bundles onto a real assembled form for two clinical archetypes", () => { + const result = assembleExamFormWithPinnedEncounterBundles({ + examFormId: "form_two_archetype_pilot", + blueprint: twoStationBlueprint(), + scenarios: [ + approvedScenario(ED_SCENARIO, "ED chest pain"), + approvedScenario(PEDS_SCENARIO, "Peds asthma"), + ], + catalog: [edCatalog(), pedsCatalog()], + }); + expect(result.assembled).toBe(true); + if (!result.assembled) { + throw new Error(result.blockers.join(", ")); + } + expect(result.form.stationRefs.map((ref) => ref.scenarioId)).toEqual([ED_SCENARIO, PEDS_SCENARIO]); + expect(result.pins.map((pin) => pin.bundleId)).toEqual([ED_BUNDLE, PEDS_BUNDLE]); + expect(result.pins.map((pin) => pin.bundleId).join(" ")).not.toMatch(/fixture|local_exam_run/u); + }); + + it("fails closed from assembly when the catalog identity does not match the station", () => { + const result = assembleExamFormWithPinnedEncounterBundles({ + examFormId: "form_mismatch", + blueprint: twoStationBlueprint(), + scenarios: [ + approvedScenario(ED_SCENARIO, "ED chest pain"), + approvedScenario(PEDS_SCENARIO, "Peds asthma"), + ], + catalog: [{ ...edCatalog(), scenarioId: PEDS_SCENARIO }, pedsCatalog()], + }); + expect(result.assembled).toBe(false); + if (!result.assembled) { + expect(result.pins).toEqual([]); + expect(result.blockers).toContain(`station:${ED_SLOT}:identity_mismatch:${ED_BUNDLE}`); + } + }); + + it("fails closed before pinning when the assembled form is incomplete", () => { + const result = assembleExamFormWithPinnedEncounterBundles({ + examFormId: "form_incomplete", + blueprint: twoStationBlueprint(), + scenarios: [approvedScenario(ED_SCENARIO, "ED chest pain")], + catalog: [edCatalog(), pedsCatalog()], + }); + expect(result.assembled).toBe(false); + if (!result.assembled) { + expect(result.form.status).toBe("blueprint_incomplete"); + expect(result.pins).toEqual([]); + expect(result.blockers).toContain( + "form:form_incomplete:assembly_not_ready:blueprint_incomplete", + ); + } + }); +}); + function edStation(): ExamStationBundlePinTarget { return { stationOrder: 1, @@ -145,3 +203,43 @@ function pedsCatalog(): PromotedEncounterBundleCatalogEntry { identityScope: "learner_runtime_opaque_bundle", }; } + +function twoStationBlueprint(): ExamBlueprint { + return { + blueprintId: "blueprint_two_archetype_v1", + title: "Two archetype form", + stationSlots: [ + { + slotId: ED_SLOT, + order: 1, + label: "ED chest pain", + requiredEnvironmentIds: [], + requiredTraceTags: [], + }, + { + slotId: PEDS_SLOT, + order: 2, + label: "Peds asthma", + requiredEnvironmentIds: [], + requiredTraceTags: [], + }, + ], + timing: { + doorwaySeconds: 60, + encounterSeconds: 900, + noteSeconds: 600, + breakAfterStationOrders: [], + }, + requiredTraceTags: [], + requiredSafetyCriticalTraceTags: [], + }; +} + +function approvedScenario(scenarioId: string, title: string) { + return { + ...edChestPainScenario, + scenarioId, + title, + status: "approved" as const, + }; +} diff --git a/packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.test.ts b/packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.test.ts index 17bbf56b7..501f8b883 100644 --- a/packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.test.ts +++ b/packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { createDurableExamFormEncounterBundlePins, - MemoryExamFormEncounterBundlePinStore, + LocalTestExamFormEncounterBundlePinStore, } from "./durable-pins.js"; import type { DurablePromotedEncounterBundleLookupEntry } from "./types.js"; @@ -12,50 +12,28 @@ const PEDS_SLOT = "station_002_peds_asthma_parent_anxiety_v1"; const ED_BUNDLE = "bdl_ed_chest_pain_opaque_v1"; const PEDS_BUNDLE = "bdl_peds_asthma_opaque_v1"; -describe("durable exam-form encounter-bundle pins", () => { - it("persists one opaque pin per station and relaunches the same assets after process restart", () => { - const live = new MemoryExamFormEncounterBundlePinStore(); - const persisted = live.persist({ +describe("local test exam-form encounter-bundle pin store", () => { + it("persists pins as test_local_memory and never as database_source_of_truth", async () => { + const store = new LocalTestExamFormEncounterBundlePinStore(); + const persisted = await store.persist({ examFormId: "form_two_archetype_pilot", pins: [edPin(), pedsPin()], }); + expect(store.backend).toBe("test_local_memory"); + expect(store.durableStore).toBe("test_local_memory"); + expect(persisted.durableStore).toBe("test_local_memory"); + expect(persisted.pins.every((pin) => pin.durableStore === "test_local_memory")).toBe(true); expect(persisted.pins.map((pin) => pin.bundleId)).toEqual([ED_BUNDLE, PEDS_BUNDLE]); - expect(persisted.durableStore).toBe("database_source_of_truth"); - - const restarted = MemoryExamFormEncounterBundlePinStore.restore(JSON.parse(JSON.stringify(live.dump()))); - const catalog = [edCatalog(), pedsCatalog()]; - const edLaunch = restarted.launchPinnedStationAssets({ - examFormId: "form_two_archetype_pilot", - slotId: ED_SLOT, - catalog, - }); - const pedsLaunch = restarted.launchPinnedStationAssets({ - examFormId: "form_two_archetype_pilot", - slotId: PEDS_SLOT, - catalog, - }); - - expect(edLaunch.launched).toBe(true); - expect(pedsLaunch.launched).toBe(true); - if (edLaunch.launched && pedsLaunch.launched) { - expect(edLaunch.bundleId).toBe(ED_BUNDLE); - expect(edLaunch.contentIdentity).toBe("cid_ed_chest_pain_v1"); - expect(edLaunch.scenarioId).toBe(ED_SCENARIO); - expect(pedsLaunch.bundleId).toBe(PEDS_BUNDLE); - expect(pedsLaunch.scenarioId).toBe(PEDS_SCENARIO); - expect(edLaunch.bundleId).not.toBe(pedsLaunch.bundleId); - } }); - it("fails closed on stale, blocked, missing, and identity-mismatched catalog rows after reload", () => { - const live = new MemoryExamFormEncounterBundlePinStore(); - live.persist({ + it("fails closed on stale, blocked, missing, and identity-mismatched catalog rows", async () => { + const store = new LocalTestExamFormEncounterBundlePinStore(); + await store.persist({ examFormId: "form_two_archetype_pilot", pins: [edPin(), pedsPin()], }); - const restarted = MemoryExamFormEncounterBundlePinStore.restore(live.dump()); - const stale = restarted.launchPinnedStationAssets({ + const stale = await store.launchPinnedStationAssets({ examFormId: "form_two_archetype_pilot", slotId: ED_SLOT, catalog: [{ ...edCatalog(), contentIdentity: "cid_other" }], @@ -65,7 +43,7 @@ describe("durable exam-form encounter-bundle pins", () => { expect(stale.blockers).toContain(`station:${ED_SLOT}:stale`); } - const blocked = restarted.launchPinnedStationAssets({ + const blocked = await store.launchPinnedStationAssets({ examFormId: "form_two_archetype_pilot", slotId: ED_SLOT, catalog: [{ ...edCatalog(), runtimeEligibility: "blocked" }], @@ -75,7 +53,7 @@ describe("durable exam-form encounter-bundle pins", () => { expect(blocked.blockers).toContain(`station:${ED_SLOT}:blocked`); } - const missing = restarted.launchPinnedStationAssets({ + const missing = await store.launchPinnedStationAssets({ examFormId: "form_two_archetype_pilot", slotId: ED_SLOT, catalog: [pedsCatalog()], @@ -85,7 +63,7 @@ describe("durable exam-form encounter-bundle pins", () => { expect(missing.blockers).toContain(`station:${ED_SLOT}:missing`); } - const mismatch = restarted.launchPinnedStationAssets({ + const mismatch = await store.launchPinnedStationAssets({ examFormId: "form_two_archetype_pilot", slotId: ED_SLOT, catalog: [{ ...edCatalog(), scenarioId: PEDS_SCENARIO }], @@ -96,14 +74,15 @@ describe("durable exam-form encounter-bundle pins", () => { } }); - it("freezes persisted pins so later mutation cannot change the relaunch identity", () => { + it("freezes created pins so later mutation cannot change identity", () => { const record = createDurableExamFormEncounterBundlePins({ examFormId: "form_immutable", pins: [edPin()], - }); + }, "test_local_memory"); expect(Object.isFrozen(record)).toBe(true); expect(Object.isFrozen(record.pins)).toBe(true); expect(Object.isFrozen(record.pins[0])).toBe(true); + expect(record.durableStore).toBe("test_local_memory"); }); }); diff --git a/packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.ts b/packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.ts index 57ae6a60f..33b29480d 100644 --- a/packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.ts +++ b/packages/openclinxr/session-state/src/encounter-bundle-selection/durable-pins.ts @@ -2,6 +2,8 @@ import type { DurableExamFormEncounterBundlePins, DurableExamStationEncounterBundlePin, DurablePromotedEncounterBundleLookupEntry, + EncounterBundlePinDurableStore, + ExamFormEncounterBundlePinPersistencePort, LaunchPinnedStationAssetsInput, LaunchPinnedStationAssetsResult, PersistExamFormEncounterBundlePinsInput, @@ -11,10 +13,17 @@ import { durableExamStationEncounterBundlePinNotEvidenceFor, } from "./types.js"; +const DURABLE_STORES = new Set([ + "database_source_of_truth", + "test_local_memory", +]); + export function createDurableExamFormEncounterBundlePins( input: PersistExamFormEncounterBundlePinsInput, + durableStore: EncounterBundlePinDurableStore, ): DurableExamFormEncounterBundlePins { const examFormId = requireNonblank(input.examFormId, "examFormId"); + requireDurableStore(durableStore); if (input.pins.length === 0) { throw new Error("durable exam-form encounter-bundle pins require at least one station pin"); } @@ -26,79 +35,80 @@ export function createDurableExamFormEncounterBundlePins( scenarioVersion: pin.scenarioVersion, bundleId: requireNonblank(pin.bundleId, "bundleId"), contentIdentity: requireNonblank(pin.contentIdentity, "contentIdentity"), - durableStore: "database_source_of_truth", + durableStore, })); assertUniquePins(pins); return freezeForm({ examFormId, pins, - durableStore: "database_source_of_truth", + durableStore, claimScope: durableExamStationEncounterBundlePinClaimScope, notEvidenceFor: durableExamStationEncounterBundlePinNotEvidenceFor, }); } -export class MemoryExamFormEncounterBundlePinStore { +export async function launchPinnedStationAssetsFromPort( + port: Pick, + input: LaunchPinnedStationAssetsInput, +): Promise { + const examFormId = requireNonblank(input.examFormId, "examFormId"); + const slotId = requireNonblank(input.slotId, "slotId"); + const form = await port.load(examFormId); + if (!form) { + return refuseLaunch(examFormId, slotId, [`form:${examFormId}:missing`]); + } + const pin = form.pins.find((entry) => entry.slotId === slotId); + if (!pin) { + return refuseLaunch(examFormId, slotId, [`station:${slotId}:missing`]); + } + const catalogEntry = input.catalog.find((entry) => entry.bundleId === pin.bundleId); + if (!catalogEntry) { + return refuseLaunch(examFormId, slotId, [`station:${slotId}:missing`]); + } + const blockers = inspectLaunchCatalog(pin, catalogEntry); + if (blockers.length > 0) { + return refuseLaunch(examFormId, slotId, blockers); + } + return { + launched: true, + examFormId, + slotId, + scenarioId: pin.scenarioId, + bundleId: pin.bundleId, + contentIdentity: pin.contentIdentity, + claimScope: durableExamStationEncounterBundlePinClaimScope, + notEvidenceFor: durableExamStationEncounterBundlePinNotEvidenceFor, + }; +} + +/** + * In-process adapter for package tests. Never a database source of truth. + */ +export class LocalTestExamFormEncounterBundlePinStore implements ExamFormEncounterBundlePinPersistencePort { + readonly backend = "test_local_memory" as const; + readonly durableStore = "test_local_memory" as const; private readonly documents = new Map(); - persist(input: PersistExamFormEncounterBundlePinsInput): DurableExamFormEncounterBundlePins { - const record = createDurableExamFormEncounterBundlePins(input); + async persist(input: PersistExamFormEncounterBundlePinsInput): Promise { + const record = createDurableExamFormEncounterBundlePins(input, this.durableStore); + if (record.durableStore !== "test_local_memory") { + throw new Error("local test pin store must not use database_source_of_truth"); + } this.documents.set(record.examFormId, cloneJson(record)); return cloneJson(record); } - load(examFormId: string): DurableExamFormEncounterBundlePins | null { + async load(examFormId: string): Promise { const found = this.documents.get(requireNonblank(examFormId, "examFormId")); return found ? cloneJson(found) : null; } - dump(): Record { - return cloneJson(Object.fromEntries(this.documents.entries())); - } - - static restore( - serialized: Record, - ): MemoryExamFormEncounterBundlePinStore { - const store = new MemoryExamFormEncounterBundlePinStore(); - for (const record of Object.values(serialized)) { - store.documents.set(record.examFormId, cloneJson(record)); - } - return store; - } - - launchPinnedStationAssets(input: LaunchPinnedStationAssetsInput): LaunchPinnedStationAssetsResult { - const examFormId = requireNonblank(input.examFormId, "examFormId"); - const slotId = requireNonblank(input.slotId, "slotId"); - const form = this.load(examFormId); - if (!form) { - return refuseLaunch(examFormId, slotId, [`form:${examFormId}:missing`]); - } - const pin = form.pins.find((entry) => entry.slotId === slotId); - if (!pin) { - return refuseLaunch(examFormId, slotId, [`station:${slotId}:missing`]); - } - const catalogEntry = input.catalog.find((entry) => entry.bundleId === pin.bundleId); - if (!catalogEntry) { - return refuseLaunch(examFormId, slotId, [`station:${slotId}:missing`]); - } - const blockers = inspectLaunchCatalog(pin, catalogEntry); - if (blockers.length > 0) { - return refuseLaunch(examFormId, slotId, blockers); - } - return { - launched: true, - examFormId, - slotId, - scenarioId: pin.scenarioId, - bundleId: pin.bundleId, - contentIdentity: pin.contentIdentity, - claimScope: durableExamStationEncounterBundlePinClaimScope, - notEvidenceFor: durableExamStationEncounterBundlePinNotEvidenceFor, - }; + async launchPinnedStationAssets(input: LaunchPinnedStationAssetsInput): Promise { + return launchPinnedStationAssetsFromPort(this, input); } } -function inspectLaunchCatalog( +export function inspectLaunchCatalog( pin: DurableExamStationEncounterBundlePin, entry: DurablePromotedEncounterBundleLookupEntry, ): string[] { @@ -179,3 +189,10 @@ function requireNonblank(value: string, label: string): string { } return value; } + +function requireDurableStore(value: EncounterBundlePinDurableStore): EncounterBundlePinDurableStore { + if (!DURABLE_STORES.has(value)) { + throw new Error("durableStore must be database_source_of_truth or test_local_memory"); + } + return value; +} diff --git a/packages/openclinxr/session-state/src/encounter-bundle-selection/index.ts b/packages/openclinxr/session-state/src/encounter-bundle-selection/index.ts index 0d140b3f8..f25722a7f 100644 --- a/packages/openclinxr/session-state/src/encounter-bundle-selection/index.ts +++ b/packages/openclinxr/session-state/src/encounter-bundle-selection/index.ts @@ -7,6 +7,8 @@ export type { DurableExamStationEncounterBundlePin, DurableExamStationEncounterBundlePinNotEvidenceFor, DurablePromotedEncounterBundleLookupEntry, + EncounterBundlePinDurableStore, + ExamFormEncounterBundlePinPersistencePort, LaunchPinnedStationAssetsInput, LaunchPinnedStationAssetsRefusal, LaunchPinnedStationAssetsResult, @@ -15,5 +17,7 @@ export type { } from "./types.js"; export { createDurableExamFormEncounterBundlePins, - MemoryExamFormEncounterBundlePinStore, + inspectLaunchCatalog, + launchPinnedStationAssetsFromPort, + LocalTestExamFormEncounterBundlePinStore, } from "./durable-pins.js"; diff --git a/packages/openclinxr/session-state/src/encounter-bundle-selection/types.ts b/packages/openclinxr/session-state/src/encounter-bundle-selection/types.ts index 581d1300d..276a72334 100644 --- a/packages/openclinxr/session-state/src/encounter-bundle-selection/types.ts +++ b/packages/openclinxr/session-state/src/encounter-bundle-selection/types.ts @@ -12,6 +12,8 @@ export const durableExamStationEncounterBundlePinNotEvidenceFor = [ export type DurableExamStationEncounterBundlePinNotEvidenceFor = (typeof durableExamStationEncounterBundlePinNotEvidenceFor)[number]; +export type EncounterBundlePinDurableStore = "database_source_of_truth" | "test_local_memory"; + export type DurablePromotedEncounterBundleLookupEntry = { bundleId: string; scenarioId: string; @@ -30,13 +32,13 @@ export type DurableExamStationEncounterBundlePin = { scenarioVersion: number; bundleId: string; contentIdentity: string; - durableStore: "database_source_of_truth"; + durableStore: EncounterBundlePinDurableStore; }; export type DurableExamFormEncounterBundlePins = { examFormId: string; pins: readonly DurableExamStationEncounterBundlePin[]; - durableStore: "database_source_of_truth"; + durableStore: EncounterBundlePinDurableStore; claimScope: typeof durableExamStationEncounterBundlePinClaimScope; notEvidenceFor: typeof durableExamStationEncounterBundlePinNotEvidenceFor; }; @@ -75,3 +77,9 @@ export type LaunchPinnedStationAssetsRefusal = { export type LaunchPinnedStationAssetsResult = | LaunchPinnedStationAssetsSuccess | LaunchPinnedStationAssetsRefusal; + +export type ExamFormEncounterBundlePinPersistencePort = { + readonly durableStore: EncounterBundlePinDurableStore; + persist(input: PersistExamFormEncounterBundlePinsInput): Promise; + load(examFormId: string): Promise; +}; From e6e3e38fefdadfd8de00f4066448cfc925bb7f53 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 4 Sep 2026 14:46:04 -0400 Subject: [PATCH 3/3] fix(exam-assembly): retain approved seed coverage Prioritize approved scenarios within deterministic coverage selection so new draft cases cannot displace the runnable ED seed and its trace requirements. tsk_f29a64d0c7c36f12 --- .../exam-assembly/src/station-selection.ts | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/openclinxr/exam-assembly/src/station-selection.ts b/packages/openclinxr/exam-assembly/src/station-selection.ts index 30f1fb50b..b55243f74 100644 --- a/packages/openclinxr/exam-assembly/src/station-selection.ts +++ b/packages/openclinxr/exam-assembly/src/station-selection.ts @@ -8,10 +8,11 @@ import type { Scenario } from "@openclinxr/shared-schemas"; * every bank scenario to a station, so authoring a case silently grew the exam form (and broke the * station-count contract). * - * Selection maximizes VARIETY so an examinee's encounters probe breadth rather than repeating a - * narrow slice: different clinical settings, different actor-role mixes (which drive different - * communication challenges — interpreter, consultant, respiratory therapist, family), different - * safety-critical demands, and different skill trace tags. + * Selection keeps approved cases ahead of draft capacity, then maximizes VARIETY within each + * readiness tier so an examinee's encounters probe breadth rather than repeating a narrow slice: + * different clinical settings, different actor-role mixes (which drive different communication + * challenges — interpreter, consultant, respiratory therapist, family), different safety-critical + * demands, and different skill trace tags. * * Deterministic by construction: no RNG and no wall-clock. Ties break on the author-controlled bank * order, so the same bank always assembles the same form (required for replay + review evidence). @@ -129,11 +130,13 @@ export type ExamFormCoverageSummary = { }; /** - * Choose `stationCount` scenarios that maximize combined coverage. + * Choose `stationCount` scenarios that preserve runnable capacity and maximize combined coverage. * - * Greedy: repeatedly take the scenario adding the most new coverage. Greedy is the standard - * approximation for maximum coverage and is stable + explainable, which matters more here than - * optimality — a reviewer must be able to see why a station was chosen. + * Greedy: repeatedly take the approved scenario adding the most new coverage while any approved + * candidate remains, then fill the remaining capacity by the same coverage score. Greedy is the + * standard approximation for maximum coverage and is stable + explainable, which matters more here + * than optimality — a reviewer must be able to see why a station was chosen. Readiness priority + * prevents a newly-authored draft with novel dimensions from displacing the form's runnable case. * * When the bank has no more scenarios than stations, every scenario is used and the author's bank * order is preserved (callers that pass an explicit short list get exactly that list). @@ -153,12 +156,15 @@ export function selectExamStationScenarios( while (selected.length < stationCount && remaining.length > 0) { let bestIndex = 0; let bestScore = -1; + let bestIsApproved = false; for (const [index, candidate] of remaining.entries()) { const score = marginalCoverageScore(candidate.scenario, covered, rarity); + const isApproved = candidate.scenario.status === "approved"; // Strictly-greater keeps the earliest (author-ordered) candidate on ties → deterministic. - if (score > bestScore) { + if ((isApproved && !bestIsApproved) || (isApproved === bestIsApproved && score > bestScore)) { bestScore = score; bestIndex = index; + bestIsApproved = isApproved; } } const [chosen] = remaining.splice(bestIndex, 1);