HL7 v2 validation gateway for hospital interface teams. Speaks MLLP, validates every message against configurable per-type profiles, ACK/NAKs with exact field positions, dead-letters rejects for replay, and converts clean ADT messages to FHIR R4.
- Hospitals run dozens of HL7 v2 feeds between ancillary systems and the EHR. When a lab system sends a DFT charge message with a non-numeric amount or a missing charge code, the worst outcome is not rejection, it is silence: the message dies in an interface engine, nobody is notified, and the charge never reaches billing. On the synthetic replay corpus below, rejected DFT messages carried $1.03M in charges; without a gateway those dollars are invisible until month-end reconciliation.
- InterfaceSentinel sits at the boundary. Every message gets parsed tolerantly, validated against a per-type profile (required segments, typed field checks, code sets, staleness, duplicates), and answered with a proper HL7 ACK: AA when clean, AE with the first error when invalid, AR when structurally unusable. Everything rejected lands whole in a dead-letter queue with a replay endpoint, so remediation is a workflow, not an archaeology dig.
- Validation rules are YAML configuration an interface analyst can edit, not code. Findings carry exact positions (FT1[2]-11) so the NAK tells the upstream team what to fix.
Data disclosure: every message in this repository is synthetic, produced by a seeded generator in
datagen/. No real patient, provider, or payer data appears anywhere. All numbers below were produced by scripts committed in this repo, and raw outputs are committed beside them.
flowchart LR
A[Ancillary systems<br/>LIS / RIS / ADT feeds] -- "MLLP :2575" --> B[Asyncio listener<br/>frame reassembly]
T[Test / ops clients] -- "HTTP :8100" --> P
B --> P[Pipeline]
P --> C{Parse}
C -- no MSH --> AR[ACK AR] & DL[(dead_letter)]
C -- ok --> V[Profile validator<br/>YAML per message type]
V -- errors --> AE[ACK AE + position] & DL
V -- clean/warnings --> AA[ACK AA]
AA & AE --> DB[(PostgreSQL<br/>messages + findings)]
DL -- "fix upstream, POST /replay" --> P
AA -. "ADT only" .-> F[FHIR R4 mapper<br/>Patient + Encounter]
DB --> SQL[SQL views] --> BI[Power BI<br/>interface health dashboard]
| Technology | Why it is here |
|---|---|
| Python 3.12 asyncio | MLLP frames arrive split across arbitrary TCP reads; stream buffering is the natural fit. See ADR-0002 |
| Purpose-built HL7 parser | Triage requires parsing past errors and reporting exact positions; strict library parsers throw where this gateway must describe. See ADR-0001 |
| YAML validation profiles | Interface contracts change per site and per feed; analysts edit configuration, deployments stay out of the loop |
| FastAPI | Ops surface: dead-letter listing and replay, metrics, stateless FHIR conversion; shares one Pipeline with MLLP so transports cannot drift |
| FHIR R4 mapping (PID/PV1) | Downstream analytics ask for Patient and Encounter first; deliberately narrow. See ADR-0003 |
| PostgreSQL (SQLAlchemy) | Message + findings commit atomically; SQLite serves local runs and CI with zero setup |
| structlog | One JSON line per message with control ID, status, and latency |
git clone <repo> && cd interface-sentinel
pip install -r requirements.txt -r requirements-dev.txt
PYTHONPATH=. uvicorn app.main:app --port 8100 # MLLP starts on 2575 in-processOr with Postgres: docker compose up --build
Send a deliberately broken charge message over HTTP (same pipeline as MLLP):
curl -s -X POST localhost:8100/messages -H "Content-Type: application/json" -d '{
"message": "MSH|^~\\&|LAB_SYS|FACILITY_A|SENTINEL|GATEWAY|20260716083000||DFT^P03^DFT_P03|C1001|P|2.5.1\rEVN|P03|20260716083000\rPID|1||MRN445566^^^SENTINEL^MR||LOPEZ^CARLOS||19701104|M\rPV1|1|O|ED^EMERGENCY\rFT1|1|||20260716||CG||||1|12O.50|||||ED^EMERGENCY"}'The ACK comes back MSA|AE|C1001 citing the first finding; GET /messages/{id} shows both: FIELD_MISSING at FT1[1]-7 (no charge code) and FIELD_FORMAT at FT1[1]-11 (12O.50 has a letter O, a classic keying error). GET /dead-letter lists it for replay after the upstream fix. Update the MSH-7 timestamp if you are running this more than 7 days after the date shown, or the staleness check will fire too, which is rather the point.
Tests: PYTHONPATH=. pytest tests/ -v (25 tests, including real MLLP round-trips over TCP against an ephemeral-port listener).
The replay harness (benchmark/effectiveness_replay.py) runs a labeled corpus with a known defect taxonomy through the gateway and scores detection per category. Raw output: benchmark/effectiveness_report.json.
| Injected defect category | Injected | Caught | Recall |
|---|---|---|---|
| FIELD_MISSING (MRN, charge code, control ID) | 568 | 568 | 100% |
| FIELD_FORMAT (malformed DOB, non-numeric amount) | 343 | 343 | 100% |
| CODESET (invalid patient class, transaction type) | 319 | 319 | 100% |
| STALE (replayed feed, 10 to 60 days old) | 250 | 250 | 100% |
| STRUCT (no MSH segment) | 240 | 240 | 100% |
| SEG_MISSING (dropped PV1/OBR) | 239 | 239 | 100% |
| False rejects on 18,041 clean messages | 0 | 0.0% |
An honest note on those numbers: deterministic validation against a defect taxonomy it was built to catch should score 100%, and a miss would be a bug, not a shortfall. So this replay is framed as what it is: a regression harness at corpus scale, and CI runs it as a merge gate (recall below 100% or any false reject fails the build). The claim is not "catches everything wrong with HL7"; it is "provably catches its documented edit catalog, and provably does not reject clean traffic."
MLLP round-trip (send framed message, wait for framed ACK) over persistent connections, single process, SQLite backend, 4 vCPU container. HL7 senders serialize on ACKs, so realistic concurrency is a handful of feed connections, not hundreds. Reproduce with benchmark/mllp_load_test.py; raw output: benchmark/results.json.
xychart-beta
title "MLLP ACK round-trip latency (ms, 2000 messages per stage)"
x-axis "persistent connections" [1, 2, 4, 8]
y-axis "latency (ms)" 0 --> 420
line "p50" [5.4, 4.62, 4.77, 19.97]
line "p95" [7.2, 7.74, 61.12, 92.55]
line "p99" [8.9, 110.17, 336.65, 386.27]
| Connections | Throughput | p50 | p95 | p99 |
|---|---|---|---|---|
| 1 | 177.7 msg/s | 5.40 ms | 7.20 ms | 8.90 ms |
| 2 | 205.6 msg/s | 4.62 ms | 7.74 ms | 110.17 ms |
| 4 | 204.4 msg/s | 4.77 ms | 61.12 ms | 336.65 ms |
| 8 | 190.1 msg/s | 19.97 ms | 92.55 ms | 386.27 ms |
Reading it honestly: throughput is flat past two connections because the pipeline is synchronous and SQLite serializes writes, so added connections buy queueing, not parallelism; the Postgres deployment relieves the write lock. The absolute numbers are the real story: a busy hospital's combined ADT, lab, and charge feeds run single-digit messages per second, and one process sustains about 200, roughly 40x headroom, before anyone tunes anything.
analytics/sql/views.sql ships five Power BI-facing views: hourly interface health by sending system, error taxonomy by code and position, rejected-DFT charge exposure in dollars (the revenue cycle number), the dead-letter remediation queue with aging, and duplicate pressure by sender (repeated control IDs usually mean an upstream system replaying after restarts).
- No PHI exists here; the corpus is synthetic by construction. Deployed against real feeds this is a HIPAA system: TLS-wrapped MLLP (or stunnel/VPN as most sites actually do), Postgres encryption at rest, and access to raw messages restricted, since dead-lettered messages contain full PHI by nature.
- Configuration and credentials arrive via environment variables (
IS_prefix); the compose password is marked local-only and deployed credentials come from a platform secrets manager. - Structured logs carry control IDs, types, and finding codes, never patient names or DOBs, so log aggregation does not become a shadow PHI store.
| Failure | Behavior |
|---|---|
| Structurally unusable message (no MSH) | ACK AR; raw bytes preserved in dead_letter; nothing silently dropped |
| Validation errors | ACK AE citing the first error with position; message stored with all findings AND dead-lettered whole for replay |
| Duplicate control ID | ACK AE with DUP finding; guards against upstream replays double-posting charges |
| Stale message (interface restart replays old feed) | ACK AE with STALE finding; window configurable via IS_MAX_MESSAGE_AGE_DAYS |
| Database down | Pipeline raises, no ACK is sent, sender retries per MLLP convention; a message is never ACKed without a committed row |
| Partial TCP frames | Buffered until the frame completes; multiple messages per read handled |
- Per-message pipeline timeout. A poison message stalls only its own connection today (senders serialize on ACKs), but a watchdog belongs in v1.1.
- FHIR ChargeItem mapping for DFTs, the obvious next resource given the billing focus (ADR-0003 explains the narrow start).
- Z-segment profile support and repetition-aware field checks; the checker seam exists, the demand does not yet.
- Enhanced-mode acknowledgments (commit-level ACKs); original mode covers the feeds this models.
- TLS termination for MLLP and mutual auth per sending system.
- Move duplicate detection into a database unique constraint for multi-instance deployments (ADR-0002, consequences).
- ChargeItem mapping plus a persisted FHIR store for longitudinal queries.
- Wire
vw_rejected_charge_exposureinto a daily Power BI subscription to revenue cycle leadership: dollars blocked at the interface, by sender, by day.
app/hl7/ parser + MLLP listener
app/validation/ profile-driven engine (the edit catalog lives in config/)
app/fhir/ ADT to FHIR R4 mapping
app/pipeline.py one pipeline, both transports
config/ profiles.yaml: the analyst-editable surface
datagen/ synthetic HL7 generator (seeded, labeled defect taxonomy)
benchmark/ MLLP load test + effectiveness replay, raw results committed
analytics/sql/ Power BI-facing views
docs/adr/ three architecture decision records
tests/ 25 unit + integration tests (real TCP MLLP round-trips)