Features
Receive HL7 messages, validate them before routing, persist to an outbox, and deliver to downstream systems with retries.
One binary. Receives HL7 v2 over MLLP, validates with CEL, routes to connectors, persists to disk. No message broker. No JVM. No runtime dependencies.
MLLP Protocol
Standard MLLP framing per the HL7 specification (Appendix C): 0x0B (Start Block) + payload + 0x1C 0x0D (End Block + Carriage Return). Leading garbage bytes before the start block are tolerated, as allowed by the spec.
| Parameter | Default | Env var |
|---|---|---|
| Max frame size | 2 MB | MAX_FRAME_SIZE |
| Idle timeout | 30s | IDLE_TIMEOUT |
| Frame timeout | 60s | FRAME_TIMEOUT |
| Write timeout | 5s | WRITE_TIMEOUT |
| Keep-alive period | 60s | KEEP_ALIVE_PERIOD |
| Max connections | unlimited | MAX_CONNECTIONS |
The idle and frame timeouts work together — whichever is shorter applies on each read. This protects against slow-loris attacks without breaking large message transfers. On shutdown, in-flight messages complete before the process exits.
CEL Validation
Rules are written in Common Expression Language and compiled at startup. A bad rule fails the startup, not a message at 3am.
Rules are evaluated in order. First failure short-circuits. The result maps to an HL7 ACK code:
| Result | ACK code | Meaning |
|---|---|---|
| All rules pass | AA | Accept — message routed to connectors |
| Rule returns false | AR | Reject — permanent, do not retry |
| Evaluation error | AE | Error — transient, sender may retry |
Available fields
Every rule can access parsed fields from four HL7 segments:
| Variable | Segment | Fields |
|---|---|---|
msh | MSH | msg_type, trigger, sending_app, sending_fac, receiving_app, receiving_fac, control_id, version |
pid | PID | id (PID-3.1), name (PID-5), dob (PID-7), sex (PID-8), ssn (PID-19), country (PID-11.6) |
pv1 | PV1 | patient_class (PV1-2), assigned_location (PV1-3), attending_doctor (PV1-7), admit_datetime (PV1-44) |
obx | OBX | First OBX only: value_type, identifier, value, unit, status |
obx_list | OBX | All OBX segments as a list, same fields as obx |
Missing segments return empty maps. Missing lists return empty lists. No null errors in expressions.
Example rules
validation:
rules:
- name: require-patient-id
expression: pid['id'] != ""
message: "PID-3.1 is required"
- name: require-sending-facility
expression: msh['sending_fac'] != ""
message: "MSH-4 sending facility is required"
- name: finnish-patients-only
expression: pid[?'country'].orValue("") == "FI"
message: "Only Finnish patients accepted at this endpoint"
- name: all-observations-final
expression: obx_list.all(o, o['status'] == "F")
message: "All OBX segments must have final status"
The ? operator provides safe key access — no error if the key is absent.
TLS and mTLS
Set TLS_CERT_FILE and TLS_KEY_FILE to enable TLS. Add TLS_CLIENT_CA to require and verify client certificates (mTLS).
Certificate hot-reload
Send SIGHUP to reload certificates from disk. No restart, no dropped connections. New connections use the updated certificate immediately. Existing connections continue on the previous one until they close naturally.
Cipher suites
TLS 1.3 uses safe defaults (not configurable — the runtime handles it). For TLS 1.2, the server enforces AEAD-only cipher suites:
| Cipher suite |
|---|
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 |
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 |
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 |
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 |
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 |
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 |
All suites use ECDHE key exchange. No static RSA. No CBC.
Handshake timeout
Stalled TLS handshakes are dropped after CONNECT_TIMEOUT (default 10s). Prevents resource exhaustion from clients that connect but never complete the handshake.
| Variable | Default | Description |
|---|---|---|
TLS_CERT_FILE | — | Path to PEM certificate |
TLS_KEY_FILE | — | Path to PEM private key |
TLS_CLIENT_CA | — | CA file for mTLS client verification |
TLS_MIN_VERSION | 1.2 | Minimum TLS version (1.2 or 1.3) |
CONNECT_TIMEOUT | 10s | TLS handshake timeout |
Connectors
Connectors deliver accepted messages to downstream systems. Each connector has its own delivery queue — connector latency does not affect MLLP throughput. The server ACKs the sender before any connector sees the message.
CEL-based routing
Each connector has an optional filter expression. Same CEL variables as validation. Connectors without a filter receive every message. Multiple connectors can match a single message — fan-out is atomic.
connectors:
- name: adt-processor
type: http
url: https://adt.example.com/hl7
filter: msh.msg_type == "ADT"
- name: all-messages-archive
type: http
url: https://archive.example.com/hl7
# no filter — receives everything
Retry with exponential backoff
Failed deliveries retry with exponential backoff and jitter:
| Parameter | Default |
|---|---|
| Initial delay | 1s |
| Max delay | 5m |
| Max attempts | 5 |
| Jitter | 0–25% |
After max_attempts failures, the message moves to the dead letter queue. Set dead_letter.disabled: true to retry indefinitely.
Environment variable interpolation
Connector config supports ${VAR_NAME} syntax. All referenced variables must be present at startup — a missing variable is a fatal error, not a silent empty string.
connectors:
- name: primary
type: http
url: ${DOWNSTREAM_URL}
headers:
Authorization: "Bearer ${API_TOKEN}"
Message Persistence
Every accepted message is written to an embedded database before the ACK is sent. The outbox survives process restarts. No external database required.
Each connector gets two queues: an outbox for pending deliveries and a DLQ for messages that exhausted retries.
When a message matches multiple connectors, all writes happen in a single transaction. Either every connector gets the message or none do. No partial delivery on crash.
Delivery order is FIFO per connector. Messages are delivered in the order they arrived.
Observability
Metrics
Set OTEL_EXPORTER_OTLP_ENDPOINT to export metrics via OpenTelemetry (OTLP over gRPC, every 10 seconds). When unset, metrics are disabled — no overhead, no infrastructure required.
| Metric | Type | Description |
|---|---|---|
mllp.connections.active | UpDownCounter | Current open connections |
mllp.connections.total | Counter | Total connections accepted |
mllp.messages.processed | Counter | Messages processed (by ack_code) |
mllp.message.duration | Histogram | Processing latency in seconds (by ack_code) |
mllp.bytes.received | Counter | Bytes received from clients |
mllp.bytes.sent | Counter | Bytes sent (ACKs) |
Logging
Operational logs are structured JSON on stdout. Each line carries version, instance_id, and hostname. Connection-scoped logs add remote_ip, connection_id, and tls_version. Message-scoped logs add message_control_id, message_type, and sending_facility.
Log writes are buffered — a slow disk does not block message processing.
Audit trail
Audit events (message_received, ack_sent, nak_sent) are written synchronously to stderr. Separate from operational logs so they can be routed to a SIEM independently. Synchronous writes guarantee ordering and durability.
Log rotation
Send SIGHUP to reopen the log file. Compatible with logrotate.
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL | info | debug, info, warn, error |
LOG_OUTPUT | stdout | stdout, file, console |
LOG_FILE_PATH | /var/log/mllp/mllp.log | File path when LOG_OUTPUT=file |
LOG_BUFFER_SIZE | 10000 | Log buffer capacity (entries) |
LOG_POLL_INTERVAL | 10ms | Buffer flush interval |
Signal Handling
| Signal | Effect |
|---|---|
SIGINT / SIGTERM | Graceful shutdown: stop accepting, drain in-flight, exit |
SIGHUP | Reload TLS certificates + rotate log file |
SIGUSR1 | Cycle log level at runtime |
A second SIGINT/SIGTERM forces an immediate exit.
PRE_SHUTDOWN_DELAY (default 0) inserts a wait before connection draining begins. Set to 3–10s in Kubernetes so the load balancer removes the pod from rotation before the server stops accepting.
| Variable | Default | Description |
|---|---|---|
SHUTDOWN_TIMEOUT | 30s | Max time to wait for connection drain |
PRE_SHUTDOWN_DELAY | 0 | Delay before drain (Kubernetes LB propagation) |
Configuration
No config file for the server itself. Every setting is an environment variable with a sensible default. Configure via Kubernetes ConfigMaps, Docker --env, or shell exports.
See the Configuration reference for the full list.