From 3930d000248b8ca18080ff65f463112662d6b46c Mon Sep 17 00:00:00 2001 From: t Date: Fri, 4 Sep 2026 12:50:07 -0400 Subject: [PATCH 1/2] feat(ui-xr): boot learner stations from pinned encounter bundles Fetch the assembled-exam opaque bundle id per station, verify identity and eligibility before mounting, and record explicit refusal or offline fixture fallback instead of inferring from local scenario names. tsk_49e99de9605261d7 --- apps/ui-xr/src/encounter-bundle-boot/index.ts | 257 ++++++++++++++++++ .../learner-station-materialization/index.ts | 105 +++++++ ...ncounter-bundle-boots-each-station.test.ts | 242 +++++++++++++++++ 3 files changed, 604 insertions(+) create mode 100644 apps/ui-xr/src/encounter-bundle-boot/index.ts create mode 100644 apps/ui-xr/src/learner-station-materialization/index.ts create mode 100644 apps/ui-xr/src/the-pinned-encounter-bundle-boots-each-station.test.ts diff --git a/apps/ui-xr/src/encounter-bundle-boot/index.ts b/apps/ui-xr/src/encounter-bundle-boot/index.ts new file mode 100644 index 00000000..58c2599a --- /dev/null +++ b/apps/ui-xr/src/encounter-bundle-boot/index.ts @@ -0,0 +1,257 @@ +import { + evaluateEncounterRuntimeLearnerUseGate, + type EncounterRuntimeAsset, + type LearnerRuntimeAssetBundle, +} from "@openclinxr/asset-registry/runtime-bundles"; +import { + materializeLearnerStationFromBundle, + type LearnerStationMaterialization, +} from "../learner-station-materialization/index.js"; + +export const encounterBundleBootNotEvidenceFor = [ + "production_asset_readiness", + "quest_readiness", + "clinical_validity", + "scoring_validity", +] as const; + +export const encounterBundleBootClaimBoundary = + "pinned_encounter_bundle_boot_not_runtime_readiness" as const; + +export type AssembledExamStationSelection = { + stationId: string; + scenarioId: string; + /** Opaque bundle id pinned by the assembled exam. Required to boot a station. */ + pinnedBundleId: string | null; + /** + * Local scenario-name hint. Ignored whenever `pinnedBundleId` is present so a + * same-named fixture cannot displace the pin. + */ + localScenarioName?: string | null | undefined; +}; + +export type EncounterBundleBootClient = { + getLearnerRuntimeAssetBundle(bundleId: string): Promise; + findLearnerRuntimeAssetBundleByScenarioStation?: (input: { + scenarioId: string; + stationId?: string | null | undefined; + }) => Promise; +}; + +export type EncounterBundleBootOutcome = "selected" | "refused" | "offline_fixture_fallback"; + +export type EncounterBundleBootEvidence = { + schemaVersion: "openclinxr.encounter-bundle-boot.v1"; + stationId: string; + scenarioId: string; + pinnedBundleId: string | null; + selectedBundleId: string | null; + outcome: EncounterBundleBootOutcome; + fallbackActive: boolean; + fallbackReason: string | null; + inferredFromLocalScenarioName: false; + lookupPath: "pinned_bundle_id" | "offline_fixture" | "none"; + identityVerified: boolean; + eligibilityVerified: boolean; + blockers: string[]; + materialization: LearnerStationMaterialization | null; + claimBoundary: typeof encounterBundleBootClaimBoundary; + notEvidenceFor: typeof encounterBundleBootNotEvidenceFor; +}; + +export type BootPinnedEncounterStationsInput = { + stations: readonly AssembledExamStationSelection[]; + client?: EncounterBundleBootClient | undefined; + offlineFixtures?: Readonly> | undefined; +}; + +/** + * Boot each assembled-exam station from its pinned opaque bundle id. + * Never infers a bundle from a local scenario name when a pin exists. + */ +export async function bootPinnedEncounterStations( + input: BootPinnedEncounterStationsInput, +): Promise { + const evidence: EncounterBundleBootEvidence[] = []; + for (const station of input.stations) { + evidence.push(await bootOneStation(station, input.client, input.offlineFixtures)); + } + return evidence; +} + +async function bootOneStation( + station: AssembledExamStationSelection, + client: EncounterBundleBootClient | undefined, + offlineFixtures: Readonly> | undefined, +): Promise { + const pin = station.pinnedBundleId?.trim() || null; + if (!pin) { + return evidenceFor(station, { + outcome: "refused", + lookupPath: "none", + blockers: ["pinned_bundle_identity_missing"], + fallbackReason: "assembled exam station has no pinned encounter bundle id", + }); + } + + let bundle: LearnerRuntimeAssetBundle | null = null; + let lookupPath: EncounterBundleBootEvidence["lookupPath"] = "pinned_bundle_id"; + let fetchFailure: string | null = null; + + if (client) { + try { + bundle = await client.getLearnerRuntimeAssetBundle(pin); + } catch (error) { + fetchFailure = error instanceof Error && error.message.length > 0 + ? error.message + : "pinned_bundle_fetch_failed"; + } + } else { + fetchFailure = "api_client_absent"; + } + + if (!bundle) { + const fixture = offlineFixtures?.[pin]; + if (fixture) { + bundle = fixture; + lookupPath = "offline_fixture"; + } + } + + if (!bundle) { + return evidenceFor(station, { + outcome: "refused", + lookupPath: client ? "pinned_bundle_id" : "none", + blockers: [fetchFailure ?? "pinned_bundle_unavailable"], + fallbackReason: fetchFailure ?? "pinned_bundle_unavailable", + }); + } + + const identityBlockers = inspectPinnedBundleIdentity(bundle, station, pin); + if (identityBlockers.length > 0) { + return evidenceFor(station, { + outcome: "refused", + lookupPath, + selectedBundleId: bundle.bundleId, + blockers: identityBlockers, + fallbackReason: identityBlockers[0] ?? "identity_mismatch", + }); + } + + const eligibilityBlockers = inspectBundleEligibility(bundle); + if (eligibilityBlockers.length > 0) { + return evidenceFor(station, { + outcome: "refused", + lookupPath, + selectedBundleId: bundle.bundleId, + identityVerified: true, + blockers: eligibilityBlockers, + fallbackReason: eligibilityBlockers[0] ?? "eligibility_blocked", + }); + } + + const outcome: EncounterBundleBootOutcome = lookupPath === "offline_fixture" + ? "offline_fixture_fallback" + : "selected"; + return evidenceFor(station, { + outcome, + lookupPath, + selectedBundleId: bundle.bundleId, + identityVerified: true, + eligibilityVerified: true, + fallbackActive: outcome === "offline_fixture_fallback", + fallbackReason: lookupPath === "offline_fixture" + ? (fetchFailure ?? "offline_fixture_fallback") + : null, + materialization: materializeLearnerStationFromBundle(bundle), + }); +} + +export function inspectPinnedBundleIdentity( + bundle: LearnerRuntimeAssetBundle, + station: AssembledExamStationSelection, + pinnedBundleId: string, +): string[] { + const blockers: string[] = []; + if (bundle.identityScope !== "learner_runtime_opaque_bundle") { + blockers.push("identity_scope_mismatch"); + } + if (bundle.bundleId !== pinnedBundleId) { + blockers.push("pinned_bundle_id_mismatch"); + } + if (bundle.stationId !== station.stationId) { + blockers.push("station_id_mismatch"); + } + if (bundle.scenarioId !== station.scenarioId) { + blockers.push("scenario_id_mismatch"); + } + return blockers; +} + +export function inspectBundleEligibility(bundle: LearnerRuntimeAssetBundle): string[] { + if (bundleUsesOnlyApprovedLocalFixtureAssets(bundle)) { + return []; + } + const gate = evaluateEncounterRuntimeLearnerUseGate(bundle); + if (gate.canUseGeneratedBundleForLearnerRuntime) { + return []; + } + return gate.blockers.length > 0 ? [...gate.blockers] : ["learner_runtime_use_blocked"]; +} + +function bundleUsesOnlyApprovedLocalFixtureAssets(bundle: LearnerRuntimeAssetBundle): boolean { + return runtimeBundleAssets(bundle).every((asset) => + asset.blob.storeKind === "app_public_fixture" + && asset.reviewStatus !== "blocked" + && (asset.reviewStatus === "fixture_approved_for_local_runtime" + || asset.reviewStatus === "approved_for_local_runtime"), + ); +} + +function runtimeBundleAssets(bundle: LearnerRuntimeAssetBundle): EncounterRuntimeAsset[] { + return [ + bundle.environment, + ...bundle.actors.map((actor) => actor.model), + ...bundle.actors.flatMap((actor) => actor.animationClips), + ...bundle.actors + .map((actor) => actor.phonemeMap) + .filter((asset): asset is EncounterRuntimeAsset => Boolean(asset)), + ...bundle.equipment.map((equipment) => equipment.model), + ]; +} + +function evidenceFor( + station: AssembledExamStationSelection, + patch: { + outcome: EncounterBundleBootOutcome; + lookupPath: EncounterBundleBootEvidence["lookupPath"]; + selectedBundleId?: string | null; + identityVerified?: boolean; + eligibilityVerified?: boolean; + blockers?: string[]; + fallbackActive?: boolean; + fallbackReason: string | null; + materialization?: LearnerStationMaterialization | null; + }, +): EncounterBundleBootEvidence { + const fallbackActive = patch.fallbackActive + ?? (patch.outcome === "offline_fixture_fallback" || patch.outcome === "refused"); + return { + schemaVersion: "openclinxr.encounter-bundle-boot.v1", + stationId: station.stationId, + scenarioId: station.scenarioId, + pinnedBundleId: station.pinnedBundleId, + selectedBundleId: patch.selectedBundleId ?? null, + outcome: patch.outcome, + fallbackActive, + fallbackReason: patch.fallbackReason, + inferredFromLocalScenarioName: false, + lookupPath: patch.lookupPath, + identityVerified: patch.identityVerified === true, + eligibilityVerified: patch.eligibilityVerified === true, + blockers: [...(patch.blockers ?? [])], + materialization: patch.materialization ?? null, + claimBoundary: encounterBundleBootClaimBoundary, + notEvidenceFor: encounterBundleBootNotEvidenceFor, + }; +} diff --git a/apps/ui-xr/src/learner-station-materialization/index.ts b/apps/ui-xr/src/learner-station-materialization/index.ts new file mode 100644 index 00000000..e8b36935 --- /dev/null +++ b/apps/ui-xr/src/learner-station-materialization/index.ts @@ -0,0 +1,105 @@ +import type { LearnerRuntimeAssetBundle } from "@openclinxr/asset-registry/runtime-bundles"; + +export const learnerStationMaterializationNotEvidenceFor = [ + "production_asset_readiness", + "quest_readiness", + "clinical_validity", + "scoring_validity", +] as const; + +export const learnerStationMaterializationClaimBoundary = + "learner_station_materialization_not_runtime_readiness" as const; + +export type MaterializedStationMemberKind = + | "actor" + | "room" + | "equipment" + | "motion" + | "voice" + | "interaction"; + +export type MaterializedStationMember = { + kind: MaterializedStationMemberKind; + id: string; + assetId: string | null; +}; + +export type LearnerStationMaterialization = { + stationId: string; + scenarioId: string; + bundleId: string; + mounted: true; + actors: MaterializedStationMember[]; + rooms: MaterializedStationMember[]; + equipment: MaterializedStationMember[]; + motion: MaterializedStationMember[]; + voice: MaterializedStationMember[]; + interactions: MaterializedStationMember[]; + claimBoundary: typeof learnerStationMaterializationClaimBoundary; + notEvidenceFor: typeof learnerStationMaterializationNotEvidenceFor; +}; + +/** + * Project a verified learner bundle into the six station member lists a runtime can mount. + * Does not load GLBs or claim Quest/production readiness. + */ +export function materializeLearnerStationFromBundle( + bundle: LearnerRuntimeAssetBundle, +): LearnerStationMaterialization { + const actors: MaterializedStationMember[] = bundle.actors.map((actor) => ({ + kind: "actor", + id: actor.actorId, + assetId: actor.model.assetId, + })); + const rooms: MaterializedStationMember[] = [ + { + kind: "room", + id: bundle.environment.scenarioAssetId, + assetId: bundle.environment.assetId, + }, + ]; + const equipment: MaterializedStationMember[] = bundle.equipment.map((item) => ({ + kind: "equipment", + id: item.equipmentId, + assetId: item.model.assetId, + })); + const motion: MaterializedStationMember[] = bundle.actors.flatMap((actor) => + actor.animationClips.map((clip) => ({ + kind: "motion" as const, + id: clip.assetId, + assetId: clip.assetId, + })), + ); + const voice: MaterializedStationMember[] = bundle.actors.flatMap((actor) => + actor.phonemeMap + ? [{ kind: "voice" as const, id: actor.phonemeMap.assetId, assetId: actor.phonemeMap.assetId }] + : [], + ); + const interactions: MaterializedStationMember[] = [ + ...bundle.uiSurfaces.map((surface) => ({ + kind: "interaction" as const, + id: surface.surfaceId, + assetId: surface.schema?.assetId ?? surface.data?.assetId ?? null, + })), + ...(bundle.sceneManifest.dialogueTurns ?? []).map((turn, index) => ({ + kind: "interaction" as const, + id: turn.traceTag || `${turn.actorId}:${index}`, + assetId: null, + })), + ]; + + return { + stationId: bundle.stationId, + scenarioId: bundle.scenarioId, + bundleId: bundle.bundleId, + mounted: true, + actors, + rooms, + equipment, + motion, + voice, + interactions, + claimBoundary: learnerStationMaterializationClaimBoundary, + notEvidenceFor: learnerStationMaterializationNotEvidenceFor, + }; +} diff --git a/apps/ui-xr/src/the-pinned-encounter-bundle-boots-each-station.test.ts b/apps/ui-xr/src/the-pinned-encounter-bundle-boots-each-station.test.ts new file mode 100644 index 00000000..82b903a7 --- /dev/null +++ b/apps/ui-xr/src/the-pinned-encounter-bundle-boots-each-station.test.ts @@ -0,0 +1,242 @@ +import { + createEdChestPainLocalLearnerRuntimeAssetBundle, + type EncounterRuntimeAsset, + type LearnerRuntimeAssetBundle, +} from "@openclinxr/asset-registry/runtime-bundles"; +import { describe, expect, it } from "vitest"; +import { + bootPinnedEncounterStations, + type EncounterBundleBootClient, +} from "./encounter-bundle-boot/index.js"; + +/** + * PLANTED CONTRACT — learner station boot uses the assembled-exam pinned bundle id. + * + * Today `initializeLearnerRuntimeAssetBundle` (`main.ts`) can recover by + * `findLearnerRuntimeAssetBundleByScenarioStation`, which infers from local scenario/station + * names. When an assembled exam pins an opaque bundle, that inference is forbidden: a + * same-named decoy must not be selected, identity and eligibility must be checked before + * any member is mounted, and offline fixture use must carry an explicit fallback reason. + * + * Two station archetypes (ED chest pain, peds asthma) must both materialize actor, room, + * equipment, motion, voice, and interaction entries from their own pins. + */ + +const ED_PIN = "local_exam_run:ed_chest_pain_local_encounter:runtime-assets"; +const PEDS_PIN = "local_exam_run:peds_asthma_local_encounter:runtime-assets"; +const DECOY_PIN = "local_exam_run:decoy_same_scenario_name:runtime-assets"; + +describe("the pinned encounter bundle boots each station", () => { + it("boots two station archetypes from pinned ids and never infers from local scenario names", async () => { + const ed = withStationMembers(createEdChestPainLocalLearnerRuntimeAssetBundle(), { + clipId: "ed_patient_idle_clip", + phonemeId: "ed_patient_phoneme_map", + surfaceId: "ed_vitals_panel", + }); + const peds = withStationMembers(createEdChestPainLocalLearnerRuntimeAssetBundle({ + encounterId: "peds_asthma_local_encounter", + scenarioId: "peds_asthma_parent_anxiety_v1", + stationId: "peds_asthma_parent_anxiety_station_v1", + }), { + clipId: "peds_patient_idle_clip", + phonemeId: "peds_patient_phoneme_map", + surfaceId: "peds_parent_prompt_panel", + actorId: "patient_maya_johnson_v1", + }); + const decoy = createEdChestPainLocalLearnerRuntimeAssetBundle({ + encounterId: "decoy_same_scenario_name", + }); + + const fetchedIds: string[] = []; + const client: EncounterBundleBootClient = { + getLearnerRuntimeAssetBundle: async (bundleId) => { + fetchedIds.push(bundleId); + if (bundleId === ed.bundleId) return ed; + if (bundleId === peds.bundleId) return peds; + if (bundleId === decoy.bundleId) return decoy; + throw new Error(`unexpected bundle ${bundleId}`); + }, + findLearnerRuntimeAssetBundleByScenarioStation: async () => { + throw new Error("scenario-name inference is forbidden when a pinned bundle exists"); + }, + }; + + const evidence = await bootPinnedEncounterStations({ + stations: [ + { + stationId: "ed_chest_pain_station_v1", + scenarioId: "ed_chest_pain_priority_v1", + pinnedBundleId: ED_PIN, + localScenarioName: "peds_asthma_parent_anxiety_v1", + }, + { + stationId: "peds_asthma_parent_anxiety_station_v1", + scenarioId: "peds_asthma_parent_anxiety_v1", + pinnedBundleId: PEDS_PIN, + localScenarioName: "ed_chest_pain_priority_v1", + }, + ], + client, + }); + + expect(fetchedIds).toEqual([ED_PIN, PEDS_PIN]); + expect(fetchedIds).not.toContain(DECOY_PIN); + expect(evidence).toHaveLength(2); + expect(evidence.every((row) => row.inferredFromLocalScenarioName === false)).toBe(true); + expect(evidence.every((row) => row.lookupPath === "pinned_bundle_id")).toBe(true); + expect(evidence.map((row) => row.outcome)).toEqual(["selected", "selected"]); + expect(evidence.map((row) => row.selectedBundleId)).toEqual([ED_PIN, PEDS_PIN]); + expect(evidence.every((row) => row.identityVerified && row.eligibilityVerified)).toBe(true); + expect(evidence.every((row) => row.fallbackActive === false)).toBe(true); + expect(evidence.every((row) => row.materialization !== null)).toBe(true); + + const [edMat, pedsMat] = [evidence[0]!.materialization!, evidence[1]!.materialization!]; + expect(edMat.actors.length).toBeGreaterThan(0); + expect(edMat.rooms).toEqual([expect.objectContaining({ kind: "room" })]); + expect(edMat.equipment.length).toBeGreaterThan(0); + expect(edMat.motion.map((member) => member.id)).toContain("ed_patient_idle_clip"); + expect(edMat.voice.map((member) => member.id)).toContain("ed_patient_phoneme_map"); + expect(edMat.interactions.map((member) => member.id)).toContain("ed_vitals_panel"); + + expect(pedsMat.actors[0]?.id).toBe("patient_maya_johnson_v1"); + expect(pedsMat.rooms.length).toBe(1); + expect(pedsMat.equipment.length).toBeGreaterThan(0); + expect(pedsMat.motion.map((member) => member.id)).toContain("peds_patient_idle_clip"); + expect(pedsMat.voice.map((member) => member.id)).toContain("peds_patient_phoneme_map"); + expect(pedsMat.interactions.map((member) => member.id)).toContain("peds_parent_prompt_panel"); + expect(edMat.bundleId).not.toBe(pedsMat.bundleId); + }); + + it("refuses a pinned fetch that fails identity or eligibility before mounting assets", async () => { + const mismatched = createEdChestPainLocalLearnerRuntimeAssetBundle({ + stationId: "wrong_station", + }); + const blocked = createEdChestPainLocalLearnerRuntimeAssetBundle({ + encounterId: "ed_chest_pain_blocked_encounter", + }); + blocked.environment.reviewStatus = "blocked"; + + const client: EncounterBundleBootClient = { + getLearnerRuntimeAssetBundle: async (bundleId) => { + if (bundleId === mismatched.bundleId) return mismatched; + if (bundleId === blocked.bundleId) return blocked; + throw new Error(`unexpected ${bundleId}`); + }, + findLearnerRuntimeAssetBundleByScenarioStation: async () => { + throw new Error("scenario-name inference is forbidden when a pinned bundle exists"); + }, + }; + + const [identity, eligibility, missingPin] = await bootPinnedEncounterStations({ + stations: [ + { + stationId: "ed_chest_pain_station_v1", + scenarioId: "ed_chest_pain_priority_v1", + pinnedBundleId: ED_PIN, + }, + { + stationId: "ed_chest_pain_station_v1", + scenarioId: "ed_chest_pain_priority_v1", + pinnedBundleId: blocked.bundleId, + }, + { + stationId: "ed_chest_pain_station_v1", + scenarioId: "ed_chest_pain_priority_v1", + pinnedBundleId: null, + localScenarioName: "ed_chest_pain_priority_v1", + }, + ], + client, + }); + + expect(identity?.outcome).toBe("refused"); + expect(identity?.blockers).toContain("station_id_mismatch"); + expect(identity?.materialization).toBeNull(); + expect(identity?.inferredFromLocalScenarioName).toBe(false); + + expect(eligibility?.outcome).toBe("refused"); + expect(eligibility?.identityVerified).toBe(true); + expect(eligibility?.eligibilityVerified).toBe(false); + expect(eligibility?.materialization).toBeNull(); + + expect(missingPin?.outcome).toBe("refused"); + expect(missingPin?.blockers).toContain("pinned_bundle_identity_missing"); + expect(missingPin?.materialization).toBeNull(); + }); + + it("records documented offline fixture fallback instead of silently using a local scenario name", async () => { + const fixture = withStationMembers(createEdChestPainLocalLearnerRuntimeAssetBundle(), { + clipId: "offline_idle_clip", + phonemeId: "offline_phoneme_map", + surfaceId: "offline_chart_panel", + }); + + const evidence = await bootPinnedEncounterStations({ + stations: [ + { + stationId: "ed_chest_pain_station_v1", + scenarioId: "ed_chest_pain_priority_v1", + pinnedBundleId: ED_PIN, + localScenarioName: "peds_asthma_parent_anxiety_v1", + }, + ], + offlineFixtures: { [ED_PIN]: fixture }, + }); + + expect(evidence).toHaveLength(1); + expect(evidence[0]?.outcome).toBe("offline_fixture_fallback"); + expect(evidence[0]?.fallbackActive).toBe(true); + expect(evidence[0]?.fallbackReason).toBe("api_client_absent"); + expect(evidence[0]?.lookupPath).toBe("offline_fixture"); + expect(evidence[0]?.inferredFromLocalScenarioName).toBe(false); + expect(evidence[0]?.selectedBundleId).toBe(ED_PIN); + expect(evidence[0]?.materialization?.motion.map((member) => member.id)).toContain("offline_idle_clip"); + }); +}); + +function withStationMembers( + bundle: LearnerRuntimeAssetBundle, + extras: { clipId: string; phonemeId: string; surfaceId: string; actorId?: string }, +): LearnerRuntimeAssetBundle { + const [first, ...rest] = bundle.actors; + if (!first) { + throw new Error("fixture bundle has no actors"); + } + const clip = deriveAsset(first.model, extras.clipId, "animation_clip"); + const phoneme = deriveAsset(first.model, extras.phonemeId, "phoneme_map"); + const schema = deriveAsset(first.model, `${extras.surfaceId}_schema`, "ui_schema"); + const actors = [ + { + ...first, + actorId: extras.actorId ?? first.actorId, + animationClips: [clip], + phonemeMap: phoneme, + }, + ...rest, + ]; + return { + ...bundle, + actors, + uiSurfaces: [ + { + surfaceId: extras.surfaceId, + renderer: "schema_panel", + schema, + }, + ], + }; +} + +function deriveAsset( + source: EncounterRuntimeAsset, + assetId: string, + kind: EncounterRuntimeAsset["kind"], +): EncounterRuntimeAsset { + return { + ...source, + assetId, + kind, + displayName: assetId, + scenarioAssetId: assetId, + }; +} From 29ee4654b9a4041d0458d6d34c873e9345216fff Mon Sep 17 00:00:00 2001 From: t Date: Fri, 4 Sep 2026 13:05:49 -0400 Subject: [PATCH 2/2] feat(ui-xr): wire pinned encounter bundle boot into learner runtime Route assembled-exam pins through bootLearnerRuntimeFromAssembledExam, drop scenario-name inference on that path, and publish refusal/fallback trace evidence. Compose with compiled-room mount from origin/main. tsk_49e99de9605261d7 --- apps/ui-xr/src/encounter-bundle-boot/index.ts | 172 +++++++++++++----- apps/ui-xr/src/main.ts | 69 ++++--- ...ncounter-bundle-boots-each-station.test.ts | 89 +++++++++ 3 files changed, 253 insertions(+), 77 deletions(-) diff --git a/apps/ui-xr/src/encounter-bundle-boot/index.ts b/apps/ui-xr/src/encounter-bundle-boot/index.ts index 58c2599a..ea34cb80 100644 --- a/apps/ui-xr/src/encounter-bundle-boot/index.ts +++ b/apps/ui-xr/src/encounter-bundle-boot/index.ts @@ -74,24 +74,102 @@ export async function bootPinnedEncounterStations( ): Promise { const evidence: EncounterBundleBootEvidence[] = []; for (const station of input.stations) { - evidence.push(await bootOneStation(station, input.client, input.offlineFixtures)); + evidence.push((await bootOneStation(station, input.client, input.offlineFixtures)).evidence); } return evidence; } +const DEFAULT_LOCAL_BUNDLE_SENTINEL = "ed_chest_pain_local_encounter"; + +/** + * Assembled-exam pin from launch URL / stored opaque id / station field. + * The local default sentinel is not a pin and must not block fail-closed refusal. + */ +export function resolveAssembledExamPinnedBundleId(input: { + queryRuntimeAssetBundleId?: string | null | undefined; + storedRuntimeAssetBundleId?: string | null | undefined; + stationPinnedBundleId?: string | null | undefined; +}): string | null { + const station = input.stationPinnedBundleId?.trim() ?? ""; + if (station.length > 0) return station; + const query = input.queryRuntimeAssetBundleId?.trim() ?? ""; + if (query.length > 0) return query; + const stored = input.storedRuntimeAssetBundleId?.trim() ?? ""; + if (stored.length > 0 && stored !== DEFAULT_LOCAL_BUNDLE_SENTINEL) return stored; + return null; +} + +export type PinnedEncounterBundleRuntimeTrace = { + source: "assembled_exam_pinned_bundle_boot"; + outcome: EncounterBundleBootOutcome; + pinnedBundleId: string | null; + selectedBundleId: string | null; + inferredFromLocalScenarioName: false; + identityVerified: boolean; + eligibilityVerified: boolean; + fallbackActive: boolean; + fallbackReason: string | null; + blockers: string[]; + mounted: boolean; + claimBoundary: typeof encounterBundleBootClaimBoundary; + notEvidenceFor: typeof encounterBundleBootNotEvidenceFor; +}; + +export type LearnerRuntimeAssembledExamBootResult = { + evidence: EncounterBundleBootEvidence; + bundle: LearnerRuntimeAssetBundle | null; + runtimeTrace: PinnedEncounterBundleRuntimeTrace; +}; + +/** + * Learner-runtime composition: boot the assembled-exam station from its pin. + * Callers must not follow this with scenario-name inference when a pin exists. + */ +export async function bootLearnerRuntimeFromAssembledExam(input: { + station: AssembledExamStationSelection; + client?: EncounterBundleBootClient | undefined; + offlineFixtures?: Readonly> | undefined; +}): Promise { + const { evidence, bundle } = await bootOneStation( + input.station, + input.client, + input.offlineFixtures, + ); + const mounted = bundle !== null && evidence.materialization !== null; + const runtimeTrace: PinnedEncounterBundleRuntimeTrace = { + source: "assembled_exam_pinned_bundle_boot", + outcome: evidence.outcome, + pinnedBundleId: evidence.pinnedBundleId, + selectedBundleId: evidence.selectedBundleId, + inferredFromLocalScenarioName: false, + identityVerified: evidence.identityVerified, + eligibilityVerified: evidence.eligibilityVerified, + fallbackActive: evidence.fallbackActive, + fallbackReason: evidence.fallbackReason, + blockers: [...evidence.blockers], + mounted, + claimBoundary: evidence.claimBoundary, + notEvidenceFor: evidence.notEvidenceFor, + }; + return { evidence, bundle: mounted ? bundle : null, runtimeTrace }; +} + async function bootOneStation( station: AssembledExamStationSelection, client: EncounterBundleBootClient | undefined, offlineFixtures: Readonly> | undefined, -): Promise { +): Promise<{ evidence: EncounterBundleBootEvidence; bundle: LearnerRuntimeAssetBundle | null }> { const pin = station.pinnedBundleId?.trim() || null; if (!pin) { - return evidenceFor(station, { - outcome: "refused", - lookupPath: "none", - blockers: ["pinned_bundle_identity_missing"], - fallbackReason: "assembled exam station has no pinned encounter bundle id", - }); + return { + evidence: evidenceFor(station, { + outcome: "refused", + lookupPath: "none", + blockers: ["pinned_bundle_identity_missing"], + fallbackReason: "assembled exam station has no pinned encounter bundle id", + }), + bundle: null, + }; } let bundle: LearnerRuntimeAssetBundle | null = null; @@ -119,52 +197,64 @@ async function bootOneStation( } if (!bundle) { - return evidenceFor(station, { - outcome: "refused", - lookupPath: client ? "pinned_bundle_id" : "none", - blockers: [fetchFailure ?? "pinned_bundle_unavailable"], - fallbackReason: fetchFailure ?? "pinned_bundle_unavailable", - }); + return { + evidence: evidenceFor(station, { + outcome: "refused", + lookupPath: client ? "pinned_bundle_id" : "none", + blockers: [fetchFailure ?? "pinned_bundle_unavailable"], + fallbackReason: fetchFailure ?? "pinned_bundle_unavailable", + }), + bundle: null, + }; } const identityBlockers = inspectPinnedBundleIdentity(bundle, station, pin); if (identityBlockers.length > 0) { - return evidenceFor(station, { - outcome: "refused", - lookupPath, - selectedBundleId: bundle.bundleId, - blockers: identityBlockers, - fallbackReason: identityBlockers[0] ?? "identity_mismatch", - }); + return { + evidence: evidenceFor(station, { + outcome: "refused", + lookupPath, + selectedBundleId: bundle.bundleId, + blockers: identityBlockers, + fallbackReason: identityBlockers[0] ?? "identity_mismatch", + }), + bundle: null, + }; } const eligibilityBlockers = inspectBundleEligibility(bundle); if (eligibilityBlockers.length > 0) { - return evidenceFor(station, { - outcome: "refused", - lookupPath, - selectedBundleId: bundle.bundleId, - identityVerified: true, - blockers: eligibilityBlockers, - fallbackReason: eligibilityBlockers[0] ?? "eligibility_blocked", - }); + return { + evidence: evidenceFor(station, { + outcome: "refused", + lookupPath, + selectedBundleId: bundle.bundleId, + identityVerified: true, + blockers: eligibilityBlockers, + fallbackReason: eligibilityBlockers[0] ?? "eligibility_blocked", + }), + bundle: null, + }; } const outcome: EncounterBundleBootOutcome = lookupPath === "offline_fixture" ? "offline_fixture_fallback" : "selected"; - return evidenceFor(station, { - outcome, - lookupPath, - selectedBundleId: bundle.bundleId, - identityVerified: true, - eligibilityVerified: true, - fallbackActive: outcome === "offline_fixture_fallback", - fallbackReason: lookupPath === "offline_fixture" - ? (fetchFailure ?? "offline_fixture_fallback") - : null, - materialization: materializeLearnerStationFromBundle(bundle), - }); + return { + evidence: evidenceFor(station, { + outcome, + lookupPath, + selectedBundleId: bundle.bundleId, + identityVerified: true, + eligibilityVerified: true, + fallbackActive: outcome === "offline_fixture_fallback", + fallbackReason: lookupPath === "offline_fixture" + ? (fetchFailure ?? "offline_fixture_fallback") + : null, + materialization: materializeLearnerStationFromBundle(bundle), + }), + bundle, + }; } export function inspectPinnedBundleIdentity( diff --git a/apps/ui-xr/src/main.ts b/apps/ui-xr/src/main.ts index 01943ae7..daed4b78 100644 --- a/apps/ui-xr/src/main.ts +++ b/apps/ui-xr/src/main.ts @@ -38,6 +38,7 @@ import { } from "./learner-exam-form-boot.js"; import { scenariosFromFixtureSequence } from "./learner-exam-scenario-source.js"; import { mountStationEnvironmentForRuntime } from "./compiled-room-runtime-mount.js"; +import { bootLearnerRuntimeFromAssembledExam, resolveAssembledExamPinnedBundleId, type PinnedEncounterBundleRuntimeTrace } from "./encounter-bundle-boot/index.js"; import { collectActorWorldBoxes, deriveInteriorPreviewCamera, @@ -578,6 +579,7 @@ declare global { maxVisibleSlots: number; }; __openClinXrLearnerRuntimeUseGateEvidence?: LearnerRuntimeUseGateEvidence; + __openClinXrPinnedEncounterBundleBootEvidence?: PinnedEncounterBundleRuntimeTrace; __openClinXrLastStationSceneBootErrorStack?: string; __openClinXrExamFlowEvidence?: OpenClinXrExamFlowEvidence; __openClinXrExamRunSummaryEvidence?: OpenClinXrExamRunSummaryEvidence; @@ -1130,6 +1132,37 @@ function requireEncounterRuntimeAsset(asset: EncounterRuntimeAsset | undefined, } async function initializeLearnerRuntimeAssetBundle(client: StationApiClient | undefined): Promise { + const pin = resolveAssembledExamPinnedBundleId({ + queryRuntimeAssetBundleId: new URLSearchParams(window.location.search).get("runtimeAssetBundleId"), + storedRuntimeAssetBundleId: window.localStorage.getItem("openclinxr.runtimeAssetBundleId"), + }); + if (pin) { + const result = await bootLearnerRuntimeFromAssembledExam({ + station: { + stationId: selectedStationId() ?? "", + scenarioId: selectedScenarioId(), + pinnedBundleId: pin, + localScenarioName: selectedScenarioId(), + }, + client, + }); + window.__openClinXrPinnedEncounterBundleBootEvidence = result.runtimeTrace; + if (result.bundle) { + useEncounterRuntimeAssetBundle(result.bundle, { + source: result.evidence.lookupPath === "offline_fixture" ? "local_fixture_fallback" : "api_bundle", + fallbackReason: result.evidence.fallbackReason, + }); + recordBootPhase(result.evidence.outcome === "offline_fixture_fallback" ? "learner_runtime_asset_bundle_fallback" : "learner_runtime_asset_bundle_loaded"); + return; + } + recordLearnerRuntimeUseGateEvidence( + encounterRuntimeAssetBundle, + "api_bundle", + result.evidence.fallbackReason ?? "pinned_bundle_refused", + ); + recordBootPhase("learner_runtime_asset_bundle_api_generated_blocked_by_evidence_gates"); + return; + } const bundleId = learnerRuntimeAssetBundleId(); if (!client) { if (await initializeStaticGeneratedLearnerRuntimeAssetBundle()) { @@ -1169,29 +1202,6 @@ async function initializeLearnerRuntimeAssetBundle(client: StationApiClient | un useEncounterRuntimeAssetBundle(bundle, { source: "api_bundle" }); recordBootPhase("learner_runtime_asset_bundle_loaded"); } catch (error) { - const selectedScenarioBundle = await selectLearnerRuntimeAssetBundleByScenarioStation(client); - if (selectedScenarioBundle) { - try { - const bundle = await client.getLearnerRuntimeAssetBundle(selectedScenarioBundle.bundleId); - if (bundle.identityScope !== "learner_runtime_opaque_bundle") { - throw new Error("learner runtime asset bundle identity scope mismatch"); - } - if (shouldUseLearnerRuntimeAssetBundle(bundle)) { - useEncounterRuntimeAssetBundle(bundle, { source: "api_bundle" }); - recordBootPhase("learner_runtime_asset_bundle_loaded_by_scenario_station", error); - return; - } - recordLearnerRuntimeUseGateEvidence( - bundle, - "api_bundle", - `api_scenario_station_bundle_blocked:${bundle.bundleId}`, - ); - recordBootPhase("learner_runtime_asset_bundle_scenario_station_blocked_by_evidence_gates", error); - return; - } catch (scenarioBundleError) { - recordBootPhase("learner_runtime_asset_bundle_scenario_station_lookup_failed", scenarioBundleError); - } - } if (await initializeStaticGeneratedLearnerRuntimeAssetBundle()) { recordBootPhase("learner_runtime_asset_bundle_static_generated_loaded_after_api_fallback", error); return; @@ -1205,19 +1215,6 @@ async function initializeLearnerRuntimeAssetBundle(client: StationApiClient | un } } -async function selectLearnerRuntimeAssetBundleByScenarioStation( - client: StationApiClient, -): Promise<{ bundleId: string } | null> { - const scenarioId = selectedScenarioId(); - const stationId = selectedStationId(); - const selectedBundle = await client.findLearnerRuntimeAssetBundleByScenarioStation({ scenarioId, stationId }); - if (selectedBundle) { - window.localStorage.setItem("openclinxr.runtimeAssetBundleId", selectedBundle.bundleId); - window.__openClinXrSelectedRuntimeAssetBundleId = selectedBundle.bundleId; - } - return selectedBundle; -} - async function initializeStaticGeneratedLearnerRuntimeAssetBundle(): Promise { try { const response = await fetch(staticGeneratedLearnerRuntimeAssetBundlePath(), { cache: "no-store" }); diff --git a/apps/ui-xr/src/the-pinned-encounter-bundle-boots-each-station.test.ts b/apps/ui-xr/src/the-pinned-encounter-bundle-boots-each-station.test.ts index 82b903a7..edd079d6 100644 --- a/apps/ui-xr/src/the-pinned-encounter-bundle-boots-each-station.test.ts +++ b/apps/ui-xr/src/the-pinned-encounter-bundle-boots-each-station.test.ts @@ -1,3 +1,6 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { createEdChestPainLocalLearnerRuntimeAssetBundle, type EncounterRuntimeAsset, @@ -5,9 +8,12 @@ import { } from "@openclinxr/asset-registry/runtime-bundles"; import { describe, expect, it } from "vitest"; import { + bootLearnerRuntimeFromAssembledExam, bootPinnedEncounterStations, + resolveAssembledExamPinnedBundleId, type EncounterBundleBootClient, } from "./encounter-bundle-boot/index.js"; +import { createRuntimeStateFromBundle } from "./runtime-state.js"; /** * PLANTED CONTRACT — learner station boot uses the assembled-exam pinned bundle id. @@ -194,6 +200,89 @@ describe("the pinned encounter bundle boots each station", () => { }); }); +describe("learner runtime composition from assembled-exam pin", () => { + it("wires the pin path into main.ts and never scenario-name inference when a pin exists", () => { + const mainSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "main.ts"), "utf8"); + expect(mainSource).toContain("bootLearnerRuntimeFromAssembledExam"); + expect(mainSource).toContain("resolveAssembledExamPinnedBundleId"); + expect(mainSource).toContain("__openClinXrPinnedEncounterBundleBootEvidence"); + expect(mainSource).not.toContain("selectLearnerRuntimeAssetBundleByScenarioStation"); + expect(mainSource).not.toContain("findLearnerRuntimeAssetBundleByScenarioStation"); + }); + + it("fetches the pin, checks identity/eligibility before mount, and publishes runtime-state/trace evidence", async () => { + const pinned = withStationMembers(createEdChestPainLocalLearnerRuntimeAssetBundle(), { + clipId: "composition_idle_clip", + phonemeId: "composition_phoneme_map", + surfaceId: "composition_chart_panel", + }); + const fetchedIds: string[] = []; + const client: EncounterBundleBootClient = { + getLearnerRuntimeAssetBundle: async (bundleId) => { + fetchedIds.push(bundleId); + return pinned; + }, + findLearnerRuntimeAssetBundleByScenarioStation: async () => { + throw new Error("composition path must not infer from scenario names"); + }, + }; + + const result = await bootLearnerRuntimeFromAssembledExam({ + station: { + stationId: "ed_chest_pain_station_v1", + scenarioId: "ed_chest_pain_priority_v1", + pinnedBundleId: resolveAssembledExamPinnedBundleId({ + queryRuntimeAssetBundleId: ED_PIN, + storedRuntimeAssetBundleId: "ed_chest_pain_local_encounter", + stationPinnedBundleId: null, + }), + localScenarioName: "peds_asthma_parent_anxiety_v1", + }, + client, + }); + + expect(fetchedIds).toEqual([ED_PIN]); + expect(result.bundle?.bundleId).toBe(ED_PIN); + expect(result.runtimeTrace.mounted).toBe(true); + expect(result.runtimeTrace.identityVerified).toBe(true); + expect(result.runtimeTrace.eligibilityVerified).toBe(true); + expect(result.runtimeTrace.inferredFromLocalScenarioName).toBe(false); + expect(result.evidence.materialization).not.toBeNull(); + expect(result.bundle).not.toBeNull(); + if (!result.bundle) { + return; + } + const state = createRuntimeStateFromBundle(result.bundle); + expect(state.scenarioId).toBe(pinned.scenarioId); + expect(result.runtimeTrace.selectedBundleId).toBe(ED_PIN); + expect(result.runtimeTrace.source).toBe("assembled_exam_pinned_bundle_boot"); + }); + + it("refuses before mounting and still records fallback evidence on the runtime trace", async () => { + const mismatched = createEdChestPainLocalLearnerRuntimeAssetBundle({ stationId: "wrong_station" }); + const client: EncounterBundleBootClient = { + getLearnerRuntimeAssetBundle: async () => mismatched, + findLearnerRuntimeAssetBundleByScenarioStation: async () => { + throw new Error("composition path must not infer from scenario names"); + }, + }; + const result = await bootLearnerRuntimeFromAssembledExam({ + station: { + stationId: "ed_chest_pain_station_v1", + scenarioId: "ed_chest_pain_priority_v1", + pinnedBundleId: ED_PIN, + }, + client, + }); + expect(result.bundle).toBeNull(); + expect(result.runtimeTrace.mounted).toBe(false); + expect(result.runtimeTrace.outcome).toBe("refused"); + expect(result.runtimeTrace.fallbackActive).toBe(true); + expect(result.runtimeTrace.blockers).toContain("station_id_mismatch"); + expect(result.evidence.materialization).toBeNull(); + }); +}); + function withStationMembers( bundle: LearnerRuntimeAssetBundle, extras: { clipId: string; phonemeId: string; surfaceId: string; actorId?: string },