Hospital Movement Architecture
Master strategy for how patients/specimens move: bounded contexts, custody-handoff spec, break-glass override.
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:
- Mover scan — who took possession (Practitioner|Device id + scanned-at timestamp)
- Role-context tuple —
(moverRole, movementContext)— distinguishes nurse-doing-nursing from nurse-as-runner from dedicated-runner; billing policy reads this - Gate evaluation result — pass/fail/overridden, with the gate identifier and (if overridden) the structured reason code
- Subject reference —
forfield 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
3. The 1:1 Lifetime Rule — Nest vs. Link
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.
3.2 Link when request TRIGGERS the move
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
- Humans →
Practitionerwithqualification[]carrying certifications (biohazard cert, controlled-substance clearance, porter certification) - Robots / AGVs / pneumatic tubes →
Devicewithspecialization[]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:
- The accepter and mover are often different people (senior accepts at station, junior intern actually pushes the wheelchair)
- Accept needs to be device-agnostic (any tablet, phone, terminal); scan is location-bound (must be at the patient)
- 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_overridesrows 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_reviewfilters 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_requestMongo entity + 9 REST endpoints + orchestrator handler + Supabase projection — transfer-request-system.mdspecimen_transport_requestSupabase entity + diagnostic-service module + orchestrator pipeline — specimen-transport-bounded-context.mdtransport_request(patient porter) Supabase entity + handlers — migration 044policy_gates+ admin UI — policy-gates.md- FHIR Task entity in
administration+AcknowledgementTaskprofile transformer + Task controller merging both paths OverrideReasonCodeenum 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
- No polymorphic
CustodyHandofftable. FHIR Task with profile differentiation only. - Movers are FHIR Practitioner (humans) and Device (robots). No parallel mover-registry table.
- Scan records
for-whom, notwho-pays. Billing rail resolves payer downstream. - Counter-scan is required wherever bonuses are paid on handoff. No bypass.
- Gates default to override-with-reason. Hard-stops are per-gate operator policy.
- Overrides are structured (typed reason code), audited (
policy_gate_overridesappend-only), and visible to billing + compliance. - The accept and the scan are separate gestures. UI may offer a combo shortcut; the architecture never assumes they are one event.
- A new movement bounded context requires real demand + at least two divergence axes. No enumeration from first principles.
9. Open Questions
- 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. - Per-gate
override_reason_codeswhitelist — 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. - 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.
- 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.
AcknowledgementRequestretirement. Migrating toTaskwithAcknowledgementTaskprofile 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
transfer-request-system.md— Patient Transfer (clinical) full specspecimen-transport-bounded-context.md— Specimen Transport bounded contextacknowledgement-system.md— Universal ack-request entity (to be consolidated into Task profile)policy-gates.md— Policy-gate base infrastructure (this doc extends with override protocol)encounter-orchestrator-triggers.md— When to use Postgres trigger vs. orchestrator handlermodule-loop/BACKEND-CONTRACTS.md— Cross-domain conventions (the “shared kernel as documentation”)event-contract.ts—OverrideReasonCodeenum (extension target)- FHIR R4 Task resource — http://hl7.org/fhir/R4/task.html