Consult to Visit to Encounter Chain
Corrected model: materialize the encounter at arrival rather than booking-as-consult.
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:
- Hard-coded class.
level: source === EMER ? source : EMER— a no-op ternary, both branches yieldEMER. A routine OPD→cardiology referral spawned an emergency-class encounter. Fixed (consultRequest.controller.mixin.ts:781,924→level: encounterData.encounterClass ?? EncounterClass.AMB). - 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 anepisodeOfCareRef, and lands anencounter_idindepartment_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 oncreate. An encounter can be bornPLANNED; binding on creation would re-introduce binding-to-a-non-visit. Thearrivedtransition 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.basedOnis0..*— one visit legitimately fulfils two referrals (cardiology and renal in one encounter). Bind all candidates: append each toE.basedOn[], set each consult’sencounterTarget = 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
VisitRequestoccurrence orAppointmentasserting “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.
- Scheduling artifact present (a booked
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: any → DELETE |
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: string → retype 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.appointmentTimingis the order system’s RRULE bridge, not just an appointment field. Writers/readers:resolveSchedulePolicy.ts:54(copiesAdministrationUsage.rruleString→appointment.appointmentTiming),orderRequestItem.controller.mixin.ts:526,569,updateOrderRequestItem.dto.ts:238(appointment?: { appointmentTiming: string }), frontendblood-order-lab/DialogOrder.tsx:934(masterRrule), consumed bymedicationRequest.{controller,service}viaRRule.fromString. Renaming = a Mongo data migration + an end-to-end order-system rename.ConsultRequest.appointmentObj: anyis live: written by the inversion +admission.controller, queried viaappointmentObj.start, read by nuclear-medicine (.date/.time). Deleting it breaks NM.ConsultRequest.appointmentRefis the only truly dead one (declared, never written/read).
Shipped now (safe, non-destructive — description/comment only, no rename, no data migration):
appointmentTimingdescription corrected to declare it an RFC-5545 RRULE (de-foot-gunned).appointmentObjdescription corrected — the old “ไม่ใช้แล้ว/not used anymore” comment was false; now marked LIVE + slated-for-removal with the reader list.appointmentRefmarked@deprecated/DEAD with the revive-or-retire pointer.
Staged (each needs explicit go-ahead — touches live features / data):
- Retype
appointmentTiming— fold intofrequency-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). appointmentObj→ typed link — migrate NM (.date/.time) and theappointmentObj.startquery to read a typed Appointment ref (reviveappointmentRef) orencounterTarget; then deprecate-then-delete the blob. Deprecate, do not delete, until readers move.- Untangle the inversion — stop
appointment.createspawning aConsultRequest{status:APPOINTMENT}; represent bookings asAppointment/VisitRequest; retireAPPOINTMENT/BOOKED/ARRIVEDconsult 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 consultstype:'in-clinic',status:'Request', noappointmentObj. TheuseNuclearMedicineEncountersappointmentObj?.date/.timeread is a fallback NM never populates itself → losing inversionappointmentObjis a no-op for NM’s own data. (Also: my step-2 binding query excludesIN_CLINIC, so it never touches NM consults.) right-side/appointment/Appointment.tsxreads real Appointments separately (listAppointmentApi, line 85) and consults (line 101) as two independent lists; it never readsappointmentObj. 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
financialservice never referencesPLANNED(grep clean). SalesOrder/AR key off active/finished encounters with clinical activity. Nothing charges the phantom. ✅ - Supabase
encounter_journey_cache/department_queues: theencounter-orchestratoronly projects onmanifest.encounter.seeded/.status_updated/.context_enriched/manifest.order.created.encounter.createemits none of these — its after-hook (encounter.controller.mixin.ts:4015) emits only acreate-encounterSocket.IO message + aclinical.encounter.createdMoleculer event. So an un-checked-in phantom gets no journey-cache / queue row. ThereforedoctorWorklistAdapter(waiting tab includes'planned',:165) andjourneySelectors(ARRIVAL_ACTIVE_STATUSES = {REGISTERED, PLANNED},:25) — both readencounter_journey_cache— never 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
- Live socket push to OPD active lists.
encounter.create’s after-hook pushescreate-encounterto roomsall-patient-opd-active/all-patient-opd-doctor-{doctor}. Consumed by 5+ActivePatientTable.tsx(patient-roster hospital-registration / medos-registration / forensic),doctor-worklist/CustomMedicalRecordTable.tsx, andworklist-table/hooks/useAppSocket.ts. Whether the phantom persists depends on each list’s backing query: Mongo-REST-backed lists that filterPLANNEDwill show it; Supabase-journey-cache lists won’t. → per-list spot-check (below). - Webhook / FHIR emission.
clinical.encounter.createdresolves to FHIREncounterinwebhookDispatcher(confirmed bywebhookDispatcher.fhir.spec.ts). FHIR-subscribed tenants receive a spuriousPLANNEDEncounter. Tenant-dependent. - Mongo phantom row. Burns an
en(visit-number) sequence at creation, attachesepisodeOfCareRef. Readable by any Mongo-REST encounter list filteringPLANNED.
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-redesignencounterTarget= 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 || encounterfallback → uses source encounter when target is null. ✅ Better, not worse.consultRequest.populate.mixin.ts:42— dereferencesencounterTarget(optional, null-safe).worklist.service.ts:656— setsencounterTarget = encounterId(dashboard write path).
D. Parallel entity
AnesthesiologistRequestshares the exactencounterTarget+findDataByEncounterTargetId+ populate shape — but does NOT pre-create an encounter (noencounter.createin its module). No phantom defect. Should adoptEncounter.basedOnlinkage 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
PLANNEDare 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 noencounterTarget. - [ ] 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.encounterTargetfirst sopopulate.mixindoesn’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 —
handleEncounterSeededonly fires onmanifest.encounter.seeded(admission), not on consultencounter.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
- A
ConsultRequestnever creates anEncounter. - An
Encountermaterializes when the patient is in (thearrivedtransition), by the normal registration flow. - Binding is a reaction to the
arrivedevent, resolved against the write model, in the medication service. - Future-dating alone never justifies
VisitRequest; only recurrence does. - Auto-bind requires a scheduling artifact; bare clinic match → worklist candidate; ambiguity → manual select.
Encounter.basedOnis0..*; bind all matching open consults.IN_CLINICbinds synchronously to the current encounter; no new encounter, no birth event.- ConsultRequest owns clinical state; VisitRequest owns scheduling state; they are orthogonal and never cross-read.
- One recurrence VO-shape, reused; not one table.
- The gate is a Task on the existing
AcknowledgementRequestsubstrate, not a bespoke concept.
12. Sequence
- ✅ EMER fix — inherit source class, default
AMB(shipped). - ✅ Binding-on-arrival (v1 shipped 2026-05-31; typechecks clean, NOT yet committed/pushed):
- Removed both pre-create-at-
Nurse acceptblocks inconsultRequest.controller.mixin.ts(no more phantom PLANNED encounters; now-unusedEncounterClass/EncounterStatus/EsiLevelTypeimports dropped). - New action
medication.consultRequest.bindOpenConsultsToEncounter+consultRequestService.findOpenConsultsForBinding(patientRef, subClinicId)(open = status ∈ {NURSE_ACCEPT, BOOKED, APPOINTMENT},type ≠ IN_CLINIC,encounterTargetunset, patient + exact sub-clinic match; resolved against the Mongo write model). - Trigger in
administration.encounter.checkIn, after the existing status-emit, gated onstatus === 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. SetsencounterTargetonly (the link all current readers use). - Deferred:
Encounter.basedOn(FHIR fast-follow, §5); the “scheduling-artifact → auto, else worklist candidate” policy; otherarrivedentry points beyond check-in (e.g.revertStatusArrived); consult-side worklist UI for ambiguous candidates.
- Removed both pre-create-at-
- 🟡 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 —
appointmentTimingdocumented as an RFC-5545 RRULE;appointmentObjre-marked LIVE+slated (its “not used” comment was false);appointmentRefmarked DEAD/@deprecated. (Description-only; takes effect on nextplatform-api-schemabuild. Schema typechecks clean.) - ⏸️ Staged (needs go-ahead per §6): (1) retype
appointmentTimingvia the frequency-rrule-overhaul (rename+migration vs. document-in-place — decision needed); (2) migrateappointmentObjreaders (NM,appointmentObj.start) to a typed link, then deprecate→delete; (3) untangle the booking→consult inversion + retireAPPOINTMENT/BOOKED/ARRIVEDstates — needs its own §8-style reader audit ofConsultRequestStatusconsumers first.
- ✅ Shipped (non-destructive): corrected the entity definitions to tell the truth —
- 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— itsdepartment_queues/consultation_contextsync (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 aconsultationentry toqueue-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.appointmentTimingshould be added to that plan’s carrier inventory.docs/architecture/treatment-series-screening-gate-pattern.md— §9 here redirects itsevaluateGatefrom frontend-only to a persisted Task onAcknowledgementRequest.