Early Access · beta. We're building toward 1.0 — and we want people involved now, while it can still be shaped. Where the project stands →

Architecture

How MessageFoundry fits together

Four views, each answering a different question: every shipped component and how it relates to the engine; the engine's internal packages and where the process boundaries sit; how a received message moves through the durable staged queue and earns a disposition; and how Connections, Routers, and Handlers wire together by name. The prose source of truth is the engine's ARCHITECTURE.md.

This architecture is real and running — but the project is young. MessageFoundry started in May 2026 and is in Early Access. See where it stands →

1 · The whole system

Top-level components

Beyond the engine itself, MessageFoundry ships as a set of independent, separately-built components — operator tools, the version-controlled config, the CLI and Windows service that run it, dev/test tooling, a standalone migration tee relay, and the CI-to-PyPI release path. Operator tools reach the engine only through the localhost API; the engine is the core, and everything else sits around it.

See the whole toolkit, up close →

Top-level components — the whole system A central headless engine, reached only through a localhost API, surrounded by independent components: operator tools (admin console, VS Code extension), version-controlled author-time config, the CLI and Windows service that run it, dev and test tooling, live HL7 traffic, a standalone migration tee relay, and the CI-to-PyPI release path. HTTP / WS loads config runs · MLLP MLLP / file shadow Operator tools Admin console · VS Code extension API — 127.0.0.1 auth · RBAC · only external surface Engine — headless asyncio pipeline · transports · parsing store · config · auth Store SQLite · PostgreSQL · SQL Server Author-time config Connections · Routers · Handlers + environments · version-controlled Run it · dev / test Windows service · CLI generators · test harness Live HL7 traffic Upstream senders ↕ downstream receivers Build / release CI: tests · SAST · SBOM · sign → PyPI · Trusted Publishing Migration — standalone tee relay parallel-run parity vs a legacy engine · own SQLite egress-suppressed shadow — imports no engine code
2 · Components & boundaries

System topology

The engine is a headless service; clients are separate processes that reach it only through the localhost API. Dependencies point one way — the API depends on the engine, the engine never imports the API or a client.

System topology The admin console, VS Code extension, and test harness reach the engine only through a localhost API with auth and RBAC. The headless engine's pipeline depends on transports, parsing, the store (SQLite, PostgreSQL, SQL Server), and config. depends on over the API / wire Admin console separate process VS Code extension authoring + ops Test harness synthetic traffic LOCALHOST · THE ONLY EXTERNAL SURFACE API — HTTP + WebSocket auth · RBAC · hash-chained audit ENGINE — HEADLESS ASYNCIO SERVICE Pipeline listener · router · transform · delivery Transports MLLP · TCP · HTTP SOAP · DB · files (SFTP/FTP/FTPS) Parsing HL7 v2 deep parse; JSON / XML / X12 routed Store SQLite · PostgreSQL · SQL Server AES-256-GCM Config Connections · Routers · Handlers
3 · Runtime message flow

The durable staged queue

A received message is ACKed on receipt — once it's durably committed to the ingress stage, before routing, transform, or delivery. Each handoff is a single committed transaction, giving reliable delivery, retries, and replay with no separate broker.

Runtime message flow through the staged queue An inbound message is decoded and parsed by the listener, committed to the ingress stage, and ACKed on receipt. It then moves through routed and outbound stages via committed transactions — router, transform, and delivery workers — before reaching the outbound connection. Each message earns a disposition finalized by the store. listener router transform deliver ACK (AA) — on receipt Inbound connection ingress raw committed routed one row / handler outbound one row / dest. Outbound idempotent send DISPOSITION · count-and-log, finalized by the store decode/validate failures NAK synchronously, pre-ingress RECEIVED → ROUTED / UNROUTED → PROCESSED / FILTERED / ERROR

Why nothing is lost. Every message is committed to the store before it's ACKed, and each stage handoff is a single transaction — so a crash can only ever repeat the last step, never skip it. In normal operation every message is delivered exactly once. The one edge case: if the engine crashes after a downstream send but before it records that success, on restart it re-sends that single in-flight message rather than risk dropping it. (Engineers call this "deliver again rather than lose" guarantee at-least-once — the deliberate opposite of at-most-once, which can silently lose clinical data.) That rare re-send is detectable, not noise: a re-delivered HL7 message carries the same MSH-10 control ID, so a downstream sees a retry of a known message — exactly where deduplication belongs.

How much one interface carries. Capacity is a measured property of this staged queue, not a separate claim. On anonymized, representative message traffic, a single inbound interface sustains a measured ~50 messages/second on commodity hardware — about 180,000 in a peak hour and on the order of 1.6 million a day — with every message committed to the durable store before it's ACKed and delivered strict per-interface FIFO. Whatever the rate, nothing is counted that isn't stored, and nothing is silently dropped.

Those day-level figures are sized to the busy hour, not a flat 24 hours: clinical ADT traffic is business-hours-bursty. In de-identified ADT traffic profiled from a live hospital interface feed, the busiest hour runs roughly 2.7× the daily average, at around 11.5 KB per message — so one interface moves on the order of 18 GB/day. Treat the message size as directionally right rather than a constant: it varies a great deal by feed, and messages carrying base64-encoded documents or images are far larger, which changes the storage and bandwidth arithmetic well before it changes the message rate. A single hospital's full ADT feed is only a few percent of one interface, so capacity is rarely the question for one site — and when it is, you scale horizontally: feeds split at the source across more interfaces, plus an optional multi-node tier. At that grain, 16 million messages a day is roughly 10 interfaces — distributed, not forced through a single pipe. Your real numbers track your transforms, storage, and hardware. How to read these throughput numbers →  ·  Throughput & capacity (PDF) ↓

4 · Config wiring

Connections, Routers, and Handlers — wired by name

Your configuration is a graph wired by name — a set of nodes connected by edges, like boxes joined by arrows in a flowchart. An inbound Connection names a Router; the Router forwards to Handler(s) by name; each Handler sends to outbound Connection(s). There's no enclosing "channel" object — set it up with guided wizards, or write it in Python.

Config wiring graph An inbound connection IB_ACME_ADT (MLLP) names a router. The router forwards by name to two handlers — to_EHR and to_archive — which Send to outbound connections OB_EHR_ADT (MLLP) and OB_ARCHIVE (File). names by name Send Send IB_ACME_ADT inbound · MLLP @router filters · forwards by name @handler: to_EHR filter → transform @handler: to_archive filter → transform OB_EHR_ADT outbound · MLLP OB_ARCHIVE outbound · File
Why wire it this way

A wiring diagram, not a stack of channels

Because the links are just names, each box is defined once and the arrows are cheap — what a bundled "channel" can't give you.

  • Define once, reuse anywhere — name a destination, a shared transform, or a router and reference it from anywhere, instead of copying it into every channel that needs it.
  • Rewiring is a one-line diff — add a destination with one more Send, or reroute a feed by changing a name. No "channel" surgery, and every change is a small version-controlled diff.
  • Mistakes caught at load — an unknown router, a dangling handler, a duplicate name or port are errors at check time, not silent surprises in production.
  • Each piece is small and testable — a Router or Handler is a tiny function with a clear contract, so your team (or an AI agent) can build and test parts in parallel.

See it run for yourself

From install to your first routed message — with the full feature set and the engine docs a click away.