medOS ultra

Hospital Movement Architecture

Master strategy for how patients/specimens move: bounded contexts, custody-handoff spec, break-glass override.

17 min read diagramsUpdated 2026-05-26docs/architecture/hospital-movement-architecture.md

Movement & custody handoff

flowchart LR
  REQ["Movement request<br/>(patient / specimen)"] --> GATE{"Policy gate<br/>+ override"}
  GATE -->|allowed| MOVE["Mover<br/>(Practitioner + Device)"]
  MOVE -->|counter-scan| HANDOFF["Custody handoff"]
  HANDOFF --> DEST["Receiving ward / lab"]
  GATE -. break-glass .-> AUDIT[("override audit")]

Master strategy doc for how patients, specimens, and (eventually) other things move through the hospital. Locks in the bounded-context decisions from the 2026-05 architecture review and specifies the custody-handoff primitive + policy-gate override protocol that all movement domains share by convention.

Status: Architecture verdict, 2026-05-27. Specimen transport and transfer request are shipped; this doc sets the rules they and any future movement domain follow.


0. TL;DR — The Five Verdicts

# Verdict Means
1 Bounded contexts over monolithic platform Patient transfer, specimen transport, and any future movement domain stay separate. No “Hospital Logistics Engine.” Mover pools and invariants are disjoint.
2 Shared kernel is conventions, not code No polymorphic CustodyHandoff table. No switch(domain) Moleculer action. Cross-domain DNA lives in BACKEND-CONTRACTS.md. FHIR Task with profiles is the disciplined data-shape unifier — existing infrastructure, not new abstraction.
3 1:1 lifetime rule for handoffs When the request is the move (specimen), nest handoff fields on the order. When the request is a clinical decision that triggers a move (transfer), keep them separate and link.
4 Strict patient scope No speculative OperationalMovementOrder. When materials/linen/waste become real demand, they get their own bounded context — not a backdoor through patient-orderable movement.
5 Break-glass is first-class Gates are override-with-reason by default. Hard-stops are an operator-promoted exception per gate. Overrides are structured, audited, and visible to billing and compliance.

1. Bounded Context Map

1.1 What is a movement bounded context

A movement domain qualifies as its own bounded context when at least two of these diverge from existing domains:

  • Invariants (temperature, biohazard, consent, fall-risk, par-level, expiry)
  • Carrier pool (porter / stretcher vs. lab runner / pneumatic tube vs. AGV / robot)
  • External integration (none vs. HL7v2 OML vs. DEA registry vs. ERP)
  • Regulatory frame (clinical, DEA, DOT, OSHA, mortuary law)

1.2 Current state

Bounded context Status Owner service Carrier pool Reference
Patient Transfer (clinical request) Shipped administration n/a — admin only transfer-request-system.md
Patient Transport (physical porter dispatch) Shipped Supabase-native + administration Porter + stretcher/wheelchair/gurney/ambulance Migration 044
Specimen Transport Shipped diagnostic Lab runner / pneumatic tube / AGV / robot specimen-transport-bounded-context.md
Pharmacy logistics (controlled-substance handoff) Not built — AI verify is unrelated TBD TBD n/a
Supply observability (par-level, expiry) In design, not transport-shaped inventory-kit n/a realtime-inventory-supply-chain.md
Nutrition tray delivery Partial — order/menu/print built, dispatch absent medication (nutrition modules) TBD nutrition-printing-flow-gap.md
Equipment / Linen / Waste / Mortuary Not built, no roadmap commitment

1.3 Rule of three, enforced

A new movement bounded context gets built when (a) a real customer/roadmap commitment exists and (b) at least two of the four divergence axes (§1.1) apply. No more enumeration from first principles.


2. The Custody Handoff Event

The shared event across every movement domain — the moment a thing changes possession from one party to another. The shared shape is FHIR Task. Each domain emits its own custody-handoff event with the same field discipline.

2.1 Field discipline (FHIR Task with CustodyHandoffTask profile)

FHIR Task field Carries
Task.code Domain code (e.g. patient-transfer-handoff, specimen-transport-handoff)
Task.meta.profile http://medos.health/fhir/StructureDefinition/CustodyHandoffTask
Task.focus Reference to the order being moved (transfer_request/abc, specimen_transport_request/xyz, OperationalMovementOrder/... if/when introduced)
Task.for Patient / encounter reference. Nullable — operational moves leave it null.
Task.requester The assigner (who initiated the handoff)
Task.owner The mover (Practitioner for humans, Device for robots)
Task.executionPeriod Pickup → dropoff window
Task.restriction Time limits (temperature degradation window, biohazard max transit)
Task.input[] Scan event, role context, gate evaluation result, override (if any)
Task.output[] Counter-scan event, billable-candidate id

2.2 The four scan-time data points

Every custody handoff records these in Task.input:

  1. Mover scan — who took possession (Practitioner|Device id + scanned-at timestamp)
  2. Role-context tuple(moverRole, movementContext) — distinguishes nurse-doing-nursing from nurse-as-runner from dedicated-runner; billing policy reads this
  3. Gate evaluation result — pass/fail/overridden, with the gate identifier and (if overridden) the structured reason code
  4. Subject referencefor field at scan time; commits to who the work was for, not who pays

2.3 Lifecycle

                  ┌─────────────────┐
                  │  pending (slot  │  ← Task created on accept / order-ready
                  │  awaiting mover)│    no mover bound yet
                  └────────┬────────┘
                           │  scan-at-pickup
                           ▼
                  ┌─────────────────┐
                  │  in-progress    │  ← mover bound, gate evaluated,
                  │  (mover bound)  │    billable-candidate emitted
                  └────────┬────────┘
                           │  counter-scan at receiver
                           ▼
                  ┌─────────────────┐
                  │   completed     │  ← receiver counter-scanned;
                  │                 │    candidate becomes settled charge
                  └─────────────────┘
                  
        any state → cancelled (with reason, compensation per domain)
        any state → failed    (counter-scan never arrived; review queue)

2.4 Counter-scan as integrity primitive

Non-negotiable once bonuses are real. A bonus-bearing single-party scan is a fraud surface. Counter-scan closes it: pickup-scan and receiver-scan together make the handoff valid. Costs almost nothing in workflow; impossible to retrofit after bonuses go live.

Receiver counter-scans bind the destination party, not the destination location. This matters because the receiving nurse confirms identity + condition, not just GPS arrival.

2.5 For-whom vs. who-pays

The scan records subject (Task.for, possibly null). The billing rail resolves payer later through existing rules (scheme, coverage, contract, write-off). The scan moment never commits to a patient charge — it emits a billable-event candidate. This decoupling means:

  • Scheme/coverage rule changes don’t rot historical scans
  • Operational moves (null subject) flow naturally to cost-center allocation
  • The (moverRole, movementContext) tuple drives the policy that decides candidate → charge

The decisive question: does the request entity exist solely to record this physical move, or does it exist to record a clinical decision that may or may not trigger one?

3.1 Nest when request IS the move

Example: Specimen Transport. A specimen_transport_request row exists because something needs to be physically moved to the lab. There is no separate “clinical decision” layer above it. The handoff fields nest directly:

// specimen_transport_request entity (already shipped)
{
  id, specimen_ids, temperature_zone, biohazard_class,
  pickup_unit_id, dropoff_unit_id, priority,
  status,                          // request lifecycle
  assigned_runner_id,              // ← handoff: mover bound
  carrier_type,                    // ← handoff: mover identity class
  in_transit_at, delivered_at,     // ← handoff: scan timestamps
  // ...
}

Adding mover_scan_payload, receiver_counter_scan_payload, gate_override fields here is the natural extension. The request and the handoff have the same lifetime.

Example: Patient Transfer. A TransferRequest is the clinical decision (“Doctor A asks Doctor B to take the patient”). When accepted, it may or may not need a physical move — and even when it does, that move is a separate concern with its own porter, its own custody event, and possibly its own lifecycle (multiple porter dispatches, hand-offs between porters, etc.).

TransferRequest (clinical)              PatientTransfer (physical)
─────────────────────────              ──────────────────────────
status: REQUESTED                       — does not exist yet —
       ↓
status: ACKNOWLEDGED                    — does not exist yet —
       ↓
status: ACCEPTED                        — does not exist yet —
       ↓                                        (orchestrator may spawn)
status: IN_PROGRESS                     PatientTransfer created
       │                                status: PENDING, mover unassigned
       │  (linkedPatientTransferId)            ↓
       │                                status: ASSIGNED (porter scans badge)
       │                                       ↓
       │                                status: IN_TRANSIT (counter-scan pending)
       │                                       ↓
       ↓                                status: COMPLETED (receiver counter-scan)
status: COMPLETED                       

The two entities have different lifetimes. The clinical accept can happen at the nursing station; the physical scan happens at the bedside ten minutes later (possibly by a different person, see §5). Forcing them into one entity collapses that gap.

3.3 The pending-handoff slot

When TransferRequest reaches ACCEPTED, a PatientTransfer is created in pending status with no mover bound. This slot appears on the source ward’s worklist as “Patient X — ready to move.” It is a first-class state, not an implicit gap. (§5 covers the UI implications.)


4. Mover Identity & Eligibility

4.1 Movers are Practitioner + Device, not a new table

  • HumansPractitioner with qualification[] carrying certifications (biohazard cert, controlled-substance clearance, porter certification)
  • Robots / AGVs / pneumatic tubesDevice with specialization[] carrying capability flags (temperature-controlled, BSL3-rated, secure-transport)

Both are FHIR R4 resources. No new “mover registry” table — the registry concept lives on existing FHIR resources, disciplined by use.

4.2 Eligibility authority, not reference table

Once handoff scans drive bonus payments, the registry’s write path is security-sensitive. The clever attack (“invent a mover, scan it, pay it”) and the mundane one (“admin forgot to deactivate the runner who left last quarter, badge still bonus-active”) both exploit loose registry writes.

Write-path requirements:

Operation Audit Approval
Create mover Audit trail with supporting docs Approval-required (two-person)
Update certifications Audit trail Approval-required
Deactivate / suspend Audit trail Single-person OK; deactivation is de-escalation
Update shift state / on-duty Audit trail only Single-person, lightweight

4.3 Fitness in principle vs. fitness right now

Two distinct read shapes on the same mover record:

Dimension Examples Write posture Update frequency
Fitness in principle Certifications, capability flags, license Heavy (paperwork, approval) Rare (months/years)
Fitness right now Shift assignment, on-duty status, current location, robot battery state, suspension Light (single-person, audit-only) Constant (minutes/hours)

Gate predicates check both. Putting shift state through the cert-update approval flow makes ops unusable; putting cert updates through the shift-state lightweight path breaks the eligibility-authority promise.


5. Workflow: Accept at Desk, Scan at Bedside

The accept (clinical decision) and the scan (physical verification) are separated in time and space, even when the same person does both. A teaching hospital makes the separation more pronounced because:

  1. The accepter and mover are often different people (senior accepts at station, junior intern actually pushes the wheelchair)
  2. Accept needs to be device-agnostic (any tablet, phone, terminal); scan is location-bound (must be at the patient)
  3. Cognitive separation prevents errors — same principle the codebase’s MAR/BCMA already respects (think-at-desk, scan-at-bedside)

5.1 The three ward-side tabs

Tab What it shows Actor Action
Incoming transfers TransferRequest rows in REQUESTED / ACKNOWLEDGED state targeting this ward Receiving charge nurse Accept / acknowledge / reject
Ready to move PatientTransfer rows in pending state with no mover bound, source ward = mine Whoever’s free Take patient (scan binds mover)
In transit PatientTransfer rows in-progress, destination ward = mine Receiving floor Counter-scan on arrival

The middle tab is the critical addition. Without it, accepted transfers sit in unbound-mover limbo and nobody owns starting the move.

5.2 The self-assign shortcut

For the small-clinic case (or after-hours when one nurse is doing everything), an “Accept & Take” combo button calls accept + take in sequence. The scan happens at the moment of take, capturing moverRoleContext: 'clinical_staff_self_assign'. Billing policy reads this and does not mint the runner bonus fee — the move was absorbed care, not logistics labor.

The shortcut is a path, not the default. Default workflow keeps them separate.


6. Policy Gates: Override-With-Reason Protocol

6.1 Why override-with-reason is the default

Software does not physically prevent moves. A degrading BSL3 sample with no certified runner will get moved by a human exercising judgment regardless of what the gate returns. The only question is whether the system records that it happened.

A hard-stop gate doesn’t prevent the unsafe move — it prevents the logging of it. The risky thing happens off-book, with no override reason, no accountable name, no audit trail. That is the worst outcome.

So gates are override-with-reason by default. Hard-stops exist only where the operator explicitly promotes them per gate (§6.4).

6.2 Override schema additions

Extends policy-gates.md. Two changes:

(a) New column on policy_gates table:

ALTER TABLE policy_gates
  ADD COLUMN IF NOT EXISTS hard_stop BOOLEAN NOT NULL DEFAULT FALSE,
  ADD COLUMN IF NOT EXISTS override_reason_codes TEXT[] DEFAULT NULL;
  -- hard_stop=true → gate blocks, no override allowed
  -- hard_stop=false → override-with-reason allowed; if override_reason_codes is non-null,
  --                   the override must use one of those codes (whitelist per gate)

(b) New table policy_gate_overrides (append-only audit):

CREATE TABLE IF NOT EXISTS policy_gate_overrides (
  id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  gate_id             UUID NOT NULL REFERENCES policy_gates(id),
  gate_trigger        TEXT NOT NULL,              -- denormalized for fast query
  subject_resource    TEXT NOT NULL,              -- 'Task' / 'TransferRequest' / etc.
  subject_id          TEXT NOT NULL,              -- the entity overridden against
  encounter_id        UUID,                       -- if applicable
  patient_id          UUID,                       -- if applicable
  override_by         UUID NOT NULL,              -- user who clicked override
  override_reason_code TEXT NOT NULL,             -- from OverrideReasonCode + handoff extensions
  override_reason_text TEXT,                      -- mandatory if code == 'OTHER'
  override_metadata   JSONB NOT NULL DEFAULT '{}',-- gate-specific context (gate eval inputs, e.g. mover qualifications)
  occurred_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_pgo_gate_trigger ON policy_gate_overrides (gate_trigger, occurred_at DESC);
CREATE INDEX idx_pgo_override_by ON policy_gate_overrides (override_by, occurred_at DESC);
CREATE INDEX idx_pgo_patient    ON policy_gate_overrides (patient_id) WHERE patient_id IS NOT NULL;

6.3 Structured reason codes

Extends the existing OverrideReasonCode enum from CDS (see event-contract.ts). Reused codes for handoff context:

Code (existing) Use in handoff
CLINICALLY_JUSTIFIED The clinical situation warrants the override (deteriorating patient, sample degrading)
BENEFITS_OUTWEIGH_RISKS Time/availability tradeoff, judged worth it
MONITORING_IN_PLACE Mitigating supervision present (senior at bedside, escort assigned)
SENIOR_APPROVED Override authorized by attending / charge nurse
OTHER Free-text required

New handoff-specific codes to add to the enum:

Code (new) Use
TIME_CRITICAL Sample / patient cannot wait for compliant mover/path
NO_QUALIFIED_MOVER_AVAILABLE The right-cert mover is unreachable; nearest-available used
PATIENT_REFUSED_WAIT Patient declined to wait for qualified escort
EMERGENCY_OVERRIDE Code blue / rapid response context

6.4 Per-gate hard-stop policy

Operators promote a gate to hard-stop via policy_gates.hard_stop = TRUE. This is a per-tenant / per-market-pack call, not a code decision. Defaults are conservative (override-allowed) because the failure mode of over-blocking (off-book moves) is worse than the failure mode of over-permitting (logged overrides reviewable at audit).

Candidate gates worth considering as hard-stops in some operator policies:

  • Specimen transport carrying radioactive isotopes when carrier lacks radiation cert
  • Mortuary handoff without dual identity verification complete
  • Controlled-substance handoff (if/when built) without witnessed double-signature

Operators decide. System defaults to override-with-reason.

6.5 Orchestrator routing

mover scans at pickup
        │
        ▼
custody-handoff service evaluates gate(s) for this (orderType, mover, payload)
        │
   ┌────┴────┐
   │         │
 PASS      FAIL
   │         │
   │    hard_stop?
   │    ┌────┴────┐
   │    │         │
   │   YES       NO
   │    │         │
   │    ▼         ▼
   │  block    UI prompts for override:
   │  (Task    user picks reason code (from policy_gates.override_reason_codes
   │  stays   if whitelisted, else from full enum) + optional free text
   │  pending)         │
   │                   ▼
   │            insert policy_gate_overrides row
   │            attach override ref to Task.input
   │            (handoff proceeds as if PASS)
   │                   │
   └─────────┬─────────┘
             ▼
        Task transitions to in-progress
        emit manifest.custody.handed_off
                 ├──► billing-policy evaluator (reads gate result + role-context tuple)
                 ├──► compliance feed (filtered: overrides flagged for review)
                 └──► per-domain side effects (specimen pipeline, transfer linkage, etc.)

6.6 Compliance + billing visibility

  • Billing: policy_gate_overrides rows are joinable to billable-event candidates. A handoff that completed via override may bill differently (e.g., emergency premium) or trigger different cost-center allocation. Driven by the existing billing rail’s rules, not by new handoff logic.
  • Compliance: A view policy_gate_overrides_review filters to overrides flagged for review (configurable per gate). Hospital compliance officer’s queue.
  • Audit: Append-only, immutable. Standard practice for any money-or-safety gate.

7. What’s Shipped, What’s Open

7.1 Shipped (foundation for the strategy above)

  • transfer_request Mongo entity + 9 REST endpoints + orchestrator handler + Supabase projection — transfer-request-system.md
  • specimen_transport_request Supabase entity + diagnostic-service module + orchestrator pipeline — specimen-transport-bounded-context.md
  • transport_request (patient porter) Supabase entity + handlers — migration 044
  • policy_gates + admin UI — policy-gates.md
  • FHIR Task entity in administration + AcknowledgementTask profile transformer + Task controller merging both paths
  • OverrideReasonCode enum on the clinical-alert side — event-contract.ts

7.2 Open work (driven by this doc)

Item Surface Reference
CustodyHandoffTask profile definition (FHIR StructureDefinition + Task transformer wiring) services/public-api/.../fhir/transformers/ §2
Enrich Task.input / Task.output schemas to FHIR-correct valueX polymorphism packages/platform-api-schema/.../task/entity/ §2
Nest handoff fields on specimen_transport_request (mover-scan payload, receiver counter-scan, gate-override ref) infrastructure/medbase/migrations/, diagnostic service §3.1
Spawn PatientTransfer on TransferRequest.ACCEPTED with pending status + ward-side “Ready to move” tab administration service + orchestrator §3.2, §5.1
policy_gates.hard_stop column + policy_gate_overrides table migration + admin UI §6.2
Extend OverrideReasonCode enum with handoff codes platform-api-schema + event-contract.ts §6.3
Custody-handoff service action (gate eval + override capture + Task emission) likely owned by administration, registers per-domain gate predicates at service start §6.5
Receiver counter-scan flow in dispatch UIs medical-kit transfer + diagnostic specimen-transport surfaces §2.4
Mover-registry write-path approval controls on Practitioner / Device mutations aaa service + audit log §4.2
(moverRole, movementContext) tuple capture at scan + billing policy table keyed on it custody-handoff service + financial-policy table §2.2, §2.5

7.3 Explicitly out of scope

  • OperationalMovementOrder — not built. When materials / linen / waste / mortuary become real demand with roadmap commitment, each becomes its own bounded context. No backdoor through patient-orderable movement.
  • Equipment / linen / waste / mortuary logistics — cupboard bare, no current pull (per the 2026-05-27 demand-signal audit).
  • Pharmacy controlled-substance handoff — present pharmacy work is AI verify + robo-dog patrol, not custody-handoff-shaped. Revisit if/when DEA chain-of-custody becomes a roadmap commitment.
  • Supply observability — shipped as inventory/par-level/expiry surface, not as a transport sibling. See realtime-inventory-supply-chain.md.

8. Invariants

  1. No polymorphic CustodyHandoff table. FHIR Task with profile differentiation only.
  2. Movers are FHIR Practitioner (humans) and Device (robots). No parallel mover-registry table.
  3. Scan records for-whom, not who-pays. Billing rail resolves payer downstream.
  4. Counter-scan is required wherever bonuses are paid on handoff. No bypass.
  5. Gates default to override-with-reason. Hard-stops are per-gate operator policy.
  6. Overrides are structured (typed reason code), audited (policy_gate_overrides append-only), and visible to billing + compliance.
  7. The accept and the scan are separate gestures. UI may offer a combo shortcut; the architecture never assumes they are one event.
  8. A new movement bounded context requires real demand + at least two divergence axes. No enumeration from first principles.

9. Open Questions

  1. Where does the custody-handoff service action live? Likely administration (cross-domain, mirrors the role TransferRequest plays). Could also be a thin new service. Decision should follow which existing service has the closest ubiquitous-language fit and existing gate-evaluation infrastructure.
  2. Per-gate override_reason_codes whitelist — opt-in or opt-out? If null, allow any code from the enum; if set, restrict to whitelist. Default-permissive seems right but operator preference may vary.
  3. Compliance review queue: realtime or batch? Override events fire frequently in busy wards; a realtime stream may flood the compliance officer. A daily/shift digest with realtime escalation for specific high-severity overrides may be the right shape. Defer until override volume is observable.
  4. Counter-scan timeout policy. If the receiver never counter-scans (mover left without handoff completion), how long before the Task auto-fails into a review queue? Per-domain or global? Probably per-domain — specimen has tighter limits than patient transfer.
  5. AcknowledgementRequest retirement. Migrating to Task with AcknowledgementTask profile is desirable (single entity, FHIR-native), but the existing entity is heavily used and the migration touches every place that creates ack rows. Schedule when delivery pressure permits.

10. References

Ask Anything