RIS Adapter Design
Full design for the HL7v2 ORM/ORU bidirectional RIS adapter: file plan, message templates, config + dispatch-log schemas, state machine.
Status: Design — implementation deferred to its own module loop (docs/ris-adapter-loop/).
Decision (2026-05-03): HL7v2 ORM/ORU bidirectional first, FHIR Subscription added later as a thin translator.
Source-of-truth bug: bug-016 / D2 in docs/nuclear-medicine-loop/09-bug-log.md.
Why a separate loop. Sized at 600-900 LOC, touching
services/interoperability/+services/medication/+infrastructure/medbase/. Each modification has cross-service ripple, so it deserves its own audit → spec → data-model → backend → wiring → seeding → verify cycle per the standard module-loop framework.
1. Scope
The RIS adapter is the bidirectional bridge between medOS imagingRequest and the hospital’s existing Radiology Information System. It covers TOR §3.15.7 (NM RIS integration) and TOR §4.1.11–4.1.15 (imaging RIS integration).
In scope (HL7v2 phase):
- Outbound: medOS imagingRequest → ORM^O01 → hospital RIS (new orders, cancellations, modifications)
- Outbound: medOS imagingResult → ORU^R01 → hospital RIS (preliminary, final, addendum, amended reports)
- Inbound: hospital RIS → ORM^O01 → medOS imagingRequest specifically (today’s
orm-to-medos.mapper.tsroutes to genericdiagnostic.order.*— extension required) - Inbound: hospital RIS → ORU^R01 → medOS imagingResult specifically (today’s
oru-to-medos.mapper.tsis generic — extension required) - Configuration: per-tenant RIS endpoint URL/host/port, MLLP credentials, message-routing rules
- Audit log: every dispatched message, ack/nak response, retry attempt
Out of scope (deferred to FHIR phase or post-UAT):
- FHIR ImagingStudy reverse-mapper (post-HL7v2 stabilisation)
- Cross-facility PACS export (TOR §4.1.15 — defer per Q7)
- DICOMweb-federated study transfer (PACS-side, not RIS)
2. Architecture
┌─────────────────────────┐
medOS write event │ ImagingRequest │
(Mongo emits via NATS) ──────────▶│ service.ts (existing) │
└────────────┬────────────┘
│ Moleculer event
▼
┌──────────────────────────────────┐
│ ris-dispatcher.service.ts │
│ (NEW Moleculer service) │
│ Subscribes to: │
│ imagingRequest.created │
│ imagingRequest.cancelled │
│ imagingRequest.reported_* │
│ Composes HL7v2 message + sends │
└────────────────┬─────────────────┘
│
┌────────────────┴─────────────────┐
▼ ▼
┌─────────────────────────┐ ┌────────────────────────────┐
│ medos-to-orm.mapper.ts │ │ medos-to-oru.mapper.ts │
│ (NEW) │ │ (NEW) │
│ ImagingRequest → ORM │ │ ImagingResult → ORU │
└────────────┬────────────┘ └────────────┬───────────────┘
▼ ▼
┌───────────────────────────────────────┐
│ mllp.client.ts (NEW) │
│ Outbound MLLP TCP client (counter- │
│ part of existing mllp.listener.ts) │
└────────────┬──────────────────────────┘
│ TCP
▼
┌──────────────────────────┐
│ Hospital RIS │
│ (external, MLLP server) │
└──────────────────────────┘
Inbound flow (hospital RIS → medOS):
Hospital RIS ──ORM/ORU──▶ mllp.listener.ts (existing on port 2575)
│
▼
┌──────────────────────────────────┐
│ hl7v2-parser.service.ts │
│ (existing — produces │
│ Hl7ParsedMessage) │
└────────────────┬─────────────────┘
▼
┌──────────────────────────────────┐
│ orm-to-medos.mapper.ts (EXTEND) │
│ Add imagingRequest-specific │
│ routing branch beside the │
│ existing diagnostic.order.* │
│ generic path. Use placer order │
│ number prefix to distinguish │
│ imaging from lab. │
└────────────────┬─────────────────┘
▼
imagingRequest.create
/update/cancel via Moleculer
3. Files to add / extend
| File | Status | LOC est. | Purpose |
|---|---|---|---|
services/interoperability/.../modules/hl7v2/mllp.client.ts |
NEW | ~150 | Outbound TCP MLLP client. Frame messages with 0x0B / 0x1C / 0x0D bytes. Handle ACK/NAK responses. Configurable timeout, retry, exponential backoff. Lifecycle managed by the dispatcher service. |
services/interoperability/.../modules/hl7v2/mappers/medos-to-orm.mapper.ts |
NEW | ~200 | mapImagingRequestToOrm(req: ImagingRequest, control: 'NW'|'CA'|'XO'): string — composes the HL7v2 ORM^O01 message segment-by-segment (MSH, PID, PV1, ORC, OBR). |
services/interoperability/.../modules/hl7v2/mappers/medos-to-oru.mapper.ts |
NEW | ~150 | mapImagingResultToOru(req: ImagingRequest, result: ImagingResult): string — composes ORU^R01 with OBX result segments. |
services/interoperability/.../modules/hl7v2/mappers/orm-to-medos.mapper.ts |
EXTEND | +80 | Add imaging-routing branch: when placer order number prefix matches imaging conventions (e.g. IMG-, RAD-), dispatch to imagingRequest.create/update/cancel instead of generic diagnostic.order.*. |
services/interoperability/.../modules/hl7v2/mappers/oru-to-medos.mapper.ts |
EXTEND | +60 | Same pattern — imaging result routing branch. |
services/interoperability/.../modules/ris/ris-dispatcher.service.ts |
NEW | ~200 | Moleculer service subscribing to imagingRequest.* events; composes message via mappers; sends via mllp.client; logs to ris_dispatch_log table. |
services/interoperability/.../modules/ris/ris.module.ts |
NEW | ~30 | Module registration |
services/interoperability/.../modules/ris/ris-config.dto.ts |
NEW | ~50 | Per-tenant RIS endpoint config DTO |
services/interoperability/.../modules/hl7v2/__tests__/medos-to-orm.spec.ts |
NEW | ~150 | Unit tests for the outbound mapper |
services/interoperability/.../modules/hl7v2/__tests__/medos-to-oru.spec.ts |
NEW | ~120 | Unit tests for the outbound mapper |
services/interoperability/.../modules/ris/__tests__/ris-dispatcher.spec.ts |
NEW | ~150 | Unit tests for the dispatcher (mocked MLLP client) |
infrastructure/medbase/migrations/<date>_ris_dispatch_log.sql |
NEW | ~80 | ris_dispatch_log table — every outbound + inbound message audit-logged with status / error / retry-count |
infrastructure/medbase/migrations/<date>_ris_config.sql |
NEW | ~60 | Per-tenant RIS config table (endpoint, MLLP port, sending app, sending facility) |
Total estimate: ~1,500 LOC including tests (close to the upper bound of the 600-900 LOC estimate from the remaining-work doc — that estimate was implementation-only; tests bump it).
4. HL7v2 message templates (paste-ready for implementation)
4.1 Outbound ORM^O01 — New imaging order
MSH|^~\&|MEDOS|<sending_facility>|<receiving_app>|<receiving_facility>|<timestamp>||ORM^O01|<msg_id>|P|2.5
PID|1||<patient.hn>^^^MEDOS^MR||<patient.last_name>^<patient.first_name>||<patient.dob>|<patient.gender>
PV1|1|O|<encounter.location>||||<ordering_doctor.id>^<ordering_doctor.last_name>^<ordering_doctor.first_name>
ORC|NW|<imagingRequest._id>|||SC|<priority>|||<request.created_at>|||<requester.id>
OBR|1|<imagingRequest._id>||<modality.code>^<modality.name>^MEDOS|<priority>|<request.created_at>|||||A||<reason>||||<study.accession_number>
4.2 Outbound ORU^R01 — Final report
MSH|^~\&|MEDOS|<sending_facility>|<receiving_app>|<receiving_facility>|<timestamp>||ORU^R01|<msg_id>|P|2.5
PID|1||<patient.hn>^^^MEDOS^MR||<patient.last_name>^<patient.first_name>||<patient.dob>|<patient.gender>
OBR|1|<imagingRequest._id>||<modality.code>^<modality.name>^MEDOS||<request.created_at>|||||F||<reason>||||<study.accession_number>|||F
OBX|1|TX|<modality.code>^<modality.name>^MEDOS||<report.findings>|||N|||F
OBX|2|TX|<modality.code>^<modality.name>^MEDOS||<report.impression>|||N|||F
4.3 Cancel control
Reuse ORM^O01 with ORC|CA|<imagingRequest._id>... instead of NW.
5. Configuration model
CREATE TABLE IF NOT EXISTS ris_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
endpoint_host TEXT NOT NULL,
endpoint_port INT NOT NULL DEFAULT 2575,
sending_app TEXT NOT NULL DEFAULT 'MEDOS',
sending_facility TEXT NOT NULL,
receiving_app TEXT NOT NULL,
receiving_facility TEXT NOT NULL,
mllp_timeout_ms INT NOT NULL DEFAULT 30000,
retry_attempts INT NOT NULL DEFAULT 3,
retry_backoff_ms INT NOT NULL DEFAULT 1000,
encoding TEXT NOT NULL DEFAULT 'UTF-8',
hl7_version TEXT NOT NULL DEFAULT '2.5',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(tenant_id)
);
Admin UI: /admin/integrations/ris-config — admin-only CRUD, edits via stage-09 bug-036’s pattern.
6. Dispatch log (audit)
CREATE TABLE IF NOT EXISTS ris_dispatch_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
direction TEXT NOT NULL CHECK (direction IN ('outbound','inbound')),
imaging_request_id TEXT, -- mongo _id reference
message_type TEXT NOT NULL, -- 'ORM^O01', 'ORU^R01', 'ACK'
message_control_id TEXT NOT NULL, -- MSH-10
raw_message TEXT, -- the actual HL7v2 string sent/received
status TEXT NOT NULL CHECK (status IN ('pending','sent','acked','naked','timeout','error')),
ack_code TEXT, -- AA / AE / AR
error_message TEXT,
attempt INT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
responded_at TIMESTAMPTZ
);
CREATE INDEX idx_ris_dispatch_log_imaging_request ON ris_dispatch_log (imaging_request_id);
CREATE INDEX idx_ris_dispatch_log_status_pending ON ris_dispatch_log (status) WHERE status IN ('pending','error');
7. State machine (dispatch)
Order created in medOS
│
▼
ris_dispatch_log row inserted (status=pending)
│
▼
ris-dispatcher picks it up (Moleculer event subscriber)
│
▼
Compose HL7v2 ORM via medos-to-orm.mapper
│
▼
Send via mllp.client (TCP MLLP)
│
├──[ACK AA]──▶ status=acked, responded_at=now
├──[ACK AE]──▶ status=naked (application error)
├──[ACK AR]──▶ status=naked (rejected)
├──[timeout]──▶ status=timeout, retry up to retry_attempts
└──[exception]─▶ status=error, manual review
8. Open questions for the implementation loop
| # | Question | When | Default if no answer |
|---|---|---|---|
| RIS-Q1 | Does the partner RIS expect MLLP over TCP or HTTPS-wrapped? | Pre-implementation | Default to plain MLLP TCP (most common) |
| RIS-Q2 | Should outbound dispatch be sync (block on ACK) or async (fire-and-forget + reconcile via dispatch log)? | Stage-04 design | Async with reconcile is more robust |
| RIS-Q3 | Per-tenant config or global? Multi-region (japan, philippines) deployments need different RIS endpoints. | Stage-04 | Per-tenant (the schema above already assumes this) |
| RIS-Q4 | How to handle PV1 in outpatient (no admission/transfer)? | Mapper impl | Render with location only; bed/floor null |
| RIS-Q5 | Imaging “placer order number” prefix to distinguish from lab orders on inbound? | Mapper impl | IMG-<imagingRequest._id> for outbound; on inbound, match ^IMG- / ^RAD- to route to imagingRequest |
9. Module loop entry point
To resume / start the actual implementation, run a fresh module loop:
# In a fresh Claude session:
"Run the medOS module-implementation loop for the **ris-adapter** module
in interactive mode. Read docs/module-loop/00-MASTER.md and
docs/architecture/ris-adapter-design.md.
Pre-flight:
- Branch: stay on whatever the user has you on (don't switch).
- State file: docs/ris-adapter-loop/_progress.json (will be created
by stage 01 audit).
- Spec source: docs/architecture/ris-adapter-design.md (this file).
Stage 01 should audit the existing HL7v2 module
(services/interoperability/.../modules/hl7v2/) + the imagingRequest
backend module, then proceed through stages 02-10.
"
The implementation is sized at 1-2 days of focused work. The artefacts above pre-fill stages 02 (spec) and 03 (data-model) so the loop can move quickly through them.
10. Cross-references
- docs/architecture/nuclear-medicine-remaining-backend-work.md §1 — original deferral entry
- docs/nuclear-medicine-loop/09-bug-log.md — bug-016 + decision lock
- Existing HL7v2 module:
services/interoperability/src/api/interoperability/modules/hl7v2/ - Existing FHIR resources:
services/public-api/.../modules/fhir/resources/imagingStudy.controller.ts(the FHIR Subscription path) - AcknowledgementRequest entity: docs/architecture/acknowledgement-system.md
infrastructure/medbase/migrations/021_fhir_subscriptions.sql— when the FHIR Subscription phase starts