medOS ultra

Consult to Visit to Encounter Chain

Corrected model: materialize the encounter at arrival rather than booking-as-consult.

16 min read diagramsUpdated 2026-05-31docs/architecture/consult-visit-encounter-chain.md

Status: Design (corrected model). 2026-05-31. Owner: TBD. Reviewers: architecture. Supersedes the future-consult half of: docs/PLAN-consult-request-wiring.md (department_queues sync there still stands; the encounter-generation half is replaced by this doc). Reads alongside: hospital-movement-architecture.md (request-vs-event discipline, Task as shape unifier), visit-scheduling-config.md (RRULE occurrence projection), frequency-rrule-overhaul-plan.md (recurrence-VO consolidation), treatment-series-screening-gate-pattern.md (the gate), fhir-transformer-module.md (FHIR mapping conventions).


1. The one-paragraph model

A consult is a request. A visit is an event. Requests do not fabricate their events in advance — they are fulfilled by an event that points back with basedOn. So a ConsultRequest never creates an Encounter. The encounter is materialized when the patient is actually in (the check-in / arrived transition — the normal registration flow, by whatever path), and at that moment a binding reaction asks: “are there open consults for this patient that this new visit fulfils?” — and links them. Recurrence (a patient who must come back on a cadence) is the only thing that justifies the VisitRequest/RRULE spine; future-dating alone does not. This is the FHIR request → booking → event triad applied honestly.

            INTENT                 BOOKING (optional)            EVENT (the visit)
   ┌────────────────────────┐   ┌────────────────────┐   ┌──────────────────────────┐
   │ ConsultRequest         │   │ VisitRequest        │   │ Encounter                │
   │  (clinical referral)   │   │  (recurrence spine, │   │  (materialized at ARRIVED)│
   │  status: requested→    │   │   iff it recurs)    │   │  Encounter.basedOn[] ─────┼──► ConsultRequest(s)
   │   accepted→responded   │   │  rruleString/dtstart│   │  ConsultRequest.encounterTarget ◄─┘
   │   →complete            │   │  status: ACTIVE…    │   │                          │
   └────────────────────────┘   └────────────────────┘   └──────────────────────────┘
   FHIR ServiceRequest          FHIR ServiceRequest.       FHIR Encounter
   (was ReferralRequest STU3)   occurrenceTiming /         (.basedOn → ServiceRequest,
                                CarePlan.activity           .appointment → Appointment)

2. What was wrong (and the EMER fix already shipped)

The future-consult path used to, on status = 'Nurse accept', call administration.encounter.create with status: PLANNED and bind ConsultRequest.encounterTarget to it. Two defects:

  1. Hard-coded class. level: source === EMER ? source : EMER — a no-op ternary, both branches yield EMER. A routine OPD→cardiology referral spawned an emergency-class encounter. Fixed (consultRequest.controller.mixin.ts:781,924level: encounterData.encounterClass ?? EncounterClass.AMB).
  2. Phantom encounter (the structural defect this doc fixes). The PLANNED shell is not charging anything — charge capture keys off active/finished encounters with clinical activity. But it is a phantom row: it burns an en (EN/visit number) sequence at creation, attaches an episodeOfCareRef, and lands an encounter_id in department_queues — so it inflates every encounter-keyed read (worklists, dashboards, episode rollups) and becomes billable on activation. The risk when removing it is counted rows, not live revenue.

3. Binding: registration materializes, consult binds (reaction, not hook)

The encounter lifecycle already calls medication.encounterJourney.emitEncounterStatusUpdated on the check-in / arrived transition (encounter.controller.mixin.ts:1977) and emitEncounterClosed on close. That handler already lives in the medication service — the same service that owns ConsultRequest. The binding reaction extends that existing handler. Consequences:

  • No new emit point, no cross-service read, no synchronous coupling in administration. The reaction resolves consults against the medication-service write model (Mongo), not the Supabase projection — so it can never bind against a stale/cancelled consult.
  • Fire on arrived, not on create. An encounter can be born PLANNED; binding on creation would re-introduce binding-to-a-non-visit. The arrived transition is the literal “the patient is in.”

Binding predicate

For the new arrived encounter E of patient P at clinic/subclinic/specialty C:

candidates = ConsultRequest where
    patientRef == P
    AND status ∈ { accepted, booked }            -- open, awaiting realization
    AND (clinic | subClinic | specialty) matches C
    AND not already bound (encounterTarget is null)
  • M:N, not 1:1. Encounter.basedOn is 0..* — one visit legitimately fulfils two referrals (cardiology and renal in one encounter). Bind all candidates: append each to E.basedOn[], set each consult’s encounterTarget = E. Do not stop at first match.
  • Auto-bind vs suggest — uniform mechanism, differentiated confidence. A bare clinic match is necessary but not sufficient (right clinic, wrong reason: an open cardiology follow-up while the patient walks into cardiology for an unrelated acute issue).
    • Scheduling artifact present (a booked VisitRequest occurrence or Appointment asserting “this visit is for that consult”) → auto-bind.
    • Bare clinic/specialty match, no artifact → emit a candidate binding the consultant confirms at the worklist.
    • This is not a special case: “came because of the consult (scheduled) vs happened to be in the right clinic (walk-in)” is the same scheduled-vs-incidental axis, surfacing as auto-vs-suggest.
    • Ambiguity (two open cardiology consults, one matching encounter) is the degenerate sub-case → present both at the worklist for manual select; never silently pick.

IN_CLINIC is the degenerate case (no fork)

A bedside / same-visit consult (type = IN_CLINIC) mints no encounter. It binds synchronously at create time to the current encounter (encounterTarget = encounter = source) and routes to department_queues (dept_type = 'consultation'). Both flavors use the one rule “bind to the realizing encounter”; for IN_CLINIC the realizing encounter already exists, so there is no birth event to await.

4. Two orthogonal state machines (no reconciliation)

Splitting along the request-vs-event axis dissolves the “reconcile two workflowStates” problem — they stop being parallel duplicates:

Aggregate Owns States
ConsultRequest clinical state requested → accepted → responded → complete (+ cancelled)
VisitRequest scheduling/materialization state DRAFT → ACTIVE → SUSPENDED/COMPLETED/CANCELLED (lifecycle) + workflowState (wait-check-in → …)

Consult only reacts to encounter events; it never reads VisitRequest state across the service boundary. That sidesteps the eventual-consistency failure mode (a stale visitRequestRef pointing at a cancelled VisitRequest). Therefore visitRequestRef is NOT the linchpin — the typed Encounter.basedOn → ConsultRequest is. visitRequestRef survives at most as optional audit metadata, off the critical path.

5. Schema changes

Entity Change Rationale
Encounter ADD basedOn?: Ref[] (0..*) — DEFERRED to FHIR fast-follow The FHIR-canonical reverse link. Net-new (today only partOfRef). Not needed for behavior: the consult→encounter direction is ConsultRequest.encounterTarget (existing), and the reverse lookup (encounter→its consults) is already served by findDataByEncounterTargetId. So basedOn only buys FHIR-shape fidelity for the transformer layer — added when the FHIR transformer needs it, not now.
ConsultRequest encounterTarget bind moves from Nurse accept to the arrived binding reaction Stop pre-creating PLANNED encounters.
ConsultRequest appointmentObj: anyDELETE Denormalized untyped blob; replaced by basedOn/typed link. (Live today — see migration gate §8.)
ConsultRequest appointmentRef: Ref → revive as typed link or retire for basedOn Currently declared-but-never-written (truly dead).
ConsultRequestStatus decide fate of APPOINTMENT / BOOKED / ARRIVED These exist only to support the Appointment-spawns-ConsultRequest inversion (§6).
Appointment appointmentTiming: stringretype to rruleString (shared recurrence shape) It already holds an RRULE consumed via RRule.fromString in medicationRequest — untyped and mislocated.

6. The Appointment-spawns-ConsultRequest inversion (to untangle)

Today, creating an Appointment calls back and creates a ConsultRequest{ status: APPOINTMENT, appointmentObj: <the whole Appointment> } (appointment.controller.mixin.ts:311; admission.controller.mixin.ts:1125 does similar). A booking masquerades as a consult, carrying the appointment as an any blob (read by e.g. nuclear-medicine/useNuclearMedicineEncounters.ts, queried via appointmentObj.start). This is backwards: a booking is a Booking, not an Intent. Target end-state: bookings live as Appointment/VisitRequest; consults reference them when relevant; the APPOINTMENT/BOOKED/ARRIVED consult states and the appointmentObj blob are retired.

Step 3 is a MIGRATION, not a blind edit (consumer inventory, 2026-05-31)

Investigation showed the two fields are live and cross-cutting — they cannot be renamed/deleted in one shot:

  • Appointment.appointmentTiming is the order system’s RRULE bridge, not just an appointment field. Writers/readers: resolveSchedulePolicy.ts:54 (copies AdministrationUsage.rruleStringappointment.appointmentTiming), orderRequestItem.controller.mixin.ts:526,569, updateOrderRequestItem.dto.ts:238 (appointment?: { appointmentTiming: string }), frontend blood-order-lab/DialogOrder.tsx:934 (masterRrule), consumed by medicationRequest.{controller,service} via RRule.fromString. Renaming = a Mongo data migration + an end-to-end order-system rename.
  • ConsultRequest.appointmentObj: any is live: written by the inversion + admission.controller, queried via appointmentObj.start, read by nuclear-medicine (.date/.time). Deleting it breaks NM.
  • ConsultRequest.appointmentRef is the only truly dead one (declared, never written/read).

Shipped now (safe, non-destructive — description/comment only, no rename, no data migration):

  • appointmentTiming description corrected to declare it an RFC-5545 RRULE (de-foot-gunned).
  • appointmentObj description corrected — the old “ไม่ใช้แล้ว/not used anymore” comment was false; now marked LIVE + slated-for-removal with the reader list.
  • appointmentRef marked @deprecated/DEAD with the revive-or-retire pointer.

Staged (each needs explicit go-ahead — touches live features / data):

  1. Retype appointmentTiming — fold into frequency-rrule-overhaul-plan.md. Phase a: add a typed sibling field on the shared recurrence VO shape, dual-write. Phase b: migrate the order-system writers/readers + backfill Mongo. Phase c: drop the old field. Decision needed: rename-with-migration vs. keep-name-document-in-place (the latter is lower-risk and already mostly done by the description fix).
  2. appointmentObj → typed link — migrate NM (.date/.time) and the appointmentObj.start query to read a typed Appointment ref (revive appointmentRef) or encounterTarget; then deprecate-then-delete the blob. Deprecate, do not delete, until readers move.
  3. Untangle the inversion — stop appointment.create spawning a ConsultRequest{status:APPOINTMENT}; represent bookings as Appointment/VisitRequest; retire APPOINTMENT/BOOKED/ARRIVED consult states. See §6.1 audit below.

6.1 ConsultRequestStatus reader audit (completed 2026-05-31) — blast radius is small & bounded

Per-state findings:

State Producers Consumers Verdict
ARRIVED ('arrived') none none (zero typed or string refs in consult context) Dead — safe to retire immediately.
BOOKED ('booked') none only findOpenConsultsForBinding (my step-2 query) + a STATUS_MAPPING.md doc No producer — effectively dead. Harmless in the binding query (matches nothing).
APPOINTMENT ('appointment') the inversion: appointment.controller.mixin.ts:308 (sole writer) (a) NM useNuclearMedicineEncounters reads appointmentObj.date/.time; (b) right-side/appointment/Appointment.tsx lists consults by patient (consultRequest.service n({patientId})); © backend appointmentObj.start date-range list filter (consultRequest.service.ts:139); (d) my binding query LIVE — the only state with real consumers.

The worklist DOCTER_APPOINTMENT tab + ListAppointment.tsx read real Appointment entities (appointment.service), NOT consult-as-appointment — so they are unaffected.

Migration — investigation collapsed it (the readers never depended on the inversion):

  • NM (DialogNuclearMedicineAppointment) creates its own consults type:'in-clinic', status:'Request', no appointmentObj. The useNuclearMedicineEncounters appointmentObj?.date/.time read is a fallback NM never populates itself → losing inversion appointmentObj is a no-op for NM’s own data. (Also: my step-2 binding query excludes IN_CLINIC, so it never touches NM consults.)
  • right-side/appointment/Appointment.tsx reads real Appointments separately (listAppointmentApi, line 85) and consults (line 101) as two independent lists; it never reads appointmentObj. The inversion only made appointments show twice (once as Appointment, once as a duplicate APPOINTMENT-consult) → removing it is a dedup win.

So the only required change was (1) stop the inversion writer — ✅ done (appointment.controller.mixin.ts: removed the consultRequest.create, kept the appointment_context journey-cache enrichment, dropped the now-unused ConsultRequestStatus import; admin tsc clean, no new errors). Steps (2)/(3) proved unnecessary. Remaining (optional, non-urgent sweep): appointmentObj is now write-dead for new appointments but still holds historical data + null-safe fallback reads — drop it + the appointmentObj.start filter + retire the deprecated states in a later cleanup (needs historical-data handling).

7. Recurrence VO consolidation (the real headline)

Three RRULE carriers exist; converge on one recurrence value-object shape{ rruleString, dtstart, timezone, until|count } — reused, not one table (dosing and visit recurrence are different aggregates in different bounded contexts; they share the shape, not storage):

Carrier Today Action
VisitRequest.rruleString typed, correct canonical shape reference
medication/AdministrationUsage.rruleString typed; type field normalized in frequency-rrule-overhaul-plan.md P1 keep (separate aggregate, same shape)
Appointment.appointmentTiming untyped string holding an RRULE retype to the shared shape (§5)

Then “does consult use VisitRequest?” reduces to: consult references the recurrence VO iff it recurs. Future-single → no spine.

8. Reader audit (completed 2026-05-31) — verdict: lower risk than feared

The phantom PLANNED consult-encounter is created by consultRequest.controller.mixin.ts on Nurse accept via administration.encounter.create. Tracing every reader before removing that path:

A. Confirmed SAFE — no action needed

  • Charges: the financial service never references PLANNED (grep clean). SalesOrder/AR key off active/finished encounters with clinical activity. Nothing charges the phantom.
  • Supabase encounter_journey_cache / department_queues: the encounter-orchestrator only projects on manifest.encounter.seeded / .status_updated / .context_enriched / manifest.order.created. encounter.create emits none of these — its after-hook (encounter.controller.mixin.ts:4015) emits only a create-encounter Socket.IO message + a clinical.encounter.created Moleculer event. So an un-checked-in phantom gets no journey-cache / queue row. Therefore doctorWorklistAdapter (waiting tab includes 'planned', :165) and journeySelectors (ARRIVAL_ACTIVE_STATUSES = {REGISTERED, PLANNED}, :25) — both read encounter_journey_cachenever see an abandoned phantom. ✅ (Projection only happens if/when it’s actually checked in.)

B. REAL pollution paths — but they vanish when the pre-create is removed

  1. Live socket push to OPD active lists. encounter.create’s after-hook pushes create-encounter to rooms all-patient-opd-active / all-patient-opd-doctor-{doctor}. Consumed by 5+ ActivePatientTable.tsx (patient-roster hospital-registration / medos-registration / forensic), doctor-worklist/CustomMedicalRecordTable.tsx, and worklist-table/hooks/useAppSocket.ts. Whether the phantom persists depends on each list’s backing query: Mongo-REST-backed lists that filter PLANNED will show it; Supabase-journey-cache lists won’t. → per-list spot-check (below).
  2. Webhook / FHIR emission. clinical.encounter.created resolves to FHIR Encounter in webhookDispatcher (confirmed by webhookDispatcher.fhir.spec.ts). FHIR-subscribed tenants receive a spurious PLANNED Encounter. Tenant-dependent.
  3. Mongo phantom row. Burns an en (visit-number) sequence at creation, attaches episodeOfCareRef. Readable by any Mongo-REST encounter list filtering PLANNED.

Because step 2 removes the pre-create entirely (rather than migrating it), B1/B2/B3 simply stop happening for consults. No suppression code needed.

C. encounterTarget readers — all behave fine post-redesign

  • healthTimeline.findDataByEncounterTargetId(encounterId) (healthTimeline.controller.mixin.ts:413) — given an encounter, finds consults targeting it. Post-redesign encounterTarget = the materialized arrived encounter (set by the binding handler); null between accept and arrival. Still resolves. ✅
  • AssignDoctor.tsx (medical-worklist :192 + central-worklist :195) — encounterTarget || encounter fallback → uses source encounter when target is null. ✅ Better, not worse.
  • consultRequest.populate.mixin.ts:42 — dereferences encounterTarget (optional, null-safe).
  • worklist.service.ts:656 — sets encounterTarget = encounterId (dashboard write path).

D. Parallel entity

  • AnesthesiologistRequest shares the exact encounterTarget + findDataByEncounterTargetId + populate shape — but does NOT pre-create an encounter (no encounter.create in its module). No phantom defect. Should adopt Encounter.basedOn linkage for consistency eventually; out of scope for step 2.

Pre-removal checklist (what’s actually left to do)

  • [ ] Per OPD-active list in B1: confirm the backing query source. Mongo-REST lists that include PLANNED are the only place a live phantom is user-visible today; once the pre-create stops, new phantoms cease — but spot-check each list renders correctly with consults that have no encounterTarget.
  • [ ] Existing data: decide between (a) leave existing phantom encounters in place and only stop creating new ones (non-destructive, preferred), or (b) scrub them — if (b), null out dangling ConsultRequest.encounterTarget first so populate.mixin doesn’t dereference deleted rows. Per repo rule #3/#4a, prefer (a); any cleanup is a separate, explicitly-approved step.
  • [x] Charges — proven safe.
  • [x] Supabase journey-cache / department_queues — proven safe (not projected pre-checkin).
  • [x] Orchestrator fan-out — handleEncounterSeeded only fires on manifest.encounter.seeded (admission), not on consult encounter.create.

9. Gate-as-Task (for the recurring-program follow-on)

The per-session screening gate (RT reference impl, treatment-series-screening-gate-pattern.md) is not a fifth un-modelable concept — it is a FHIR Task. focus → the fraction/Procedure, status ready/on-hold/rejected, businessStatus for the clinical outcome (pass / md-review / defer). medOS already has the substrate: foundation/acknowledgementRequest (AcknowledgementRequest, documented FHIR R4 Task, status state machine + multi-channel dispatcher). Persist the gate as an AcknowledgementRequest variant or a sibling Task profile — costs a profile, not a table — and the “evaluateGate runs frontend-only, not persisted server-side” defect disappears, with a queryable state machine for free.

10. FHIR / HL7v2 mapping

Layer DDD role medOS FHIR R4 HL7v2
Intent request aggregate ConsultRequest (recurrence via VO when it recurs) ServiceRequest (was ReferralRequest STU3); recurrence = occurrenceTiming / CarePlan.activity REF^I12
Booking scheduling entity Appointment / VisitRequest occurrence Appointment SIU^S12
Event event/fact aggregate (identity: EN/VN/AN) Encounter Encounter (.basedOn → ServiceRequest, .appointment → Appointment) ADT (A04/A01)

Note: RRULE is iCalendar (RFC 5545), not FHIR; FHIR’s analog is Timing.repeat. medOS deliberately reuses the iCal rruleString convention across dosing and visits — a shared-kernel value object.

11. Invariants

  1. A ConsultRequest never creates an Encounter.
  2. An Encounter materializes when the patient is in (the arrived transition), by the normal registration flow.
  3. Binding is a reaction to the arrived event, resolved against the write model, in the medication service.
  4. Future-dating alone never justifies VisitRequest; only recurrence does.
  5. Auto-bind requires a scheduling artifact; bare clinic match → worklist candidate; ambiguity → manual select.
  6. Encounter.basedOn is 0..*; bind all matching open consults.
  7. IN_CLINIC binds synchronously to the current encounter; no new encounter, no birth event.
  8. ConsultRequest owns clinical state; VisitRequest owns scheduling state; they are orthogonal and never cross-read.
  9. One recurrence VO-shape, reused; not one table.
  10. The gate is a Task on the existing AcknowledgementRequest substrate, not a bespoke concept.

12. Sequence

  1. EMER fix — inherit source class, default AMB (shipped).
  2. Binding-on-arrival (v1 shipped 2026-05-31; typechecks clean, NOT yet committed/pushed):
    • Removed both pre-create-at-Nurse accept blocks in consultRequest.controller.mixin.ts (no more phantom PLANNED encounters; now-unused EncounterClass/EncounterStatus/EsiLevelType imports dropped).
    • New action medication.consultRequest.bindOpenConsultsToEncounter + consultRequestService.findOpenConsultsForBinding(patientRef, subClinicId) (open = status ∈ {NURSE_ACCEPT, BOOKED, APPOINTMENT}, type ≠ IN_CLINIC, encounterTarget unset, patient + exact sub-clinic match; resolved against the Mongo write model).
    • Trigger in administration.encounter.checkIn, after the existing status-emit, gated on status === ARRIVED, fire-and-forget.
    • v1 policy: exactly-one match → auto-bind (encounterTarget); multiple → logged + left for manual worklist select (no silent tiebreak); zero → no-op. Sets encounterTarget only (the link all current readers use).
    • Deferred: Encounter.basedOn (FHIR fast-follow, §5); the “scheduling-artifact → auto, else worklist candidate” policy; other arrived entry points beyond check-in (e.g. revertStatusArrived); consult-side worklist UI for ambiguous candidates.
  3. 🟡 De-junk the scheduling fields (partially shipped 2026-05-31) — investigation found these fields are live + cross-cutting (see §6 consumer inventory), so this is a staged migration, not a blind edit.
    • Shipped (non-destructive): corrected the entity definitions to tell the truth — appointmentTiming documented as an RFC-5545 RRULE; appointmentObj re-marked LIVE+slated (its “not used” comment was false); appointmentRef marked DEAD/@deprecated. (Description-only; takes effect on next platform-api-schema build. Schema typechecks clean.)
    • ⏸️ Staged (needs go-ahead per §6): (1) retype appointmentTiming via the frequency-rrule-overhaul (rename+migration vs. document-in-place — decision needed); (2) migrate appointmentObj readers (NM, appointmentObj.start) to a typed link, then deprecate→delete; (3) untangle the booking→consult inversion + retire APPOINTMENT/BOOKED/ARRIVED states — needs its own §8-style reader audit of ConsultRequestStatus consumers first.
  4. Recurring programs — reference the one recurrence VO (§7); persist RT’s gate as a Task (§9).

13. Reconciliation notes

  • docs/PLAN-consult-request-wiring.md — its department_queues/consultation_context sync (useConsultQueueSync, status→queue mapping) is unaffected and still needed; this doc only replaces that plan’s encounter-generation assumption (it assumed nurse-accept owns the future encounter — it does not). Add a consultation entry to queue-generation-registry.ts (still missing).
  • docs/architecture/frequency-rrule-overhaul-plan.md — §7 here is the visit/appointment-side counterpart of that plan’s medication-side recurrence cleanup; both converge on the one recurrence VO-shape. Appointment.appointmentTiming should be added to that plan’s carrier inventory.
  • docs/architecture/treatment-series-screening-gate-pattern.md — §9 here redirects its evaluateGate from frontend-only to a persisted Task on AcknowledgementRequest.
Ask Anything