1 Introduction

Perfuse moves clinical messages between systems that were never designed to talk to each other. A laboratory sends results in HL7 v2; the practice management system expects them with different field positions, a different patient identifier and an acknowledgement within five seconds. Something has to sit in the middle, and that something is an integration engine.

This manual describes what Perfuse does, every option it accepts, and — where a decision is not obvious — why it works the way it does.

1.1 What Perfuse is for

A single interface between two clinical systems is commonly costed in the tens of thousands, with fifteen to twenty per cent of that again every year in maintenance. Much of that cost is not the work; it is that the work requires somebody who has done HL7 before, and most practices do not employ one.

Perfuse is built on the assumption that the person configuring it has a problem to solve and no prior HL7 experience. That assumption shows up in specific places:

The chapter on testing and the one on migration exist because the answer to "why should I trust this" has to be evidence you gather yourself, not a claim made here.

1.2 What is in it

Everything below is in the one binary. Each line names the chapter that covers it.

NCPDP Telecommunication and SCRIPT, SMART Health Links and Cards, delimited and raw. See message formats.

MQTT, IBM MQ, and AWS, Azure and Google Cloud storage and messaging. See the source reference and the destination reference.

mapping tables and code sets, and mapping suggestions. See filtering and transformation.

sent after delivery. See delivery and acknowledgements.

finds a patient's messages across formats. See alerting, the message store and debugging.

migration.

decision service, eCR and ELR. Da Vinci CRD, DTR and PAS answer both their 2.2.x and their 2.1.0 releases. See CMS-0057.

tenants, a SMART authorization server, TEFCA and UDAP, and an audit log. See security.

operations, the web interface, the command line and server options.

1.3 Standards and versions

Which release of each standard Perfuse implements, and how that was checked. "Checked" names the outside tool or reference that judged the result; where nothing outside has judged it, it says so. Results are from test data, not from a production site.

1.3.1 Messaging and documents

StandardReleaseChecked
HL7 v2any version: messages are read by their own separators, not a version's schema; acknowledgements default to 2.5.1NIST HL7 v2 validator; real Mirth and OIE traffic
HL7 v2 to FHIRHL7's V2-to-FHIR mapping (ADT, ORU, SIU, MDM, VXU and the segments in them)the HL7 FHIR validator against US Core, UK Core, FR Core, ISiK, nl-core and HL7 Europe
FHIRR4 (4.0.1), with R4B and R5 read and writtenthe HL7 FHIR validator; Inferno
C-CDA and CDAC-CDA 2.1ONC SITE (C-CDA samples sent and received over XDR); not by a CDA schematron
HL7 v3PIX and PDQ (IHE ITI-44, 45, 47)IHE's NE2008 schemas and sample messages
DICOM and DICOMwebDIMSE C-STORE, C-FIND, C-MOVE, C-GET, C-ECHO; QIDO-RS, WADO-RS, WADO-URI, STOW-RSOrthanc
X12 (HIPAA)005010: 837, 835, 270/271 (X279A1), 276/277 (X212), 834, 278 request (X217) and inquiry (X215); 275 in 006020 (X314)payers' published companion guides; CMS esMD samples
CAQH CORE connectivityvC2.2.0 SOAP and MIME, vC4.0.0 with MTOMa stand-in UM system written from CAQH's rules
NCPDPTelecommunication D.0; SCRIPT 2023011 (and 2017071) with ePA; Real-Time Prescription Benefit v13written from the public Surescripts companion guides and their examples; not against NCPDP's licensed schemas
UN/EDIFACTsyntax 3 and 4, CONTRLpydifact 0.1.8
IHE document sharingXDS.b, XCA, XDR, MHD 4.2.2, PIXm, PDQm, XDMthe NIST XDS Toolkit; ONC SITE
Direct secure messagingApplicability Statement 1.2, XDR and XDM for Direct, DNS and LDAP certificate discoveryONC SITE; ONC's Certificate Discovery Tool (18 of 18)
AS2RFC 4130, compression RFC 5402pyas2lib
Signed attachmentsHL7 DSDR, XAdES-X-Lxmlsec1, OpenSSL

1.3.2 FHIR implementation guides

GuideReleaseChecked
US Coreconversions to 9.0.0 (USCDI v6); FHIR server against 6.1.0 and 7.0.0the HL7 validator; Inferno US Core
SMART App Launch2.2Inferno: 80 of 80 (public app)
Bulk Data Access2.0.0Inferno: 48 of 48
Subscriptions R5 Backportfor R4a real HTTP receiver; the PAS kit's notification tests
Da Vinci CRD2.2.1, and 2.1.0Inferno 2.2.1: 92 pass, none fail; 2.1.0 by the guide's own examples
Da Vinci DTR2.2.0, and 2.1.0Inferno 2.2.0: 43 pass, 2 fail (a kit defect); 2.1.0 by the guide's own examples
Da Vinci PAS2.2.1, and 2.1.0Inferno 2.2.1: 82 of 84; 2.1.0 by the guide's own examples
Da Vinci PAS, sending (provider side)2.2.1the HL7 Da Vinci PAS payer reference implementation; not a production payer
Da Vinci PDex2.2.0Inferno PDex 2.0: payer-to-payer 19 of 19
Da Vinci HRex $member-match1.2.0Inferno PDex payer-to-payer
Da Vinci ATR2.1.0not checked by an outside tool
Da Vinci CDex $submit-attachment2.1not checked by an outside tool
Da Vinci Plan-Net1.1.0Inferno: 199 pass, 10 fail (a kit defect)
Da Vinci US Drug Formulary2.1.0the HL7 validator
ISiK (German hospitals), FHIR serverStufe 5, Basis modulegematik's ISiK test suite 3.0.9: 421 of 421
PACIO Transitions of Care, Medication Profile, Advance Directives, Functioning1.0.0, 1.0.0, 2.0.0-ballot, 2.0.0PACIO's Inferno kits with PACIO's own data: every failure in its data or the kit. C-CDA to TOC document: the HL7 validator on HL7's examples, one error left, the guide's own
CARIN Blue Button2.2.0the HL7 validator
eCR2.1.2the HL7 validator
ELRHL7 2.5.1 ELR Release 1 with its errataONC's ELR tool: 28 of 28
International Patient Summary2.0.0the HL7 validator
DEQM5.0.0the HL7 validator
SQL on FHIRv2the official test suite: 144 of 144
SMART Health Cards and LinksCards 1.4, Links STU 1the specifications' own example card; a published SMART Health Links viewer library
UDAP SecuritySTU 1a real authorization server and trust community
TEFCA Facilitated FHIRSOP v2.0not checked against a real QHIN

1.3.3 National and European profiles

Checked by converting published or invented HL7 v2 messages and validating every resource; the full results and the reasons for each failure are in the repository's conformance/national folder.

ProfileReleaseResources valid
UK CoreSTU2 2.0.2185 of 187
FR Core2.2.0138 of 156
ISiK Basismodul4.0.3 (Stufe 4)18 of 47; 42 of 47 with the hospital's own bundle script
Nictiz nl-core0.12.1-beta.1 (zib 2020)12 of 24; 18 of 24 with the hospital's own bundle script
HL7 Europe Laboratory Report2.1.0106 of 426 (the guide expects a document a v2 result is not)
HL7 Europe Hospital Discharge Report1.0.068 of 69

None of this is a certification. Perfuse has not been certified by ONC, CMS, gematik, Nictiz, NHS England or anyone else.

1.4 What it replaces

Perfuse covers the ground held by Mirth Connect, and can import Mirth channel exports directly. The chapter on migration describes what translates cleanly, what needs a decision, and how to prove the result matches before cutting over.

The differences that matter in daily use are debugging and silence. In Mirth, seeing what a transformer did to a message generally means redeploying the channel with logging added. In Perfuse, any recorded message can be replayed through the current configuration and inspected step by step, without touching the running channel. And where a Mirth alert on "no traffic" is a fixed threshold that is useless on any feed that is quiet at night, Perfuse learns what each feed's normal week looks like and alerts against that.

1.5 How to read this manual

The first chapters are conceptual and are worth reading in order: channels, message flow, then transformation. Everything after that can be read as needed.

The reference chapters near the end list every configuration key, its type, whether it is required and its default. Those chapters are generated from the source, so they cannot describe an option that does not exist or omit one that does. If a key appears in a configuration file and not in the index, it is not a Perfuse key.

1.6 Conventions

Configuration is YAML. Most keys are snake_case on the wire — max_message_size, idle_timeout — and this manual always gives the wire form, because that is what you type. There is one long-standing exception, dataType on a channel, which is camelCase; it is documented that way in the channel reference and accepted only in that spelling.

Field references in HL7 use the usual notation: PID-5 is the fifth field of the PID segment, PID-5.1 its first component, and PID-3(2) the second repetition of the third field. A path written without a repetition addresses every repetition (see paths).

Durations are written as a number and a unit — 30s, 5m, 2h. A bare number is not a duration and will be refused rather than guessed at.

2 Getting Started

Perfuse is a single binary with an embedded web interface. There is no application server to install, no database to provision and no separate administration console.

2.1 Running it

perfuse serve -channels ./channels -db ./perfuse.db -addr 0.0.0.0:8443 \
  -tls-cert ./cert.pem -tls-key ./key.pem

-channels is a directory of YAML files, one per channel. -db is a SQLite file holding recorded messages, users and audit history; it is created if it does not exist.

On first start with no users configured, an administrator account is created and its password written to the log once. That password is not recoverable afterwards.

Running without -tls-cert and -tls-key serves plain HTTP. That is acceptable on a loopback address for a first look and is not acceptable anywhere else: the web interface carries credentials and message content, both of which are readable on the wire without TLS. See security.

2.2 The first channel

A channel needs a name, a source and at least one destination. The smallest useful one receives HL7 over MLLP and writes each message to a file:

name: first-feed
dataType: hl7
source:
  type: mllp
  listen: "0.0.0.0:6661"
destinations:
  - name: to-disk
    type: file
    file:
      root: /var/spool/perfuse
      dir: incoming

Save that as channels/first-feed.yaml and it is picked up without a restart.

Three things about that file are worth noticing, because they are the shape of every channel:

2.3 Building a channel from the interface

Everything above can be done from the web interface instead, and for a first channel that is the better route because the form will not let you produce a file that does not load.

The builder offers three ways in:

  1. From a message somebody sent you. Paste the sample the lab or vendor emailed. Perfuse reads it and proposes a channel, along with three separate lists: what the sample shows, what has been guessed, and what no sample can tell you. This is described in testing.
  2. From something that already works. A set of complete channels that need only their addresses filled in.
  3. From nothing. A blank form. Available, but it is the slowest of the three and the easiest to get wrong.

2.4 Verifying it works before connecting anything

Two things can be done before the sending system exists.

Send a message by hand from the interface — the message sender accepts pasted text and reports what happened to it, including which destinations accepted it and what each transformation did.

Or generate test traffic. If the channel has handled any messages at all, Perfuse can learn their shape and produce more messages like them, in the same proportions, with none of the original content. See testing.

2.5 What to do next

3 The Web Interface

Perfuse is built on the assumption that everything is doable from the browser, and a feature that can only be reached by editing a file is treated as a defect. This chapter is a tour of what is behind each part of the navigation, organised the way the interface is: by the question you are asking.

The bar carries Dashboard and Channels directly, because those are where most sessions start and end. Everything else is grouped, and the groups are named after questions rather than after subsystems — you do not go to "the audit subsystem", you go and find out who changed something.

3.1 Reading the header

Five things in the header are easy to miss.

Every view has an address. The address bar follows the view — #/channels, #/messages, #/settings — so a reload returns to it, a bookmark opens it, a link sent to a colleague lands them on it, and the back button moves between views rather than leaving the console. An address naming a view above your role opens the dashboard instead.

Search, or ⌘K. It searches across channels, settings, messages and the manual, which means it is usually faster than navigating — particularly for a setting whose name you half remember.

Theme. Three of them: Midnight, Dark, and Light. Midnight is near-black and is what a wall display in a server room usually wants; Dark is a softer neutral grey; Light is for a laptop next to a window. The choice is stored in the browser rather than against the account, because a theme is a property of where somebody is sitting, not of who they are — the same operator wants Midnight on the wall and Light on a train. A machine's own preference is consulted once, to pick a sensible default the first time.

Language. The console in English (UK or US date order), German, French, Dutch, Spanish, Italian, Portuguese, Polish, Swedish, Danish, Norwegian or Finnish, with dates and numbers written the way that locale writes them. The choice is kept in the browser, and the browser's own language is used until one is made. The translations were made by machine and have not yet been reviewed by native speakers; English is the reference, and the picker says so. Code, messages, payloads, channel names and anything typed are never translated. The manual is published in the same languages at https://perfuse.health/manual/<language>/ (this server's own Manual is the English one).

Manual. The document you are reading, served by the running server. It is the same file the binary can write to disk, so it always describes the version in front of you rather than the latest release.

3.2 Monitor — whether it is working, and what happened

3.2.1 Dashboard

The state of every channel, throughput, and anything currently wrong. This is the screen to leave open.

3.2.2 Messages

Every message that arrived, what it was transformed into, per destination, and what happened to it. Searchable, including by a filter expression, so "show me the ones from this sender that were rejected" is a query rather than an afternoon.

A message can be replayed from here, and reprocessed through the *current* configuration rather than the one that was live at the time — which is how you confirm a fix works against the traffic that broke it. See the message store for what is kept and for how long.

3.2.3 Queue

What is waiting to be delivered, and why. A queue that is not draining is the single most useful early warning an integration engine produces, and it is worth pairing with an alert rule so nobody has to be watching. See delivery.

3.2.4 Alerts

What has fired and what the rules are. Rules are edited here rather than in a file. See alerting.

3.2.5 Metrics

Throughput, latency and error rates over time. Useful for capacity questions and, more often, for board packs — which is why the site's own logo appears on it if one has been uploaded.

3.2.6 SQL on FHIR

SQL on FHIR views: pick an example or a saved view, or write one, run it over this server's FHIR store (all patients or one) or over resources pasted in, see the rows with their types, and download CSV, NDJSON or Parquet. Save keeps it for the FHIR endpoint's $sql-run. See message formats.

3.2.7 Quality measures

Digital quality measures: pick an example or a Measure in the FHIR store, or write one with FHIRPath criteria. Choose a measurement period and a report (summary, summary with patient lists, or one patient) and evaluate it over the FHIR store or pasted resources. The screen shows each population's count and patients, the score, and the strata. Download MeasureReport saves the FHIR result, and Save to the FHIR store keeps the Measure for Measure/$evaluate-measure. Measures in CQL are refused: Perfuse has no CQL engine. See digital quality measures.

3.2.8 Flow map

A picture of how messages actually move: which sources feed which channels, and which destinations they reach.

Its value is not decoration. Every estate accumulates a channel nobody remembers commissioning and a destination nobody realised was still receiving, and both are visible here in a way they are not in a list of channel files.

3.2.9 Team dashboards

A dashboard for each kind of person who watches the interfaces: Operations, Connections, Interface analyst, Prior authorization, Imaging, Revenue cycle, Laboratory, Privacy and security, and a plain-words Department summary. Tiles show counts, states and times, never a message or a patient, so a dashboard can go to a manager or a wall screen without widening who sees patient data.

Each person opens on the dashboard their directory group or role is given. -dashboards dashboards.yaml maps them:

groups: {PACS-Admins: imaging, Network-Team: connections}
roles: {viewer: manager}

A group wins over a role, and anyone unmatched opens on Operations. Arrange tiles hides and reorders tiles, the channel picker filters them, and Save as my view keeps that as your own dashboard until Reset.

Export to Grafana downloads the dashboard as Grafana JSON over /metrics, with a data source variable and a channel filter, for anyone who needs more than light configuration. Tiles with no metric behind them are left out, and the export's description names them.

The Connections tile checks every networked destination in layers. It looks the name up, opens a TCP connection, completes TLS where it is used, and reads the destination's last delivery and failure from real traffic. Each failure says what it means, for example "refused: the partner's engine is probably stopped", "timed out: a firewall or a VPN tunnel that is down", or "a certificate from an authority this server does not trust". Nothing is sent to the partner, with one exception: an immunization registry that has had no traffic for seven days is asked its CDC connectivityTest, which submits nothing. With -vpn tunnels.yaml the VPN tunnels tile beside it reads each partner tunnel's state from AWS Site-to-Site VPN, an Azure VPN Gateway connection or strongSwan on this host, so a time-out can be told apart from a tunnel that is down. It also compares both sides' IKE and IPsec settings, as the cloud reports ours and the file lists theirs, and says where they cannot agree. Connection sheet downloads a partner's settings side by side as Markdown to send them; it never holds a pre-shared key. Figures a dashboard needs that are not recorded yet are listed under Not measured yet instead of being drawn as empty tiles.

The department dashboards compute their own figures on the server, and each one says what window and source it covers:

and overdue now, with the median time to decide. Also how the services asked for were answered (approved, modified, denied, pended), as each stands now, over 30 days.

and the 95th percentile), C-FIND queries and failures per query channel, and imaging reports (an ORU whose OBR-24 is an imaging section, or an MDM) that did not reach every destination.

(A3, A4, A6, A7, A8). Also 835 claims matched by patient account number to 837s sent through this server, and the commonest rejection reasons.

AA, > or <) that did not reach every destination.

the audit log (opening a message is audited as message.read). Also each live API token and when it was last used; a token unused for 90 days is flagged.

3.2.10 Sharing what a feed actually contains

Profile this feed reports what is really arriving: which trigger events, which fields are populated and how often, what shapes the values take, and which codes a sender actually uses. That last one is usually the most useful single fact in it.

A profile can be downloaded and sent to somebody else. It needs a name and — required — which system produced the feed, because the question a stranger asks of a shared profile is whether it describes their system, and an optional field would make "Unknown" the commonest answer in any shared collection.

It carries no patient data, and that is checked rather than assumed. A profile is statistical: counts, rates, lengths, shapes. Values are kept only for fields the standard defines as code tables, because a code table value is not identifying. Identifier values are never listed.

Two things follow from that being a promise rather than a hope. The server rebuilds the profile from the stored messages when you export it, rather than sending the one on your screen — so what leaves the building was assembled by the profiler, which has a test proving this property, not by a browser. And the export refuses outright if a profile ever does list values for a field that is not a code table, naming the field but not the values.

A profile that arrives from elsewhere is checked the same way on the way in, for a different reason: it was produced by somebody whose care is unknown, and showing their patients' identifiers would be this server's disclosure. Importing one changes nothing — a profile describes a feed, and what to do about it is a separate decision.

3.3 Build — change what a channel does, and try it before it is live

3.3.1 Channels

The channel builder. Sources, destinations, filters, transformations, scripts and tests, with the generated YAML shown beside the form as you work — so the file is never a mystery, and anybody who prefers the file can read along.

See channels for what the parts are, and transformation for what the steps do.

3.3.2 Scripts

A workbench for scripts, separate from the builder because writing a script and wiring a script are different activities.

All seven slots are offered — filter, transformer, preprocessor, postprocessor, writer, lifecycle and reader — and choosing the right one matters, because the slot decides what the script receives and what it is expected to return. Checking a script as the wrong kind is not a workaround; it compiles a different thing.

JavaScript, Lua and WebAssembly are all available. See transformation.

3.3.3 Contracts

Expectations about what a feed must look like, created and checked here as well as from perfuse contract. See the command line.

3.3.4 Tables

Lookup tables and code sets: the mappings that turn one system's codes into another's. Editable here so that adding a code is not a deployment.

3.3.5 AI Mapper

Proposes field mappings with a confidence score for each, and abstains rather than guessing. Despite the name it uses no language model and makes no network call: the suggestions come from rules, field names and the sample's contents, on this server. It also proposes a channel from a single sample message, and states explicitly what the sample cannot tell it. That second half is the part that makes it trustworthy — a proposal that looked complete would invite you to believe things the message never said.

3.3.6 Playground

A scratchpad for messages and transformations. Paste a message, apply steps, see the result.

Nothing is sent anywhere once the page has loaded, which is the reason it exists in this form: it is safe to paste something real into, on the understanding that it stays in the browser.

3.3.7 Shadow

Runs traffic through a candidate configuration alongside the live one and reports the differences, so a change can be evidenced before it is trusted. The same idea as perfuse compare, applied to a change rather than to a migration. See testing.

3.4 Exchange — the standards this server speaks to other organisations

3.4.1 FHIR lab

Convert HL7 v2 to FHIR, validate a resource, and inspect the result. The samples cover admissions, lab results, appointments, clinical notes, immunizations and a reportable visit. The validation includes US Core, which is the profile American regulation is written against, so this answers "is what they sent us actually conformant" without adopting anything.

Public health case report builds the eCR eICR a case reporting destination would send for the message. It shows which trigger code made the visit reportable, what the report had to mark as missing, and the document itself. A message with nothing reportable gets the reason instead. Nothing is sent from here.

3.4.2 Subscriptions

FHIR subscriptions and whether their notifications are arriving: events, accepted, waiting, and the last reason one was refused. Subscriptions are created by FHIR clients through the FHIR API, not from this page, and the credentials they gave the server are never shown. See message formats.

3.4.3 Documents

Clinical documents — CDA and the printable form of a document. Useful when the thing being exchanged is a discharge summary rather than a message.

3.4.4 Document sharing

The IHE XDS.b registry and repository started with -xds: how many documents, patients and submission sets it holds and how many were replaced; the addresses of the repository, registry, XCA gateway and MHD; a search for a patient's documents in this registry or another community's (an ITI-18 registry or an ITI-38 gateway, with a token for it); and publishing a CDA document by hand, with the facility type and practice setting a document does not say. See message formats.

3.4.5 Direct

Direct secure messaging. This server's Direct addresses lists every address a channel sends or receives as, with its certificate, when it expires, the anchors it trusts, and anything wrong (a missing file, an expired certificate). The Address book keeps correspondents and the certificates their mail is encrypted to: paste one from their HISP, or Save with the certificate from DNS; a certificate that is not for the address or its domain is refused. Find a certificate in DNS asks without saving. See message formats.

SMART Health Links and Cards, for CMS's *Kill the Clipboard*. Receive a SMART Health Link reads the link or QR code a patient shows at check-in, verifies the health cards inside and sends it on to a channel. Share a visit record makes a link this server hosts, optionally with a passcode, and a QR code for the patient; Use their patient summary fills it with the patient's International Patient Summary from this server's FHIR store. Links this server hosts lists them with how often each was fetched, when it expires and the passcode tries left, and revokes one. Verify a SMART Health Card checks a card's signature and issuer on its own. See CMS-0057 for the server options it needs.

3.4.7 Imaging

The archive this server keeps when it runs with -dicomweb-archive: counts of studies, series, instances and space used, the DICOMweb address and the data-source block for an OHIF viewer, and a study search by patient name, ID, accession and date made with the same QIDO-RS request a viewer makes. A study opens to its series and instances, and an instance downloads as a DICOM file. Nothing is rendered: Perfuse moves images and a viewer displays them. Without an archive the screen says how to turn one on. See DICOMweb.

3.4.8 TEFCA

The audit trail for exchange through a Qualified Health Information Network, and the purpose-of-use rules that govern it.

Two transports, at genuinely different stages, and the screen says which is which. One flag covering both would be wrong in whichever direction it was rounded.

Facilitated FHIR is implemented, and has never spoken to a real QHIN. It follows the Sequoia Project Standard Operating Procedure effective 8 March 2026: discover the partner's UDAP metadata, verify it, register dynamically, obtain an access token carrying the purpose of use, and query. UDAP is a public key infrastructure over OAuth 2.0 — a trust community issues X.509 certificates to its members, and a member authenticates by signing JWTs with the private key and attaching the certificate chain.

The security layer is verified against somebody else's implementation, which is the only verification worth anything here. A registration signed by this build was sent to a live third-party reference server and refused with unapproved_software_statement — Untrusted: Certificate is not a member of community. To produce that answer the server decoded the request, parsed the software statement as a JWT, checked its signature, walked the certificate chain, and reached a decision about membership. It is the difference between being turned away at the door and being handed back an illegible letter.

What remains cannot be done from here. The certificate that proves community membership comes out of QHIN onboarding and is not something software produces. Until one exists, no exchange with a real partner completes.

How a channel asks. A destination of type tefca asks the Facilitated FHIR partner set in Settings, TEFCA (tefca.partnerFHIRBase) for each message's patient: by identifier when the message has one (PID-3, or a FHIR Patient's identifier), otherwise by family name with date of birth, and never by a name alone, because a wrong match on a name returns somebody else's record without an error. The destination sets the purpose of use (treatment, payment, operations, public health, individual access); a purpose the participant does not declare is refused before anything is sent. Purposes go as the TEFCA Exchange Purpose codes (T-TRTMNT, T-PYMNT, T-HCO, T-PH, T-IAS, system urn:oid:2.16.840.1.113883.3.7204.1.5.2.1), or with tefca.purposeCodes: hl7 as HL7 purpose-of-use codes for communities that use those. The Bundle found is the destination's reply and can be handed to another channel with results_to. Every query, refused ones included, is in the trail on this screen, which names the partner.

The whole path, from discovery to a token the partner's FHIR server accepts, is tested against the udap-dotnet reference server (see verification).

The older QHIN-to-QHIN exchange built on the IHE profiles is not implemented. An attempt is refused with an error saying so, and the refusal is audited as a failure. That is deliberate rather than pending a tidy-up: a stubbed exchange reporting success would put entries in the audit trail for exchanges that never happened, and a trail that disagrees with what happened is worse than none, because the trail is what gets believed.

Throughout, the endpoints used for exchange come from the signed part of a partner's metadata, never the document body. Verifying a signature and then reading the endpoints from the unsigned copy would be an elaborate way of trusting the network: an attacker able to rewrite the response leaves the signed element alone, points the token endpoint at their own server, and collects client assertions from everybody who checked the signature and then ignored what it covered.

A trust anchor bundle is required and its absence is refused at startup. Checking a partner's signed metadata against the certificate that arrived inside it proves only that one party made both — a mistake this codebase has made before, in SAML, where the verifier preferred the embedded certificate and authenticated an attacker as an administrator while every test passed. Include the intermediates in the bundle: a UDAP server need only send its leaf certificate, and the public reference server does exactly that.

3.5 Payers — claims, prior authorization, drugs and the CMS payer rules

3.5.1 Claims & auth

Build an X12 275 claims attachment in the 006020 form CMS-0053-F adopts, from a pasted C-CDA or an uploaded PDF, TIFF or JPEG, and see it read back. Read a 275 and download the document inside. Convert an X12 278 decision into a Da Vinci PAS ClaimResponse. Nothing on this page is stored or sent. See message formats for what the 275 is built from and what it does not do.

3.5.2 Drugs

The drug formulary loaded with -formulary, and the two NCPDP standards answered from it. Formulary lists the plans and the drugs with their tier and requirements, and where the public Da Vinci US Drug Formulary API and the NCPDP endpoints are. Price check (RTPB) prices a drug for a member as a prescriber's Real-Time Prescription Benefit request would: the coverage status, the restrictions in words with their codes, the member's share and the plan's, and the cheaper alternatives. Prior authorization (ePA) sends a SCRIPT initiation to this server's own endpoint, shows the question set as a form, sends the answers and shows the decision; below, every case with where it stands and whether the prescriber has been told, the case's answers, a button to send an unsent decision again, and the drug figures CMS-0062-P proposes. See CMS-0057.

3.5.3 CMS-0057

The payer side of the CMS Interoperability and Prior Authorization rule, as five tools:

and what each still needs.

certify fewer units. See the reviewer queue.

The conversions store nothing and send nothing.

3.5.4 CMS Aligned Network

Readiness for the CMS Interoperability Framework: each of its 26 criteria, grouped as CMS groups them, with what Perfuse provides, whether it is on here, the setting that turns it on, and what is the organisation's to do. The counts at the top filter the list. See CMS-0057.

3.6 Administer — who can do what, and how this server is set up

3.6.1 Users

Accounts, roles (monitor, viewer, operator, editor, admin) and channel groups. Each person's channel groups are edited in place in the list; empty means every channel, and lab=editor, adt gives a different role in one group. An administrator limited to some groups sees only the people of those groups. The header shows your own groups beside your role when you have any. See security.

Machine credentials are also here. A token is for a machine rather than a person: it does not expire and is not affected by anybody signing out, which is what a server polling its neighbour needs. Only a hash is stored, so a token is shown once when it is created and cannot be recovered — a stolen database yields no usable token. Give one the lowest role that does the job.

With MCP on, the section also shows the address an AI agent connects to, a client configuration to copy and each tool with the role it needs; issuing a token with Let an AI agent act with it gives it the mcp:write scope. See security.

3.6.2 Tenants

On a shared server, the platform operator's list of organisations: each one's channel count and stored size beside its limits (channels, messages a minute, storage), which are set in place. Shown only to the platform role. See security for what each limit does.

3.6.3 Certificates

The TLS certificates in use and when they expire. The expiry dates are the point: a certificate that lapsed on a Sunday is a class of outage that is entirely preventable and routinely is not prevented.

3.6.4 Activity

Who changed what, and when. Every configuration change is recorded, which is what makes "it started failing on Tuesday" answerable.

3.6.5 Data protection

A person's requests under the GDPR and the UK GDPR, legal holds, the erasure ledger, retention and the record of processing activities; administrators only. See data protection.

3.6.6 Fleet

Every Perfuse instance on one page.

Mirth sells this as a separate product; here it is a configuration file and a read-only token. The reason it is worth having is narrow and important: a dead server does not look like a quiet one. A single instance's dashboard cannot tell you that a second site stopped reporting, and a fleet view can.

3.6.7 Migrate

Drop in a Mirth or OIE channel export and see how many of your channels would run here unchanged. The same analysis as perfuse explain and perfuse translate, without needing a terminal. See migration.

3.6.8 Settings

Every setting the server has, with a control for each — including the ones that would traditionally live only in a file. Each says whether it takes effect at once or needs a restart, and whether it has been set explicitly or is sitting at its default.

The settings cover every perfuse serve option that turns on a feature: the FHIR endpoint and its version, the payer APIs, PAS and the CRD rules, the X12 278 bridge, eCR, the SMART authorization server, TLS and signing, metrics, fleet and VPN. They are grouped as FHIR, Payers, Public health, SMART, Security and so on. Each is also shown with the flag it corresponds to.

Restarting from the console. A setting marked *Needs a restart* is saved at once but read only when the server starts. While any are waiting, a banner at the top of Settings lists them with a Restart the server button (the sign-on editor and the settings files offer the same button after a save). It asks first, because every channel stops for a few seconds: each finishes the message it is handling, as it does when the service is stopped, and senders resend whatever arrives meanwhile. The console then waits for the server to answer again and reads the settings afresh. Only an administrator can restart, and each restart is recorded in the activity log as server.restart.

On Linux and macOS the process replaces itself with a fresh copy of the same program and arguments, keeping its process id, so systemd, launchd or a container runtime sees nothing happen. On Windows the server starts again inside the same process, which keeps a Windows service under the service manager. If the server has not answered after two minutes, the console says so; the reason is in the server's log.

Flags and the settings file. The first time the server starts, the flags it was given are written into the settings file. From then on the file wins over the flags, so changing a setting is done in the console (or the file), not by editing the service's command line. A flag the file does not yet mention is recorded when the server starts with it. Removing a flag from the command line later does not turn its feature off: turn it off in Settings.

SNMP has a panel below the settings: whether the agent is answering and on which address, the networks it answers, how many alert traps were sent or failed, and Download the MIB for the base OID in use. The values are set under Monitoring, SNMP.

Branding is here too: a site can set the product name, a tagline, an accent colour, and upload a logo. The logo then appears in the header, on the sign-in page, on the dashboard and on the reporting screens. If none is uploaded, nothing is substituted — an unbranded installation stays unbranded rather than acquiring decoration.

3.7 What the colours mean

Four registers, and each says something different. The distinction is worth stating because it is the difference between a screen you can read at a glance and one where everything competes for attention.

Plain text is a fact about how Perfuse works. Most of the interface. Nothing is coloured to tell you it is important.

Green means something succeeded just now, because of something you did. It appears after an action and does not persist.

Amber is a caution: worth knowing before you act, and not a fault. A feature that is not switched on, a setting whose changes are audited, a conversion that approximates rather than reproduces, a transport Perfuse handles but cannot fully verify. Amber is the colour of "read this before you continue".

Red means something is wrong at this moment. A value that was rejected, a request that failed, a certificate that has expired, a critical alert that is firing. Nothing else uses it.

That last rule is why a screen does not open red. Red is the only colour that carries an instruction — stop, and it may be something you did — and a screen that spends it on decoration has nothing left to say when the input really is wrong. This was not always true: the users screen used to open with "the server sent a response that could not be read" on every installation that had not configured passkeys, and the settings screen marked audited values in the same colour as a rejected one. A test in web/src/RedMeansWrong.test.ts now fails if a phrase describing something absent or approximate is given a red style.

Inside a code box the vocabulary is different. There, red is a token type — an HL7 segment name, an XML element — and carries no judgement at all.

4 Channels

A channel is one flow of messages: somewhere they come from, optional changes, and one or more places they go. It is the unit of configuration, the unit of monitoring and the unit of deployment. Everything else in Perfuse exists to serve channels.

4.1 Anatomy

A channel file has these parts, in the order they take effect:

PartKeyRequiredWhat it does
IdentitynameyesNames the channel. Must be unique.
Data typedataTypenoHow to parse messages. Defaults to HL7 v2.
SourcesourceyesWhere messages arrive from. Exactly one.
FilterfilternoDecides which messages to accept.
TransformationstransformationsnoOrdered changes to the message.
DestinationsdestinationsyesWhere messages go. One or more.

Plus several that apply across the whole channel: scripts, shadow, contract, tables, attachments, group and enabled. Each is in the channel reference.

4.2 One source, many destinations

This asymmetry is deliberate and it is worth understanding early, because it is the single most common source of confusion when coming from a different engine.

A channel has exactly one source. If messages arrive from two places, that is two channels. Trying to express it as one channel produces a configuration where a failure in either transport looks the same from outside, and where the two feeds' traffic is mixed together in the statistics — so a drop in one is hidden by the other.

A channel has any number of destinations, and they are independent. One failing does not stop the others, and each has its own queue, its own retry policy and its own delivery record. A message that reached three of four destinations is recorded as exactly that, not as a success or a failure.

4.3 Destinations are not a chain

Each destination receives the message as it stood after the channel's transformations. A destination does not receive the output of the destination before it.

This matters when a destination has its own transformations, which it may. Those apply to that destination's copy only. A common mistake is to put a change on the first destination and expect the second to see it.

If you genuinely need a chain — the output of one flow feeding into another — use a channel destination, which sends to another channel by name. That is explicit, appears in the message history as two channels, and can be monitored as two things, which a hidden chain cannot.

4.4 Naming

The name is the channel's identity everywhere: in the file, in the message store, in alerts, in the audit log and in the URL. Changing it is not a rename; it is a new channel, and the old one's history stays under the old name.

group is separate and exists for organising the interface. A group has no effect on behaviour, and grouping channels does not make them share anything.

On a multi-facility deployment, put the facility in the name rather than only in the group. Mirth deployments across several sites reliably drift into inconsistent naming, and the point at which that becomes painful is an incident at two in the morning where the channel list has four things called adt-inbound.

4.5 Enabling and disabling

enabled: false stops a channel starting without deleting it. Its configuration, history and statistics stay.

A disabled channel is not a paused one. Its source is not listening, so a sender attempting to connect gets a refused connection rather than a hung one. For MLLP that is the right behaviour — the sender will queue and retry — but it does mean the sender's own error log will fill up, so a disabled channel is not a substitute for arranging an outage with whoever is sending.

4.6 Where channels live

One YAML file per channel, in the directory given by -channels. The filename is not the channel name; the name key is. Keeping them the same is strongly advised and nothing enforces it.

Files are re-read without a restart. A file that does not parse is reported and the previously loaded version keeps running, so a syntax error cannot take a working feed off the air.

A channel edited in the web interface is written back to its file. If those files are in version control — which is recommended — expect commits from configuration changes made through the interface, and do not hand-edit a file at the same time as somebody has it open in the builder.

4.7 Encrypting a channel's own listener

A channel that listens on a port is its own server, and its TLS is separate from the TLS on Perfuse's web interface. Setting one does not set the other. For an HTTP source the settings are under TLS and limits on the source.

Switching TLS on asks for a certificate and a private key. Requiring a client certificate turns it into mutual TLS: the sender must present one signed by an authority you name, which is authentication, so a listener with it on does not also need a shared token — though having both is not wrong.

The authority is asked for only once a client certificate is demanded, and is left out of the file until then. A channel naming an authority that is never consulted reads as though senders are being verified when they are not.

An MLLP listener has the same settings under Encrypt this listener, with one difference worth stating: on MLLP a client certificate is the only authentication available. There is no token and no header, so a listener without one accepts messages from any host that can reach the port.

Two limits sit alongside it. Give up reading after a duration, because a sender that opens a connection and stops writing otherwise holds it open. And largest message accepted, in bytes, because zero means no limit and the limit is then memory — a sender that posts a gigabyte is refused rather than absorbed.

4.8 Validation

A channel is checked when it loads, and the checks are refusals rather than warnings where the alternative would be a channel that runs and does the wrong thing.

Examples of things refused outright: a destination with no type, a transformation addressing a path that cannot exist in the declared data type, an acknowledgement mode that the source transport cannot deliver, and a Schedule II prescription with refills.

The distinction Perfuse tries to hold to is that anything which would produce a plausible-looking wrong result is refused, and anything which is merely unusual is allowed and reported. A channel that cannot be wrong is more valuable than a channel that starts.

5 Message Flow

This chapter follows one message from the moment it arrives to the moment it is delivered or given up on. Knowing this order is the fastest way to work out where a change belongs, and why something is not happening.

5.1 The order of events

Arrival de-frame Parse Record as received Filter Transform Destination A Destination B Destination C unparseable filtered Filtered is a normal outcome, not an error. Unparseable is recorded and stops. The acknowledgement is sent after delivery by default, so a sender is not told "received" before it is true.
  1. Arrival. The source accepts the bytes. For a stream transport this includes de-framing — finding where one message ends and the next begins.
  2. Parse. The bytes become a tree according to the channel's dataType. A message that cannot be parsed stops here and is recorded as a parse failure.
  3. Record. The message is written to the message store as received, before anything modifies it. This is what makes replay and reprocessing possible.
  4. Filter. If the channel has a filter, it decides whether to accept the message. A rejected message is recorded as filtered, which is a normal outcome and not an error.
  5. Transform. The channel's transformations run in order, each against the result of the last.
  6. Fan out. Each destination gets a copy of the transformed message, applies its own transformations if it has any, and delivers.
  7. Record the outcome. Per destination: delivered, queued, failed, or filtered.
  8. Acknowledge. If the source expects an acknowledgement, it is generated and sent. By default this happens here, after delivery — see below.

5.2 When the acknowledgement is sent

This is the most consequential setting in a channel, because it decides what the sender is being promised. It is ack.when, and it has two values.

on_delivery is the default. The acknowledgement is sent only after every enabled destination has accepted the message. A positive acknowledgement therefore means the data actually arrived somewhere, which is the strongest claim an engine in the middle can honestly make.

on_receipt acknowledges as soon as the message is parsed and queued, before any destination has been written.

Perfuse defaults to on_delivery because promising delivery and then losing the message is worse than being slow. Under on_receipt, a message that has been acknowledged can still be lost if the process dies with work queued — the sender believes it is delivered, nothing upstream will resend it, and the only record that it existed is in Perfuse's own store.

5.3 When to choose on_receipt

The case for it is real, and it is timeouts. An MLLP sender that does not get an acknowledgement within a few seconds will typically resend, so a slow destination under on_delivery produces duplicates rather than delay. If one destination is a slow archive or a system that is regularly unavailable, on_delivery couples the sender's timeout to that destination's worst case.

The trade is explicit: on_receipt moves the risk from duplicates to loss. Choosing it means accepting that an acknowledged message can be lost in a crash, and it is worth pairing with queue-depth alerting so that a queue which has stopped draining is noticed. See alerting.

Under either setting, a positive acknowledgement is not a promise that the receiving application has *processed* the message — only that it accepted the bytes. If you need to know a result was filed, that has to come from the receiving system, and no integration engine can provide it.

5.4 Where the message is recorded

Before the filter, and therefore before any transformation. Three consequences:

The transformed form is also recorded, per destination, alongside the outcome. So the store holds what arrived, what was sent, and what happened — which is what an investigation needs.

5.5 Failure at each stage

StageWhat failure looks likeWhat happens
ArrivalFraming error, connection dropped mid-messageRecorded, connection closed, sender retries
ParseNot valid for the declared typeRecorded as a parse failure, negative acknowledgement if the source expects one
FilterThe filter itself errorsTreated as a refusal, recorded with the error
TransformA step failsDepends on the step's on_error; see transformation
DeliverDestination unreachable or rejectsQueued and retried per the destination's policy; see delivery

The important asymmetry: a parse failure is permanent and a delivery failure usually is not. Retrying an unparseable message will never help, so it is not retried. Retrying an unreachable destination usually will, so it is.

5.6 Ordering

Within one destination, messages are delivered in the order they arrived, and a message that fails and enters the queue holds its place — later messages wait behind it rather than overtaking.

This is the right default for clinical data, where an admission followed by a discharge delivered in the wrong order is worse than both being late. It also means one poisoned message can hold up a queue, which is why queue depth is worth alerting on.

Across destinations there is no ordering guarantee, because they are independent. Two destinations will not necessarily receive the same message at the same time, and a fast one may be several messages ahead of a slow one.

Ordering across channels is not guaranteed at all. If two feeds must be applied in a particular order relative to each other, that ordering has to be enforced by the receiving system, because nothing in the middle can know which of two independently arriving messages happened first.

5.7 Concurrency

A source may handle several connections at once, bounded by max_connections. Messages from different connections are processed concurrently.

This means the arrival order of two messages sent simultaneously down two connections is not defined, and cannot be — they genuinely arrived at the same time. A sender that needs ordering has to use one connection, which is what almost all of them do.

6 Transformation

Transformations change a message. They are a list, they run in order, and each one sees the result of the one before it.

Perfuse's transformations are declarative: each step names an operation and the field it applies to. There is no scripting language in the ordinary path. This is a deliberate limitation and the reasoning is in why not a script below.

6.1 The steps

StepWhat it does
setWrites a literal value to a path, creating it if absent.
copyCopies one path to another, with an optional default when the source is absent.
clearEmpties a field, leaving it present.
removeRemoves a field entirely.
mapLooks the current value up in a table and replaces it.
replaceRegular expression substitution within a field.
padPads a value to a fixed width.
dateConverts a date or timestamp between formats.
trimStrips surrounding whitespace.
caseConverts to upper or lower case.

Every step also accepts description, which appears in the trace and in the interface, and when, which makes the step conditional.

A step is written as the operation name with its own block:

transformations:
  - description: Our record number, not theirs
    set:
      path: PID-3.4
      value: RIVERSIDE
  - copy:
      from: PID-19
      to: PID-3.1
      default: UNKNOWN
  - map:
      path: PID-8
      table:
        M: Male
        F: Female
set and value are not siblings. set takes a block containing path and value. Writing set: PID-3.4 with value: beside it is the commonest mistake in a hand-written channel and produces a load error rather than a silent misconfiguration.

6.2 Clear, remove and the difference

clear leaves the field present and empty. remove takes it out altogether.

For HL7 this distinction is real and it reaches the receiver. An empty field says "I have this field and it has no value". An absent field says nothing at all. Some receiving systems treat the first as an instruction to blank their stored value and the second as an instruction to leave it alone — which is the difference between deleting a patient's recorded allergy and not mentioning it.

If you do not know which the receiver wants, ask. Guessing has a fifty per cent chance of silently destroying data on the far side.

6.3 Conditions

when takes an expression and the step runs only if it is true:

  - when: MSH-9.2 == "A08"
    set:
      path: EVN-1
      value: A08

A step whose condition is false is recorded as skipped, and this is reported separately from a step that ran and changed nothing. That distinction is the point of the step-through debugger and it is covered in debugging: a false condition and an absent field look identical from outside and mean opposite things.

6.4 Paths

A path addresses a field: PID-5 is the fifth field of PID, PID-5.1 its first component, PID-3(2) the second repetition of PID-3 and OBX(3)-5 the fifth field of the third OBX segment.

Two behaviours worth knowing:

6.5 Errors

Most steps have nothing to fail at. Setting a value, clearing a field or copying from an absent path with a default are all defined for every input.

date is the exception, because a value that is not a date it recognises has no correct conversion. It takes on_error:

The default is fail because a timestamp that silently did not convert is worse than a message that did not arrive. A date in the wrong format usually still looks like a date, so it passes the receiver's validation and lands in the record as the wrong day.

keep is legitimate when the receiver tolerates either format, and when used the trace marks the change as deliberate so it does not read as a step that failed quietly.

6.6 Order matters, and the trace shows it

Each step sees the previous step's output. Two consequences that catch people:

Both are visible in the step-through view, which shows the whole message after each step rather than only the change. See debugging.

6.7 Suggesting mappings, and what to do with a suggestion

The AI Mapper proposes mappings between a set of source fields and a set of targets. Its defining behaviour is that it declines: below its confidence threshold it abstains rather than guessing, because a confident wrong mapping in a clinical system is worse than no mapping at all.

6.7.1 Give it something to be confident about

A name on its own is weak evidence, even when it matches exactly. PatientMRN against PatientMRN scores 68, which abstains at the default threshold of 70.

The engine knows what an HL7 path means — it will tell you PID-7 is Date/Time of Birth and score your field name against that rather than against the string PID-7, which shares no characters with anything. A locally defined Z segment means whatever your site decided, so nothing is claimed about it.

That gets a mapping most of the way. To finish it, supply both halves:

With those, the engine can see that the values agree with the target's kind, and a suggestion rests on more than a name.

6.7.2 Approval is one mapping at a time

Each suggestion has its own approval box. There is no "accept everything above 80%", deliberately: the engine has already used the confidence to decide whether to offer the mapping, and what remains is a judgement about this field in this feed. A number is not that judgement.

An abstention cannot be approved at all. It has no box, not a disabled one, and the screen says the engine does not know. If you know the answer, map it by hand — that is a different act from agreeing with a suggestion.

A suggestion below your threshold is shown without a box too, marked as below it. Worth reading — a near miss is often the mapping you wanted, and hiding it would leave you wondering whether the engine considered it — but not approvable, because your threshold is the line you drew.

6.7.3 Two things to do with what you approved

Add them to a channel turns each mapping into a copy step. This only works where the source is a path in the message, such as ZPI-1, because a step copies from one place in a message to another. The steps go into the channel file and take effect when it next loads — nothing changes in the traffic until then. Each step's description records the confidence it was suggested at and that a person approved it, which is what makes it accountable six months later.

Download as a recipe is the right answer for the commoner case: a mapping from a column name, a JSON key or a vendor's own field name. Those are real mappings, and a channel cannot read such a field directly, so a step cannot express them. The recipe records them as approved and can be shared.

6.8 Why not a script

Mirth puts a JavaScript transformer at the centre of every channel, and it is the most flexible design available. Perfuse offers scripting — see the scripts key in the channel reference — but does not make it the ordinary path, for three reasons that come from what goes wrong in practice.

A declarative step can be inspected. Perfuse can tell you that a step reads PID-5 and writes PID-8, before running it, and can therefore show a field-level trace, warn that a step addresses a path the declared data type cannot contain, and generate a per-step replay. None of that is possible for arbitrary code.

A declarative step can be reasoned about by the interface. The builder can offer the fields that exist in this channel's traffic, and a change to a step can be checked against real historical messages before it is saved.

And a declarative step cannot do something unrelated to the message. A script can call out to the network, read the filesystem, or depend on the time of day. When a channel that worked for two years starts producing wrong output, the cause being outside the message is the hardest case to diagnose, and removing the possibility is worth more than the flexibility it costs.

Where a genuine transformation cannot be expressed declaratively, use a script and accept that the trace will show it as one opaque step.

6.9 What a script is allowed to do

A script is refused anything outside the message unless it is granted, and the grants are on the Scripts section of the builder. This is deliberately unlike Mirth, which gives every script all of them — so there, a transformer can quietly start reading the filesystem after a copy-paste.

There are three.

Read and write files requires you to name the directories it may use, and a channel granting file access without them is refused at load. That requirement is the whole difference between file access and unrestricted file access: granting it without directories once reached the entire filesystem, including Perfuse's own database, which holds password hashes, the LDAP service account password and the OIDC client secret. Creating a channel needs only the editor role, so the escalation completed the moment an administrator started the channel.

Query a database uses the connections configured on the channel. A script with this can read anything those credentials can.

Route messages to other channels lets a script send a message into another channel, which is how a loop between two channels becomes possible.

Granting a capability to a channel with no scripts does nothing, and the generated file omits the whole block in that case.

6.10 Handling a date that does not match its format

A date step converts from one format to another, and the interesting question is what happens when a value does not match the format it claims.

The default is to stop the message, and it is the right default: a timestamp that silently did not convert is accepted downstream and then misread, which is worse than a message that failed loudly and is sitting where somebody can see it.

Leave the value as it is passes it through in its original format. Downstream then sees two formats in one field, which is the failure the step existed to prevent — but it is the right choice where the field is optional and a partner sends it inconsistently.

Empty the field is honest and better than a wrong format, but a receiver that requires the field then rejects the message instead.

6.11 The scripting languages

Three are accepted, and which one to pick depends mostly on whether you are migrating. The value goes in a script's language key:

ValueWhat it is
javascriptThe default. An unmarked script is JavaScript.
luaThe better choice when nobody is migrating.
wasmThe bytes of a compiled WebAssembly module, not source.

JavaScript is the default, and an unmarked script is treated as JavaScript. It is here as a compatibility obligation rather than a preference: Mirth's scripts are JavaScript and a site moving across has thousands of lines of it. That includes support for E4X, a long-dead extension to the language that Mirth scripts use for XML and that has to be translated before anything will parse it.

Lua (lua) is the better choice for anyone not migrating. A smaller runtime, no prototype chain to misuse, and integer-indexed tables that match segment and field addressing more naturally than JavaScript's do. Its standard library is small enough to enumerate, which is what makes its sandbox easier to be confident about.

WebAssembly (wasm) is not a language but a compiled module, and the field wants the bytes of a .wasm file rather than source. Use it when the transformation already exists in a compiled language, or when it is heavy enough that the runtime cost matters.

Its contract is deliberately the simplest one available: a module reads the message on standard input and writes its result to standard output. Bytes in, bytes out — which is what every toolchain already does, and what somebody testing a module on their own command line already has. The alternative was an exported function over linear memory, which is faster and requires every module author to agree about allocation, string encoding and who frees what; for something that runs once per message, that trade is not worth it.

Anything a module writes to standard error becomes log lines, so it can explain itself the way a script calling logger can.

A non-zero exit means the module *failed*, which is not the same as a filter deciding to reject a message. A filter module says true or false on standard output. Conflating the two would make a crashing module indistinguishable from one deliberately dropping traffic.

6.11.1 What the output means

The rules are the same for all three languages rather than new ones per runtime, so a script's observable behaviour — what it can see, what it may do, how long it may take, what a filter verdict means — is defined once.

For a transformer, preprocessor or writer, empty output means the message is unchanged. Falling off the end of a script leaves the message alone; treating no output as "the message is now empty" would discard traffic on a technicality.

WebAssembly modules are compiled when the channel loads, not on the first message. Compiling is not free — a module built from a language that brings its own runtime can take hundreds of milliseconds — and paying that at load time means the first message through a channel is not mysteriously slower than the rest.

6.12 Shared mapping tables

The other half of the same problem: a site's sex codes and patient classes get translated identically in every channel that touches them, and a table inside a channel is a table only that channel can use.

# codes.codeset.yaml
tables:
  - name: sex-to-lab
    describes: the hospital's sex codes into the numeric codes the lab expects
    decided_by: Dave, during the 2019 migration
    decided_on: 2019-04-11
    source: the lab's interface specification, version 3
    entries:
      - from: M
        to: "1"
      - from: U
        to: "9"
        why: the lab has no 'unknown', and 9 is its 'not stated'
# in the channel
tables:
  - codes.codeset.yaml
transformations:
  - map:
      path: PID-8
      use: sex-to-lab

The provenance is the point. Most of what makes an interface hard to maintain is that nobody knows *why* a mapping is the way it is, and the person who knew has left. A mapping with a reason can be argued with; one without becomes something nobody dares change and nobody dares delete.

Tables are made and changed on Build → Tables as well as in the file. Saving a table that is in force restarts the running channels that load its file, so the next message uses it; a draft restarts nothing.

6.12.1 Versions and effective dates

A table may keep several editions under one name, each with its own version. The edition a message uses is chosen when the message arrives: of the approved editions whose dates cover that day (UTC), the one that started latest. So a code change agreed for the first of next month is entered now and takes over on its own.

tables:
  - name: sex-to-lab
    version: "3"
    effective_from: 2026-11-01   # optional; empty means from the start
    effective_until: 2027-10-31  # optional; the last day it applies
    source_system: urn:local:hospital-sex
    target_system: http://terminology.hl7.org/CodeSystem/v2-0001
    entries: [...]

(kept for the record, used by nothing).

starting the same day, are refused when the file loads.

from today, so it replaces the one in force. One that had itself only started today is retired, since two editions starting the same day leave no rule.

otherwise the table's.

who saved and who approved it, and Compare with the version in force shows what an edition adds, removes and changes.

6.12.2 Sign-off

With Settings → Data → Mapping tables "Changes to a mapping table need a second person's approval" (data.tableApproval) on, every save is a draft, and the person who saved it cannot approve it: somebody else does, optionally giving the day it comes into force. An approved edition cannot be edited in place; New version from this copies it into the next version. Approving, retiring and deleting a draft are written to the audit log, and approving restarts the running channels that load the file.

6.12.3 Values no table maps

Every value a map step looked for and did not find is counted, per table, with the channels it came through and when it was first and last seen. The Tables screen lists them, most frequent first. Map it opens the table with that value already added as a row; Dismiss removes it until it is seen again. Values longer than 64 characters are not recorded (they are not codes), and the list keeps what was seen within the message retention period. The list is kept in terminology.db beside the main database.

6.12.4 Code systems

Published code systems can be imported from their publishers' own files, under Code systems on the Tables screen (admins) or with POST /api/terminology/import?format=… and the file as the body:

FormatFileSystem
loincLoinc.csv from the LOINC table releasehttp://loinc.org
icd10cmthe CMS order file (icd10cm_order_*.txt) or codes filehttp://hl7.org/fhir/sid/icd-10-cm
rxnormRXNCONSO.RRF from the RxNorm full releasehttp://www.nlm.nih.gov/research/umls/rxnorm
snomedthe RF2 description snapshot (sct2_Description_Snapshot-en_*.txt)http://snomed.info/sct
snomed-rf2a whole SNOMED CT RF2 release package (the zip a national release centre publishes)http://snomed.info/sct, version http://snomed.info/sct/{module}/version/{date}
clamla ClaML file: WHO ICD-10, ICD-10-GM and OPS (BfArM), CIM-10 FR (ATIH)from the file's title, or the given system
icd11the WHO ICD-11 MMS simple tabulation (tab-separated, with Code and Title columns)http://id.who.int/icd/release/11/mms
csvany delimited file, with system, codeColumn and displayColumn (CPT, HCPCS, local codes, national tables)the given system

None ships with Perfuse: LOINC, SNOMED CT, RxNorm and CPT each have their own licence, and the screen asks the importer to confirm the organisation holds it (recorded with the import). Imported codes are used three ways:

saved, and codes that are not in the system are listed on the edition;

for them (see the FHIR server options). ICD-10-CM codes are accepted with or without the dot.

National editions and classifications. A SNOMED CT edition is imported from its whole RF2 package: concepts with their real status, every active description, and the language reference sets. The edition is found from the package (the national module with the most concepts; edition names it instead) and becomes the version URI. language chooses whose preferred term is each concept's display (sv, da, nl, de, fr, en-GB, en-US ...; English otherwise), and every language's preferred term and fully specified name are kept as designations: $lookup returns them, and its displayLanguage picks one (de-CH falls back to de). The editions Perfuse names (International, UK, Germany, Netherlands, Sweden, Denmark, Belgium, Ireland, Norway, Switzerland, Estonia, Spain) are listed by GET /api/terminology/editions, the module IDs not checked against a published source marked as such; any other edition imports too, named by its module.

ClaML files are read in UTF-8, ISO-8859-1, ISO-8859-15 or Windows-1252, as their XML declaration says. Each category's title is its preferred rubric (nested terms included); a modifier (a fifth character for the site of a fracture, say) makes the codes it adds, with the modifier's title after the category's, unless the category excludes it; blocks=true imports chapters and blocks as well. The ICD-11 tabulation's columns are found by name, since their order has changed between releases, and the dashes that show depth are removed from titles. A delimited file takes delimiter (, ; | or tab), encoding (utf-8, iso-8859-1, iso-8859-15, windows-1252) and noHeader=true, with columns then numbered from 1. The console offers settings for the UK ICD-10 5th Edition and OPCS-4 files (TRUD), France's CIM-10 FR table of labels (ATIH) and an ICD-10-BE export; their publishers do not all document their columns, so check them against the file's header.

Effective dates. effective=YYYY-MM-DD imports a version now and uses it from that day (ICD-10-GM, say, each 1 January): until then a lookup that names no version is answered by the version already in force, and the screen shows the date.

ICD-11 from the WHO. With a client registered at icd.who.int (Settings → Data → Code systems, serve -icd11-client-id and -icd11-client-secret; -icd11-release, 2025-01 by default), ICD-11 codes that are not imported are looked up in the WHO ICD API (codeinfo, then the entity's title) and kept for a day. A postcoordinated code (NC72.2&XK9K) is checked whole, so a combination ICD-11 does not allow is refused, and its display names every part ("Fracture of neck of femur (Right)").

ICD-11 without sending codes out. The WHO publishes the same API as a container (whoicd/icd-api, free, Intel and Arm). Run it on your own network and give its address (serve -icd11-url http://icd-api.local, or ICD API address in the same settings); no client is needed and no code leaves your network. Tested against that container (version 2.6.0, release 2025-01 in English and French).

7 Filtering

A filter decides which messages a channel accepts. It is one expression on the channel, evaluated after the message has been parsed and recorded, before any transformation.

filter: MSH-9.1 == "ADT" && MSH-9.2 in ("A01", "A03", "A08")

A message the filter rejects is recorded with the outcome filtered. That is a normal result, not an error, and it does not raise an alert or produce a negative acknowledgement.

7.1 Filtered is not failed

This distinction is worth being firm about because conflating them is how a real problem gets hidden.

A filtered message was correctly declined: it was not for this channel. A failed message was one this channel should have handled and could not. If both were recorded the same way, a channel that starts failing every message would look exactly like a channel that is filtering most of its traffic, which is the normal state of many channels.

So the statistics count them separately, the alert rules treat them differently, and the interface shows them in different colours. A rise in filtered messages might mean the sender has started sending something new; a rise in failures means something is broken.

7.2 What a filter can read

Any field in the parsed message, by the same paths transformations use. The filter runs before transformations, so it sees the message as it arrived — not as it will be sent.

That ordering is deliberate. A filter that read post-transformation values would be deciding whether to accept a message based on changes made on the assumption it was accepted, which is circular and produces channels whose behaviour depends on the order of two things that look independent.

7.3 Expressions

Comparisons: ==, !=, and for numbers <, <=, >, >=.

Membership: in ("A01", "A03"), which is clearer than a chain of || and is the form the builder produces.

Combination: &&, ||, and parentheses. Negation with !.

Absence: a path that is not present compares unequal to any value, including the empty string. To test for absence explicitly, compare against absent. This matters because an absent field and an empty field are different, as described in transformation, and a filter that treats them the same will accept messages it was written to exclude.

7.4 An error in the filter is a refusal

If the filter itself fails to evaluate — a malformed expression that got past validation, or a comparison that cannot be made — the message is refused and the error is recorded against it.

Refusing rather than accepting is the safer of the two, but neither is good, and the outcome is recorded distinctly from an ordinary filter refusal so it can be alerted on. A filter that is erroring on every message is silently dropping the entire feed, and it must not look like a filter that is working.

7.5 Filter at the source where you can

A filter runs after the message has crossed the network and been parsed. Some sources can filter earlier and more cheaply:

Filtering at the source is a performance decision, not a correctness one, and it has one cost: a message filtered at the broker is never recorded by Perfuse, so it does not appear in the message store and cannot be inspected later. If you need to be able to prove what the sender sent, filter here rather than there.

7.6 Testing a filter

A filter can be checked against real historical traffic before it is saved. The interface reports how many of the last N recorded messages the filter would accept, reject, and error on — which is the fastest way to find out that an expression which looks right accepts nothing.

The trace also shows, per message, which fields the filter read and what values it found. A filter that unexpectedly rejects everything is usually reading a path that does not exist in this feed, and the trace names it.

8 Message Formats

A channel's dataType decides how bytes become a message. It affects parsing, what paths mean, how the message is written back out, and which transformations are valid.

ValueFormat
hl7HL7 v2, the pipe-delimited standard. The default.
hl7v3HL7 v3 and CDA documents, which are XML.
x12ASC X12, used for eligibility and claims.
ncpdpNCPDP Telecommunication Standard D.0 pharmacy claims.
scriptNCPDP SCRIPT, electronic prescriptions. XML.
dicomDICOM imaging objects.
delimitedCSV and other delimited text.
rawNo parsing. Bytes in, bytes out.

8.1 HL7 v2

The default, and what most of this manual's examples use. Segments terminated by carriage return, fields by |, components by ^, repetitions by ~.

Two things about the encoding are worth knowing because they cause silent faults.

The delimiters are not fixed. They are declared in MSH-1 and MSH-2 of each message, and Perfuse reads them from there rather than assuming pipes. A sender that uses a different field separator parses correctly.

The consequence is that a message whose separators have been replaced — most often by a word processor or mail client substituting typographic look-alikes — parses perfectly into fields that are all wrong. Nothing errors. This is why the sample reader described in testing reports which separator character it found, and refuses to express confidence when it is unusual.

When somebody sends you a sample and the channel built from it produces nonsense, check the separators before anything else. Ask for a plain text attachment rather than a message pasted into an email body.

8.2 HL7 v3 and CDA

XML rather than delimited. Paths are element and attribute paths rather than field positions.

In v3 a value is almost always an attribute rather than element text. A comparison that only reads element text will report two documents as identical when the birth date, the gender and every identifier differ. Perfuse's comparison reads both, and this is why: the alternative silently succeeds.

hl7v3 options are in the format reference.

8.3 X12

Segments terminated by ~ conventionally, with delimiters declared in the ISA segment. Like HL7, they are read from the message rather than assumed.

X12's structure is envelopes within envelopes — interchange, functional group, transaction set — and a single interchange can hold many transactions.

By default an interchange is one message. The whole file gets one record, one outcome and one row in the interface, however many transactions it contains.

8.3.1 Mirth's X12 XML

Mirth Connect and Open Integration Engine show X12 as XML: a root X12Transaction (or EDIMessage when the input does not start with ISA) carrying the delimiters as attributes, and one element per segment and element named ISA.01, NM1.03.1 and so on. Channels and receivers built for Mirth read and write that form. Perfuse reads and writes the same one:

The format was written from Open Integration Engine 4.6's observed output and published format, and is checked against it: 25 generated interchanges and 4 kept examples come out byte for byte as OIE writes them, and back (see verification). A DOCTYPE is refused, as in every XML Perfuse reads.

8.3.2 Splitting an interchange

Setting split makes each transaction set a message in its own right, with its own record, its own metrics and its own row. An operator looking for one claim then finds one claim rather than a file of four hundred.

It is off by default because turning it on multiplies everything downstream — message counts, rows, alert thresholds — and a threshold tuned for files that is suddenly counting claims will not mean what it used to. That should be a decision somebody made rather than a default they inherited.

split and acknowledge cannot both be set, and a channel with both is refused at load. An acknowledgement is a statement about a whole interchange, so splitting would send one per transaction set, and a partner receiving several 999s naming the same functional group has no way to reconcile them.

8.3.3 When the counts do not add up

X12 declares its own counts — how many segments, how many transactions — and the envelope key says what to do when they disagree with what is actually present. Those counts are the only mechanism X12 has for detecting that half a claims file arrived.

ValueBehaviour
requireRefuse the interchange. The default.
warnProcess it, and record the fault against the message.
ignoreDo not check at all.

warn exists for a real situation: some partners' software has generated wrong counts for years in otherwise complete files, and a site that has to process those needs a way to say so out loud in the configuration rather than turning validation off wholesale.

ignore is named to be uncomfortable. Setting it throws away the only means X12 gives you of noticing a truncated file, and a truncated claims file is indistinguishable from a small one.

8.3.4 Transactions Perfuse reads as structured data

Any X12 interchange can be routed, filtered and addressed by path. These transactions are additionally parsed into named fields, so a channel can ask what a payer decided rather than which element sits at position four.

TransactionWhat it carries
835Remittance advice: what was paid, what was denied, and the adjustment reasons.
275Claims attachments: clinical documentation supporting a claim.
277Claim status, including a payer's request for additional documentation.
278Prior authorisation: the request for review, and the payer's decision.
999Functional acknowledgement, which Perfuse also generates.
271Eligibility: whether a patient is covered, and their co-payment, coinsurance, deductible and out-of-pocket amounts, in and out of network.
834Enrolment: members added, changed and terminated, with each coverage line and its dates.
837Claims, professional and institutional (read for CMS-0057's CARIN conversion).

Perfuse also builds the 270 eligibility inquiry and the 276 claim status request, under Payers → Claims & auth or through POST /api/x12/eligibility/build and /api/x12/claimstatus/build. Both are the HIPAA 5010 versions (005010X279A1, 005010X212), read back and envelope-checked before they are shown, and neither is sent from that page: delivering one is a channel's job, where the trading partner and its credentials are.

8.3.5 Eligibility, and the CAQH CORE data content rule

A 271 is a list of EB segments, each a single fact: active coverage, or a $25 co-payment for service type 30 in network, or $800 of the individual deductible remaining. The reader assembles them into the answer a front desk wants - "coverage active under Gold PPO 2000; co-payment in network $25; individual deductible remaining in network $800" - and names a rejection (an AAA segment) in words, with what the payer says to do next.

It also checks the response against the CAQH CORE Eligibility & Benefits data content rule: for an inquiry about health benefit plan coverage (service type 30), an active response should carry plan dates, the plan name, co-payment, coinsurance, deductible and remaining deductible both in and out of network, and the base service types (1, 33, 35, 47, 48, 50, 86, 88, 98, AL, MH, UC). Each is listed as met or missing. This is Perfuse's reading of the rule, useful for "why does this payer never tell us the deductible?" - it is not CORE certification, which CAQH's authorised testing vendors carry out.

8.3.6 Prior authorisation

Perfuse implements prior authorisation twice, on purpose.

The FHIR side is what CMS-0057-F requires payers to expose by 1 January 2027. The 278 is what payers accept today, and the rule does not replace it — Da Vinci PAS is in practice a mapping onto this transaction, because a 278 is what a utilisation management system speaks. An engine reading only the FHIR half can talk to the systems that will exist and not to the ones that do.

A 278 response carries the authorisation number, and that is the field the whole transaction exists to deliver. Without it a certified service is not billable: the claim goes in with no authorisation number and is denied for the lack of one, which reads downstream as a clinical denial.

A denial and a refusal to consider are not the same thing

This is the most expensive confusion in the transaction, and Perfuse reports the two separately.

What arrivedWhat it meansWhat to do
HCR with action A3The payer considered the request and will not certify it.Appeal, or change the plan of care.
An AAA segmentThe payer would not consider it — patient not found, requester not recognised, review not covered. Nothing was decided.Correct the request and send it again.

Both read as "not approved" to anybody scanning, and the correct actions are opposite. Appealing something nobody ruled on achieves nothing; resubmitting a denial produces the same denial. The parsed outcome names which happened — denied against not-considered — and AAA04 carries the payer's own advice on whether the request is recoverable at all.

Partial certification is reported as partially-certified rather than folded into approval. There is an authorisation number to bill against, so it counts as approved, but the quantity certified is not the quantity requested — and a practice booking six sessions against three approved discovers it at the fourth claim.

An action code Perfuse does not recognise is reported as unknown, not as pending. Pending reads as "wait", and waiting is the wrong action for most of what an unrecognised code could be.

A channel can route on the decision directly:

filter: HCR-1 == "A3"

Converting between the two

Perfuse converts a parsed 278 into a Da Vinci PAS ClaimResponse, and reports every judgement the conversion needed rather than only the result. The conversion follows three rules:

Narrowing to a three-state vocabulary — approved, denied, pended — is available and explicit about what it discards. Narrowing a refusal to consider yields "denied" and says in words that the correct action is a corrected resubmission rather than an appeal.

Checked against PAS 2.2.1, not only against itself

The ClaimResponse is shaped to the Da Vinci PAS 2.2.1 profile and was checked with the official HL7 validator loaded with the published PAS package. The first version had only been checked by Perfuse's own tests, and the validator rejected it with six to eight errors per resource. Every one of them is something a payer's PAS endpoint would also have rejected:

What was wrongWhere PAS puts it
The decision was in adjudication.category and reason.The reviewAction extension, on an adjudication whose category is submitted.
The reason code named X12 element 1034.X12 external code list 886.
A pended request answered outcome: queued.outcome is about processing and PAS allows only complete, error and partial. A pend is complete, with review action A4.
processNote.type was a CodeableConcept.R4 has a code: print.
patient, created, insurer and request were missing.All four are required. They are given as identifier references, because the response stands alone rather than in a bundle.

The corrected output validates with no errors for a certification, a denial and a pend. The warnings that remain are the validator saying it cannot load X12's code systems, which X12 licenses. The authorisation number is carried in reviewAction.number, where the guide's own example puts it, and in preAuthRef when the response has only one.

The validator is not part of make check, because it needs Java and a download of the guide and its dependencies. What it found is held by a test instead: each rule it enforced is asserted, so a change that reintroduces one of those errors fails.

The Claims & auth view converts a pasted 278 and shows the decisions, the notes and the resource.

8.3.7 Attachments, and why they need special handling

CMS-0053-F makes the 275 a HIPAA standard for claims attachments, with compliance required by 26 May 2028. It carries a document — an operative note, an imaging report, a scanned form — tied to the claim it supports.

The 277 is the other half of the same conversation. A payer receives a claim, decides it needs documentation, and sends a 277 asking for it; the provider answers with a 275.

A channel can route on the status code directly, because STC01 is a composite and its second component is the status. Written in canonical form, because a component is addressed with a dot and the two notations cannot be mixed — STC-1.2 is element 1, component 2:

filter: STC-1.2 in ("227", "233", "252", "287")

Those are the codes that unambiguously mean a document is wanted. The status category in STC-1.1 is deliberately not used on its own: R4 means a documentation request in some implementation guides and "forwarded elsewhere" in others, so a filter on the category alone would put finalised claims in the queue for somebody to chase.

Two identifiers decide whether an attachment is ever associated with its claim, and both are reported: the attachment control number, which the provider put on the original claim to say documentation would follow, and the trace number, which matches the payer's request. An attachment arriving with neither is filed and never connected to anything, while the claim ages.

A document inside a 275 is not interpreted. Perfuse reports its bytes and what the sender says they are. An attachment may be a PDF, a TIFF, a CDA document or an HL7 message, and a channel needing the content can hand those bytes to the format that reads it.

The 006020 form, which is the one the rule adopts

CMS-0053-F adopts version 006020, and its 275 differs from the 005010 form in two ways that matter.

Perfuse unwraps the entity and reports the document inside it, with its content type and file name. The bytes it reports are exactly the ones the sender wrapped. An entity that cannot be unwrapped leaves the raw payload in place, with a note saying why.

Building a 275. Perfuse also builds 006020X314 attachments: an unsolicited one to accompany a claim, or a response to a payer's 277 request. The difference is BGN01 02 against 11, and whether TRN02 carries the claim's PWK06 or the 277's TRN02. Every built interchange is parsed back before it is returned, and the response says whether the document inside is the one that went in.

The structure comes from the two published companion guides for 006020X314: the CMS esMD guide and UnitedHealthcare's. It does not come from the X12 technical report, which X12 licenses and which this project does not hold.

There is no validation against the technical report. The partner's companion guide governs.

8.3.8 Electronic signatures on attachments

CMS-0053-F also adopts a signature standard, at 45 CFR 162.2002(e): the HL7 Implementation Guide for CDA Release 2, Digital Signatures and Delegation of Rights, Release 1. It applies to signatures put on attachment information when it is sent in a HIPAA attachment transaction, from 26 May 2028. A payer may not ask for more than the guide does.

Perfuse signs a C-CDA that way on its way into a 275, and checks the signatures on every C-CDA in a 275 it reads.

If any of that is unavailable, the signature is still made and holds. The result names the XAdES form it reached, and says what stopped it short of X-L: for example a self-signed certificate (no CA to ask) or no time-stamping authority.

The key is -signing-cert and -signing-key. Put the CA certificates after the signer's in the same file so the path travels with the signature. For a signature that means what DSDR means, the certificate should name the clinician, with their NPI, rather than the server. Signing is an admin action, as signing any document is.

The guide does not name a namespace for its own elements. Perfuse puts digitalSignature, authorizedSigner and SignaturePurpose in urn:hl7-org:sdtc. It also states the purpose as a XAdES commitment type with the ASTM OID, which XAdES tools understand. Delegation of rights, where a delegate signs with a SAML assertion from the authorised signer, is not produced, and a received one is reported as not verified.

The provider NPI is checked against its check digit. A payer matches an attachment to its claim partly on that number, and a mistyped one leaves the attachment unmatched while the claim is denied for missing documentation.

The Claims & auth view builds a 275 from a pasted C-CDA or an uploaded file, optionally signing the C-CDA, and reads one back with each signature's findings. Nothing on that page sends anything: the interchange is handed back to be delivered through a channel, which is where the partner, the credentials and the audit trail belong.

8.3.9 Binary segments

A BIN segment carries raw bytes, and raw bytes contain delimiters. Every other X12 segment ends at the segment terminator, so an ordinary parser finds segments by searching for one byte; an attachment cannot be found that way. A PDF containing a ~ would be cut into pieces, and each piece is a structurally valid segment — so the document is truncated, every segment after it is misaligned, and nothing reports an error.

Perfuse reads the byte count the segment declares and takes exactly that many bytes. Two consequences worth knowing:

The esMD guide's own sample interchange declares 16 bytes for the same 53-byte payload that its table correctly counts as 53. Perfuse refuses that sample, as it should. A test holds both the table's example and the sample's typo, so a change in either direction is noticed.

8.4 FHIR

8.4.1 What an HL7 v2 message becomes

The message type decides what is produced, and every conversion reports the judgements it needed as notes beside the bundle.

v2FHIR
ADTPatient, an Encounter when there is a visit, a Condition per DG1 diagnosis (also listed in the Encounter's diagnosis, an admitting one as AD), a Procedure per PR1 and an AllergyIntolerance per AL1. A28, A31, A29 and A24 are about the person, so they produce no Encounter or diagnoses, but do carry allergies.
ORUDiagnosticReport per OBR, with its Observations and Specimen, and the order it answers as a ServiceRequest (its requester OBR-16 or ORC-12, its status ORC-5); OBR-32 is the results interpreter, OBR-34 and OBR-35 performers. Results are grouped under the OBR before them by position, because OBX-4 is not reliable.
ORM, OML, OMGServiceRequest.
SIUAppointment, with the patient, the practitioners from AIP and the locations from AIL.
MDMDocumentReference carrying the document.
VXUImmunization, one per RXA.

Anything else produces the Patient and a warning naming the type that was not mapped.

Scheduling. A booking, a reschedule, a cancellation and a no-show all land on the same Appointment. Its identity comes from the appointment's own numbers in SCH-1 and SCH-2, never from the message, so a cancellation updates the booking. The alternative would be a second, cancelled appointment beside a live one, which shows the patient booked into a slot that no longer exists.

Documents. Text notes (TX, FT, ST) keep their line breaks, including blank lines, because a blank repetition in a note is a paragraph break.

This is what CMS Interoperability Framework criterion 14 asks for: chart notes and clinical documents returned as FHIR attachments.

Immunizations. A dose is never reported as given when the message says otherwise.

Awkward input. Each of these is reported in the notes rather than decided silently:

Identifier.period. An assigning authority carrying an ISO OID (AUSHIC&2.16.840.1.113883.3.879&ISO) becomes urn:oid:. A type code outside HL7 table 0203 is kept as text, not labelled as one.

other event, such as an A08, nothing is merged and a warning says the prior identifier is still live.

timezone, otherwise the offset on MSH-7 (v2 presumes the sender's local time), otherwise UTC. A time of birth is the exception: with no offset from any of the three it is left out rather than labelled UTC, because a wrong offset can move a birth to another day. birthDate is kept, and a warning says the time was dropped and that setting a timezone for the feed keeps it.

DocumentReference that the Observation points to in derivedFrom, since an R4 Observation has no attachment value. An SN comparator of = is dropped as redundant; <> keeps the text, because no quantity can say "not equal".

flavor UNK, because R4 requires a class.

own and a partner's, as Dutch and Flemish names are sent - family is the whole name and the parts go in HL7's family-name extensions (humanname-own-prefix, -own-name, -partner-prefix, -partner-name).

anaesthetist (PR1-8) and surgeon (PR1-11) are performers with SNOMED CT roles. An OPS code also gets its SNOMED CT category from its chapter, by HL7 Deutschland's OPS-SNOMED-Category map, which ISiK requires.

8.4.2 What a site adds: the bundle script

A national profile often wants what an HL7 v2 message does not carry: a hospital's IK number or ODS code, which contact level a visit is (ISiK), whether a given name is a first name or an initial (the Dutch nl-core). Perfuse does not guess those. A fhir destination's bundle_script is where a site writes them: JavaScript run on each converted Bundle, as bundle, before it is validated and sent. What the script leaves is what goes out, checked like any conversion, and a script that throws fails the message as any transformer does. The console has it under the destination's *Add what your systems know*.

destinations:
  - name: kis
    type: fhir
    fhir:
      url: https://fhir.klinikum.example/fhir
      version: "4.0.1"
      identifier_systems: {KLINIKUM-MUSTERSTADT: "https://fhir.klinikum-musterstadt.example/sid/patientennummer"}
      bundle_script: |
        bundle.entry.forEach(function (e) {
          if (e.resource.resourceType === "Organization" && e.resource.name === "KLINIKUM-MUSTERSTADT") {
            e.resource.identifier = [{system: "http://fhir.de/sid/arge-ik/iknr", value: "260326822"}];
          }
        });

perfuse fhir convert -bundle-script FILE runs the same script, so a site can try it on its own messages first. Two worked examples are in the repository: examples/isik/bundle-script.js (IKNR, Kontaktebene, MR-typed patient number, Diagnosetyp) and examples/nictiz/bundle-script.js (first names and initials). With them the invented German and Dutch messages in conformance/national go from 18 to 42 of 47 resources valid against ISiK and from 12 to 18 of 24 against nl-core; what is left is in the messages or the terminology server, and is listed there.

The Bundle goes through Perfuse's model on its way back, so a script's changes are checked and written in the destination's FHIR version. Members the model has no field for are kept as the script wrote them, inside data types too.

8.4.3 US Core 9.0.0 and USCDI v6

-us-core (and claim_us_core on a fhir destination) puts a US Core profile in meta.profile, but only on a resource that has been checked against that profile and found to conform. One that falls short is still converted and sent without the claim, and a note gives the reason, such as "US Core requires an encounter type, and this Encounter has none". The checks are transcribed from US Core 9.0.0, the version ONC's 2026 Standards Version Advancement Process approves for USCDI v6. They cover Patient, Encounter, the laboratory Observation and DiagnosticReport, Specimen, Practitioner, Organization, Location, Immunization, DocumentReference, the encounter-diagnosis Condition and AllergyIntolerance.

What the converter carries, by USCDI v6 data class:

USCDI v6FromAs
Patient DemographicsPIDName, birth date, sex, address, phone and email, identifiers. Race and ethnicity (PID-10, PID-22) in US Core's extensions when coded in CDC Race and Ethnicity. Preferred language (PID-15) as BCP 47
Encounter InformationPV1Encounter with type, class, period, location and participants
Facility InformationPV1-3Location, with the facility as its managing Organization
Problems, Date of OnsetDG1Condition (encounter diagnosis). DG1-5 is the onset, as the HL7 v2-to-FHIR map has it
Allergies and Intolerances, ReactionAL1AllergyIntolerance with category, criticality and reaction
LaboratoryORUDiagnosticReport, Observations, Specimen (from SPM), ServiceRequest
OrdersORM, OMLServiceRequest
ImmunizationsVXUImmunization
Clinical NotesMDMDocumentReference
Care Team MembersPV1, TXA, DG1Practitioner

Not carried, because v2 messages of these types do not hold them or Perfuse does not map them: Medications, Procedures, Goals and Preferences, Health Status Assessments, Care Plan, Family Health History, Medical Devices (UDI), Health Insurance Information, Vital Signs as a distinct profile, and Provenance.

8.4.4 R4 in both directions

A FHIR server declaring R4 accepts R4. That was not true of Encounters until this release. The server converted what it served to R4 but read what it received as if it were already in its internal form, so an R4 Encounter was refused with a 400. So was every bundle Perfuse's own ADT conversion produced, because each carries Encounter.class, which R4 writes as a single coding.

It was found the first time anything posted a converted ADT to Perfuse's own R4 server. A test now does that for every mapped message type, and another checks that everything written as R4 is read back unchanged.

8.4.5 International Patient Summary

GET /fhir/Patient/{id}/$summary, or GET /fhir/Patient/$summary?identifier=system|value, returns an International Patient Summary (HL7 IPS 2.0.0): a FHIR document Bundle whose Composition lists the patient's active problems, allergies, medicines, immunizations, results, vital signs, procedures, devices, past illnesses, social history and care plans, each with a readable narrative. It is the short record a clinician who has never met the patient reads first, and the summary the European Health Data Space exchanges between countries from 2029.

It is built from what the FHIR store holds, and nothing is invented:

carries the coded empty reason unavailable and says the server holds no information, "which does not mean there is none". An empty allergy list must never read as "no known allergies".

Access follows $everything: a token limited to one patient gets a 403 for another, and an identifier that matches two patients is refused rather than guessed. Only the IPS profile is built; asking for another profile is refused. The document Perfuse produces from its test records validates against the IPS 2.0.0 Bundle profile with the HL7 validator, with no errors. A record built from real feeds can still fail where the feed's own codes do (a display name the code system does not use, for example), because the summary passes the data on as it was received.

Exchange → Health links → Share fills the content with a patient's summary: type the patient's FHIR id and choose Use their patient summary, then read it and create the link. Building it is audited as fhir.summary.

8.4.6 Transfer summaries for post-acute care (PACIO)

When a patient leaves hospital for a nursing home, a rehabilitation unit or home health, the hospital's EHR sends a C-CDA: a transfer summary, a discharge summary or a CCD. Perfuse turns that document into the transfer summary PACIO's Transitions of Care guide describes (hl7.fhir.us.pacio-toc 1.0.0): one FHIR R4 document Bundle, US Core 8.0.1 inside, that the receiving facility's system can file without anyone reading XML.

allergies, behavioral health, functional status, immunizations, discharge instructions, medical devices, medications, plan of care, problems, procedures, reason for referral, results, social history and vital signs.

unavailable and the sentence "The sending document had no ... section". An empty allergy section must not read as "no known allergies".

status, behavioral health (the C-CDA's mental status section), advance directives, medical devices, plan of care, discharge instructions, reason for referral and social history are sent this way today; the notes say so for each.

problems and discharge diagnoses (US Core Condition, problem list or encounter diagnosis), procedures, immunizations, results, vital signs, and medications as a PACIO medication list of MedicationStatements (104204-3, discharged medication list, from a discharge medications section; 104202-7, active medication list, otherwise).

(85354-9) with two components, and a more specific code (body weight measured, 3141-9) also carries the code FHIR's vital-signs profiles fix (29463-7), beside the original.

digit is valid), its custodian the custodian Organization, its encompassing encounter the stay the summary closes. A document with no person as author, or no custodian, is refused: the guide requires both, and Perfuse will not put a name on a clinical record that nobody gave.

negated allergy to one drug is refused, because FHIR cannot say it and the resource would say the opposite; a negated problem is refuted.

Three ways to make one:

Bundle endpoint (url), or writes <document>.transfer.json to dir. In the channel builder: Emit › A PACIO transfer summary.

Checked with the official FHIR validator against the TOC 1.0.0 Bundle profile, on HL7's own C-CDA examples (a transfer summary, a discharge summary and a CCD). The one error it reports is in the guide: every vital sign matches both its simple-observation and its vital-signs Bundle entry slices, so it cannot be avoided by any sender. Not yet done: turning the text-only sections' entries (functional status, mental status, plan of care, devices) into coded resources, and the guide's TOC DocumentReference that indexes a stored transfer summary.

8.4.7 Subscriptions

With -fhir-subscriptions, the FHIR endpoint sends notifications when an Encounter or an Appointment is created or changed. It follows the FHIR Subscriptions R5 Backport IG for R4, which is what CMS Interoperability Framework criterion 15 and TEFCA's FHIR roadmap use for appointment and encounter notifications.

A client creates a Subscription naming a topic, and optionally a filter such as Encounter?patient=Patient/123. The topics are listed in the capability statement:

TopicFires onFilters
https://perfuse.health/fhir/SubscriptionTopic/encounterEncounter created or updatedpatient, status, class
https://perfuse.health/fhir/SubscriptionTopic/appointmentAppointment created or updatedpatient, status

A filter that names anything else is refused, not ignored. An ignored filter is a subscription that notifies about every patient when the client asked about one.

The properties the implementation is built around:

What a client can make the server do is limited deliberately:

It is off unless asked for, because once on, a FHIR client can make this server send patient data to a URL of the client's choosing. It needs an R4 endpoint that accepts writes, and refuses to start otherwise.

Not implemented, so nobody has to find out by trying:

The Subscriptions view shows each subscription's topic, filter and endpoint, how many events it has had, how many were accepted and how many are waiting, and the last reason one was refused. Header values and the endpoint's query string are withheld, because that is where a subscriber's credentials live.

8.5 NCPDP Telecommunication Standard

Pharmacy claims. Fixed-width header followed by variable segments.

Two details that matter, both of which are easy to get wrong and produce plausible results:

The header is exactly 56 bytes and fixed width. A short header cannot be detected by length alone, because a truncated header plus the following segments still exceeds 56 bytes. Detection is that a correct header never contains a separator byte, so the header is scanned for one and a message that has one is refused with the number of bytes it is short by.

0x1E and 0x1C are group and field start markers, not separators. Treating them as separators shifts every field one place left, and most of the resulting values stay plausible — a quantity lands where a day supply was expected and both are numbers.

NCPDP and SCRIPT channels are excluded from MLLP framing, and this is enforced rather than advised. A claim legitimately contains 0x1C, which is MLLP's own end-of-block byte, so framing a claim would truncate it at the first field marker.

8.6 NCPDP SCRIPT

Electronic prescriptions, in XML. The internal package is called eprescribe because script was taken by the JavaScript engine.

The central safety concern is the substitution flag, and it reads backwards from the intuition: on the wire, 0 means substitution is allowed and 1 means dispense as written.

An absent substitution flag is refused rather than defaulted. Both possible defaults reach the patient — one may dispense a generic where the prescriber required the brand, the other prevents a substitution the prescriber permitted — so there is no safe assumption and the message stops.

A Schedule II prescription with refills is refused, and the builder will not produce one.

Refusals and warnings are kept as separate lists rather than one severity-tagged list, because a reader who has to check a severity field to know whether the message was sent will eventually not check it.

Electronic prior authorization (PAInitiationRequest, PARequest, PAAppealRequest, PACancelRequest) and Real-Time Prescription Benefit are answered by the payer side at /ncpdp/script and /ncpdp/rtpb, from the drug formulary; see CMS-0057.

8.7 DICOM

Imaging. Perfuse can receive DICOM objects, query a remote archive, and store to one, over the classic DICOM network services or over DICOMweb, and it can keep an archive of its own for viewers.

DICOM is not a message format in the same sense as the others — objects are large, and the association handshake matters more than the payload parsing. The called_ae and calling_ae titles are the commonest reason a first connection fails, and the failure looks like a network problem rather than a configuration one.

8.7.1 What a C-STORE listener accepts

Left open, a DICOM listener accepts anything from anyone that can reach the port. Four settings under What this listener accepts narrow that, and the first is the one worth understanding.

Only accept these calling AE titles is the restriction. The *called* AE title — the one alongside the address — is the name a sender dials, so it identifies this listener rather than the sender, and it is not a restriction. Anybody who can reach the port can use the correct called AE title, because it is published to them. Without a list of permitted callers, any host on the network may push images into a clinical channel.

Only accept these SOP classes and only accept these transfer syntaxes take comma-separated UIDs. Empty accepts every kind of object and negotiates whatever compression the sender offers, which includes kinds this channel's destinations cannot handle. Refusing at the association is clearer to the sender than accepting the object and failing afterwards.

Largest object accepted is in bytes, and zero means no limit. Whole-slide images and long series run to gigabytes, so the limit is otherwise memory.

8.7.2 DICOMweb

DICOMweb is DICOM over HTTP: QIDO-RS searches, WADO-RS and WADO-URI retrieve, STOW-RS stores. Browser viewers such as OHIF speak only DICOMweb, and so do the cloud imaging services (Google Cloud Healthcare, Azure Health Data Services, AWS HealthImaging). Perfuse uses it in three places.

The imaging archive. Start the server with -dicomweb-archive DIR (Settings › Data › Imaging archive) and it keeps objects in that folder, indexed in SQLite, and serves them at /dicomweb on the console's own address: QIDO-RS at the study, series and instance levels, WADO-RS for objects, metadata, frames and bulk data, WADO-URI at /dicomweb/wado, and STOW-RS. Reading takes the viewer role and storing the operator role, with a session or an API token; every read and store is in Activity as dicomweb.read or dicomweb.store. An account limited to a tenant or to channel groups is refused, because the archive belongs to the whole server. Objects are kept exactly as received, so what a viewer retrieves is byte for byte what was stored. Frames are returned in the transfer syntax they were stored in; a request that would need transcoding answers 406 rather than a converted image.

A search on an attribute the archive does not index answers 400 rather than ignoring the condition and returning more studies than were asked for. Indexed: patient name (wildcards, and fuzzy matching with fuzzymatching=true), patient ID, birth date, sex, study date and time (ranges), accession number, study ID, description, referring physician, study, series and SOP instance UIDs (lists), modality, body part, series number and description, SOP class, instance number, rows, columns and number of frames.

The source. type: dicomweb listens for STOW-RS, at /dicom-web/studies by default (the path Orthanc and dcm4chee use), and sends every object through the channel as a DICOM message. The channel needs dataType: dicom. Set a bearer token or a username and password: without one, anybody who can reach the port can send images into the channel. The answer lists each object as stored or failed, with the DICOM failure reason, so a sender knows which to resend.

name: imaging-from-viewers
dataType: dicom
source:
  type: dicomweb
  dicomweb:
    listen: 0.0.0.0:8043
    token: ${STOW_TOKEN}
    tls: { enabled: true, cert_file: /etc/perfuse/imaging.crt, key_file: /etc/perfuse/imaging.key }
destinations:
  - name: archive
    type: dicomweb
    dicomweb:
      url: https://perfuse.example/dicomweb
      token: ${ARCHIVE_TOKEN}

The destination is in delivery, under DICOMweb archives.

Pointing OHIF at the archive

Use a DICOMweb data source with qidoRoot and wadoRoot set to the archive's address (the Imaging screen shows it, with the whole data-source block to copy), wadoUriRoot to that address followed by /wado, imageRendering and thumbnailRendering to wadors, and supportsFuzzyMatching true. A viewer on another origin needs that origin in -dicomweb-cors (Browser origins allowed to call it), and its requests need an API token in the Authorization header, because a browser on another origin does not send the console's session cookie. Serving OHIF from the same origin through a reverse proxy avoids both.

Tested against Orthanc with its DICOMweb plugin, in both directions: the destination stores into Orthanc, and Orthanc's DICOMweb client searches Perfuse's archive and retrieves a study byte-identical to the one stored. OHIF 3 lists the archive's studies and displays their frames with the settings above. OHIF and several other clients send type= unquoted in the Accept and Content-Type headers, which RFC 2045 does not allow; Perfuse accepts it.

8.8 IHE document sharing: XDS.b, XCA, XDR, MHD, PIXm and PDQm

Clinical documents shared between organisations the way health information exchanges, Carequality and eHealth Exchange do: a repository holds them, a registry indexes their metadata, and a community answers other communities' queries through a gateway. Start the server with -xds REPOSITORY-OID (Settings › Data › Document sharing) and it is all three, in this server's database, at these addresses, each taking an API token as a bearer token (the viewer role to query and retrieve, the operator role to submit; most affinity domains add mutual TLS in front):

AddressActorTransactions
/xds/repositoryDocument RepositoryITI-41 Provide and Register Document Set-b, ITI-43 Retrieve Document Set
/xds/registryDocument RegistryITI-18 Registry Stored Query (all thirteen stored queries), ITI-42 Register Document Set-b
/xca/gatewayXCA Responding GatewayITI-38 Cross Gateway Query, ITI-39 Cross Gateway Retrieve, with -xds-home-community urn:oid:...
/mhdMHD Document Recipient and ResponderITI-65 Provide Document Bundle, ITI-66 Find Lists, ITI-67 Find Document References, ITI-68 Retrieve Document
/mhd/PatientPDQm Supplier and PIXm ManagerITI-78 Patient Demographics Query, ITI-83 Patient/$ihe-pix, from the FHIR store's Patients
/pixv3PIX Manager (HL7 v3)ITI-44 Patient Identity Feed HL7 V3, ITI-45 PIXV3 Query, from the patient index (-mpi)
/pdqv3PDQ Supplier (HL7 v3)ITI-47 PDQV3 Query, with continuation and cancel, from the patient index

The SOAP transactions are SOAP 1.2 with WS-Addressing, and MTOM/XOP where documents travel (a document inline as base64 is accepted too). A failed transaction is answered with a RegistryResponse carrying IHE's error codes, not a SOAP fault: XDSDuplicateUniqueIdInRegistry, XDSNonIdenticalHash, XDSPatientIdDoesNotMatch, XDSMissingDocument, XDSRegistryMetadataError (with the field that is missing), XDSStoredQueryMissingParam, XDSUnknownStoredQuery, XDSUnknownRepositoryId, XDSUnknownCommunity, and UnresolvedReferenceException. A replacement (RPLC, XFRM_RPLC) deprecates the document it replaces, which then answers only queries asking for deprecated documents; APND, XFRM and signs are kept as associations. Hash and size are computed by the repository and checked against what the submission says. On-demand documents and metadata update (ITI-57) are not supported, and a submission asking for them is refused with the reason.

MHD is the same registry in FHIR: a document provided over MHD is found by ITI-18 and over XCA, and one provided over XDS.b is found by ITI-67. A DocumentReference's id is the entry's UUID, its masterIdentifier the uniqueId, and its attachment's url the ITI-68 address of the document. Patients are resolved to the affinity domain's identifier from the FHIR store: the first identifier whose system is urn:oid:, or the Patient carried in the Bundle. PIXm answers every other identifier of the Patient with the one given (sourceIdentifier=system|value, narrowed by targetSystem), and 404 for one it does not know; PDQm searches by identifier, name, birth date, gender, address and telecom.

Publishing. A channel's xds destination publishes each clinical document it receives, a CDA document or one carried in an HL7 v2 message, to any XDS.b repository with ITI-41. The metadata is read from the document: the patient (the identifier in patient_domain, the affinity domain's assigning authority), title, type (as typeCode and, unless set, classCode), creation and service times, author, confidentiality, language and format (C-CDA 2.1 or 1.1 structured body, XDS-SD, or mimeTypeSufficient, from its templates); and completed from what a document does not say: the sender's source_id, the facility_type and practice_setting every registry requires. The repository's RegistryResponse decides the delivery: a Failure, or an error in a PartialSuccess, is a failed delivery with the registry's error codes.

destinations:
  - name: hie
    type: xds
    xds:
      url: https://hie.example/xds/repository
      source_id: 1.2.840.114350.1.13.99
      patient_domain: 1.2.840.114350.1.13.99.2.7.5.737384.14
      facility_type: { code: "225732001", scheme: 2.16.840.1.113883.6.96, display: Community hospital }
      practice_setting: { code: "394802001", scheme: 2.16.840.1.113883.6.96, display: General medicine }
      tls: { enabled: true, cert_file: /etc/perfuse/hie.crt, key_file: /etc/perfuse/hie.key }

XDR. Add direct_from (your Direct address) and direct_to (the recipients') and the same destination sends XDR as Direct's transport, as the Direct Project's *XDR and XDM for Direct Messaging* describes it: a Direct addressing block (urn:direct:addressing) in the SOAP header names sender and recipients, and the submission's intendedRecipient names each recipient (||^^Internet^address). Point url at the recipient's XDR endpoint or a HISP that takes XDR. XDR travels over TLS, usually with a client certificate (tls), and is not S/MIME. A direct source takes XDR too (see Direct below).

    xds:
      url: https://xdr.hisp.example/xdr
      source_id: 1.2.840.114350.1.13.99
      patient_domain: 1.2.840.114350.1.13.99.2.7.5.737384.14
      facility_type: { code: "225732001", scheme: 2.16.840.1.113883.6.96, display: Community hospital }
      practice_setting: { code: "394802001", scheme: 2.16.840.1.113883.6.96, display: General medicine }
      direct_from: referrals@direct.hospital.example
      direct_to: ["intake@direct.clinic.example"]
      tls: { enabled: true, cert_file: /etc/perfuse/xdr.crt, key_file: /etc/perfuse/xdr.key }

PIX and PDQ in HL7 version 3. With the patient index on (-mpi), /pixv3 is a PIX Manager and /pdqv3 a PDQ Supplier over SOAP 1.2, for the hospitals and exchanges that still speak IHE's v3 profiles. A source feeds patients with ITI-44 (record added PRPA_IN201301UV02, revised PRPA_IN201302UV02, duplicates resolved PRPA_IN201304UV02): the patient's first identifier is its record, the others and those in asOtherIDs are identifiers it carries, and a merge folds the prior record's identifier into the surviving one, as an HL7 v2 A40 does. ITI-45 (PRPA_IN201309UV02) answers the other identifiers of the person holding one, narrowed by dataSource, in Patient.id; the queried identifier comes back only in the repeated query. ITI-47 (PRPA_IN201305UV02) matches demographics (name, gender, birth time, identifier, address, telecom) with the index's own matcher, minimumDegreeMatch as a score out of 100, otherIDsScopingOrganization limiting the domains, and initialQuantity paging the answer: the rest comes with QUQI_IN000003UV01 for ten minutes, or is dropped by its cancel. The answers follow the transactions' cases: an unknown patient or domain is AE with error 204 and the location of the parameter, a known patient with nothing in the domains asked for is NF. v3 names an authority by OID only, so an identifier whose system is a URL with no OID behind it (some national numbers) is left out of v3 answers and given over FHIR; set the enterprise identifier's system to an OID (urn:oid:..., in the index settings) for the EUID and the custodian to appear. The viewer role queries and the operator role feeds; every transaction is audited with an identifier, never a name. A bare v3 message posted as application/xml is answered in kind, which is handy for testing by hand.

Exchange › Document sharing shows what the registry holds and where it is reached, finds a patient's documents in this registry or another (a registry's ITI-18 or a gateway's ITI-38), downloads one, and publishes a CDA document by hand with the metadata a destination would give it.

Verified against the NIST XDS Toolkit 7.13.0, the IHE reference implementation: Perfuse's ITI-41, ITI-18 and ITI-43 against its Registry/Repository simulator (accepted with no validation errors, the document retrieved byte-identical), and its Initiating Gateway simulator querying and retrieving from Perfuse's Responding Gateway over XCA, with no errors in its log. Every SOAP message Perfuse writes validates against the ebXML Registry 3.0 and XDS.b schemas, and the MHD DocumentReference and SubmissionSet against IHE MHD 4.2.2 with the HL7 FHIR validator. XDR from an xds destination with a Direct addressing block was accepted by the toolkit's Document Recipient simulator, whose metadata validation found the one defect described in docs/verification.md, and by ONC SITE's XDR validator, the Direct XDR checklist the (h)(2) certification test applies. The PIX and PDQ v3 answers validate against IHE's HL7 V3 schemas (NE2008 multicacheschemas), and IHE's own sample messages (the ITI-44 feed, merge, ITI-45 query, ITI-47 query, continuation and cancel) are answered as their published responses show. Not yet checked against an independent PIX or PDQ v3 implementation, Gazelle or a Connectathon.

8.9 Direct secure messaging

Direct is how US providers send each other referrals, summaries and results by secure e-mail: every message is signed with the sender's certificate and encrypted to the recipient's (S/MIME), and travels between HISPs (Health Information Service Providers) that issue the addresses and vouch for them. Perfuse is the edge system beside your HISP, not a HISP: the HISP issues your address, its certificate and its key, and you keep its trust bundle (such as DirectTrust's). It follows the Direct Applicability Statement for Secure Health Transport 1.2, the Implementation Guide for Delivery Notification, IHE XDM (ITI TF-1 Annex 15) for documents with metadata, the Certificate Discovery for Direct Project Implementation Guide (DNS and LDAP), and XDR and XDM for Direct Messaging.

Sending. A direct destination signs each message as your address (SHA-256, RSA), encrypts it to every recipient's certificate and your own (AES-256-CBC, RSA key transport), and submits it to the HISP by SMTP with STARTTLS required. The message travels as an attachment named by its content (document.xml, message.hl7, resource.json), or, with xdm: true, a CDA document travels as an IHE XDM package (IHE_XDM/SUBSET01/METADATA.XML with the document and its hash and size, plus INDEX.HTM and README.TXT) that receiving EHRs import with its metadata. Recipient certificates come from the Direct address book (below), then from DNS, where Direct publishes them as CERT records (RFC 4398): under the address with its @ made a dot, else under its domain; then from the recipient domain's LDAP directory, which its _ldap._tcp SRV record names (or ldap: host:port; ldap: off never asks one): each naming context is searched anonymously for an entry whose mail is the address, then the domain, and its userSMIMECertificate or userCertificate read. A certificate is used only when it is for the address or its domain, has not expired, and chains to one of your anchors, fetching any intermediate issuer its Authority Information Access extension names; and it is refused when its issuer's revocation list (from its CRL distribution point, signed by that issuer) lists it. A list that cannot be fetched does not stop delivery. A recipient with no such certificate is a failed delivery, never plain e-mail. to and subject take field references such as {PV1-8.1}, and request_mdn asks the recipient for a delivery notification.

destinations:
  - name: referrals
    type: direct
    direct:
      address: referrals@direct.hospital.example
      cert_file: /etc/perfuse/direct/referrals.crt   # with its chain after it
      key_file: /etc/perfuse/direct/referrals.key
      anchors_file: /etc/perfuse/direct/anchors.pem
      to: ["intake@direct.clinic.example"]
      subject: "Referral for {PID-5.1}"
      text: "Please see the attached summary."
      xdm: true
      xdm_source_id: 1.2.840.114350.1.13.99
      smtp_host: smtp.hisp.example:587
      username: referrals
      password: ${DIRECT_HISP_PASSWORD}
      request_mdn: true

Receiving. A direct source is the SMTP server the HISP delivers your address's mail to. It takes mail for that address only (anything else is refused at RCPT), requires STARTTLS when given a certificate, and opens each message before anything reads it: decrypts it with your key, verifies the signature against your anchors, and checks that the signing certificate is for the From address or its domain. A message that is not encrypted S/MIME, is signed by a sender you do not trust, or is signed by a certificate for someone else is refused at DATA with the reason, so the HISP keeps it and reports it to the sender; Perfuse never accepts what it then drops. An XDM package's documents each become a message (checked against the hashes in its metadata); otherwise each attachment does, or the text when there is none. With smtp_host set, each message taken in is answered with a signed, encrypted "processed" notification (MDN), and notifications arriving for what you sent are logged.

source:
  type: direct
  direct:
    address: intake@direct.clinic.example
    cert_file: /etc/perfuse/direct/intake.crt
    key_file: /etc/perfuse/direct/intake.key
    anchors_file: /etc/perfuse/direct/anchors.pem
    listen: 0.0.0.0:2525
    tls_cert: /etc/perfuse/tls/server.crt
    tls_key: /etc/perfuse/tls/server.key
    smtp_host: smtp.hisp.example:587

From the HISP's mailbox. A HISP that keeps mail rather than delivering it gives the address an IMAP mailbox: imap reads it (TLS or STARTTLS, every poll a fresh session), opens each message as above, moves what was taken to move_to (or deletes it) and what could not be opened to error_folder, so nothing is read twice. listen may then be left out. A HISP whose mail server has a private certificate is trusted with tls.ca_file (its certificate, or its authority's) and tls.server_name; verification cannot be turned off.

    imap:
      host: imap.hisp.example
      username: intake@direct.clinic.example
      password: ${DIRECT_MAILBOX_PASSWORD}
      move_to: Processed

XDR. xdr takes XDR at /xdr on its own HTTPS address, as a HISP sends Direct it converted to SOAP or a partner sends point to point: an ITI-41 Provide and Register request whose documents each become a message. A submission whose Direct addressing block names another address is refused, and one the channel does not take is answered with a RegistryResponse failure saying why. client_ca_file requires a client certificate from those authorities. XDR is protected by TLS, not S/MIME, so a source that only takes XDR needs the address but not its certificate, key or anchors. Each document becomes a message of the channel's dataType: CDA documents need raw (passed on as they are) or hl7v3, since the default, hl7, expects HL7 v2 and refuses them.

    xdr:
      listen: 0.0.0.0:8445
      tls_cert: /etc/perfuse/tls/server.crt
      tls_key: /etc/perfuse/tls/server.key
      client_ca_file: /etc/perfuse/direct/hisp-clients.pem

Exchange › Direct lists the Direct addresses your channels send and receive as, with their certificates, expiry and anchors; keeps the address book of correspondents (name, organisation, notes and certificate, pasted from their HISP or found in DNS or LDAP, and refused when it is not for that address or domain); and looks up an address's certificates in DNS or LDAP without saving them. The book is the directory direct-certs beside the database, one PEM file per address or domain, which a destination reads unless it names its own recipient_certs_dir.

Verified against OpenSSL 4.0's cms and smime in both directions: what Perfuse signs and encrypts OpenSSL verifies and decrypts (PKCS#1 v1.5 and OAEP key transport), and Perfuse opens what OpenSSL signs and encrypts (AES-128 and AES-256). A message made with openssl smime and delivered over SMTP is received, opened and stored by a channel, and one Perfuse sends a message to another through a HISP stand-in, which answers with an MDN the sender opens. Certificates are found in OpenLDAP 2.4 (address-bound and domain-bound, written by OpenLDAP's own ldapadd); a Direct message is read from a GreenMail IMAP mailbox and opened, and one that is not Direct moved to the error folder; XDR from an xds destination is taken by a direct source and refused when addressed to someone else, and accepted by the NIST XDS Toolkit's Document Recipient and ONC SITE's XDR validator; SITE's Direct XDR Edge Test Tool sent both its minimal and full metadata messages to a direct source, which took them. Against ONC's Direct Certificate Discovery Tool, a Direct destination chooses the right certificate, or none, in all 18 of its discovery cases (D1 to D18: expired, revoked and AIA-issued certificates, SRV priority and weight, an unreachable directory). A Direct source reads a message from SITE's own HISP (Apache James) over IMAP with STARTTLS. Not yet tested: SMTP exchange with SITE's HISP (AWS holds outbound mail on port 25 for new accounts) or with an accredited HISP.

8.10 SQL on FHIR views

A ViewDefinition (HL7 SQL on FHIR v2) turns each FHIR resource of one type into rows of named columns, each column a FHIRPath expression, so FHIR data can be read as tables by DuckDB, Athena, Spark, a data warehouse or a spreadsheet. Perfuse runs them with its own FHIRPath engine: column, nested select, forEach, forEachOrNull, repeat and unionAll, where, constant (every value[x] type), collection columns, %rowIndex, and the getResourceKey() and getReferenceKey(type) join keys.

{
  "resourceType": "ViewDefinition",
  "name": "blood_pressure",
  "resource": "Observation",
  "constant": [{ "name": "systolic", "valueCode": "8480-6" }],
  "where": [{ "path": "code.coding.exists(system = 'http://loinc.org' and code = '85354-9')" }],
  "select": [{ "column": [
    { "name": "id", "path": "getResourceKey()", "type": "id" },
    { "name": "patient_id", "path": "subject.getReferenceKey(Patient)", "type": "id" },
    { "name": "systolic", "path": "component.where(code.coding.code = %systolic).value.ofType(Quantity).value.first()", "type": "decimal" }
  ]}]
}

Monitor › SQL on FHIR runs a view over this server's FHIR store (one patient, or all) or over resources pasted in (NDJSON from a bulk export, a Bundle, a JSON array), shows the rows, and downloads them as CSV, NDJSON or Parquet. It starts from five example views (patient demographics, observations with a value, blood pressure, conditions, encounters) and saves views to the views directory beside the database, one JSON file each.

The FHIR endpoint answers the specification's $sql-run at the system level, by GET or POST, for a ViewDefinition subject given inline (subjectResource), by subjectReference (ViewDefinition/<name>, a saved view) or by subjectCanonical (a saved view's url), with _format (csv, ndjson (the default), json or parquet), header, patient, _since, _limit, and resource to run over resources sent with the request instead of the store. It is a read: a SMART token needs read scope for the view's resource type, a token bound to one patient gets that patient's rows only, and granular scopes narrow the resources read, as they do a search. More than a million rows needs _limit, patient or _since.

perfuse sof -view v.json [-format csv|ndjson|json|parquet] [-o out] files... runs a view over FHIR files without a server: the NDJSON files of a bulk export, Bundles or arrays.

perfuse sof -view blood_pressure.json -format parquet -o bp.parquet export/Observation.ndjson
duckdb -c "SELECT patient_id, avg(systolic) FROM 'bp.parquet' GROUP BY 1"

Parquet files have one row group and uncompressed PLAIN pages; booleans are BOOLEAN, integer types INT64, decimals DOUBLE and everything else UTF-8 strings (a collection column as its JSON).

Verified with the official SQL on FHIR test suite (the 144 tests of the sql-on-fhir.js repository's tests/, run on every build: all pass), and the Parquet output read back by DuckDB 1.5.6 with the right types and values. Not supported: SQLQuery and SQLView Libraries (SQL over the views' tables), $sql-export, _format=fhir, and the source and group parameters; each is refused with the reason.

8.11 Digital quality measures

A digital quality measure counts the patients in each population of a FHIR Measure over a measurement period (initial population, denominator, denominator exclusions and exceptions, numerator, numerator exclusions) and scores the result. The answer is a FHIR MeasureReport, shaped as the Da Vinci Data Exchange for Quality Measures (DEQM) guide asks. This is where CMS's eCQM programmes and NCQA's digital HEDIS are heading.

In Perfuse the population criteria are FHIRPath ("language": "text/fhirpath"), run by the same engine as SQL on FHIR. Each criterion is evaluated once per patient. Its focus is a Bundle of that patient's record (the Patient compartment), and it has these variables: %subject (the Patient), %periodStart and %periodEnd (the period, as dateTimes) and %"Measurement Period". A criterion gives true or false; a single value that exists counts as true.

{
  "code": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/measure-population", "code": "numerator" }] },
  "criteria": {
    "language": "text/fhirpath",
    "expression": "entry.resource.ofType(Observation).where(code.coding.where(system = 'http://loinc.org' and code = '4548-4').exists() and effective.ofType(dateTime) >= %periodStart and effective.ofType(dateTime) <= %periodEnd).exists()"
  }
}

The populations nest as the Measure specification says. A patient is in the denominator only if in the initial population. A denominator exclusion takes the patient out before the numerator is judged. A denominator exception counts only for a patient not in the numerator. A proportion's score is (numerator − numerator exclusions) / (denominator − exclusions − exceptions); a ratio's is numerator / denominator; a cohort has counts and no score. Single-criterion stratifiers split every population by their value (sex, say).

Perfuse has no CQL engine. The published CMS eCQMs and HEDIS digital measures are written in CQL, so they cannot be run as they are. A Measure whose criteria are CQL is refused with that reason. Write the logic as FHIRPath instead. Continuous-variable scoring, multi-component stratifiers and Group or Practitioner subjects are refused the same way.

Monitor › Quality measures evaluates a measure over this server's FHIR store or over pasted resources, for a period. It offers three reports: a summary, a summary with the patients in each population, or one patient. The screen shows the populations, the score and the strata, and downloads the MeasureReport. It starts from three examples: blood pressure under control, diabetes with an HbA1c test, and patients seen in the period. They are illustrative, written for this manual, and are not CMS eCQMs or HEDIS measures. Save to the FHIR store checks the Measure and stores it as a Measure resource.

The FHIR endpoint answers Measure/[id]/$evaluate-measure and Measure/$evaluate-measure?measure=<url>, by GET or by POST of a Parameters resource. Parameters:

The report is returned, not stored. The operation is a read, so a SMART token needs read access to Measure and to Patient. A token bound to one patient gets an individual report about that patient and no other.

perfuse measure -measure m.json -period-start 2026-01-01 -period-end 2026-12-31 [-type summary|subject|subject-list] [-subject Patient/ID] [-o out] files... evaluates a measure over FHIR files (a bulk export's NDJSON, Bundles, arrays) without a server. -measure example:example-bp-control runs a shipped example, and -examples lists them.

Verified. The HL7 FHIR validator checks the shipped Measures against FHIR R4, with no errors. It checks a summary, an individual and a subject-list MeasureReport against the DEQM 5.0.0 summary, individual and subject-list profiles, also with no errors. Not verified: equivalence with any published eCQM, since none can be run here without CQL.

8.12 The patient index (MPI)

A master patient index links the records every system keeps for a person (the hospital's MRN, the clinic's, the lab's) into one enterprise identifier, the EUID. It is off until somebody decides to keep one, because it is a table of patients' names, birth dates and identifiers: turn on Settings → Data → Patient index (or serve -mpi) and restart. It lives in mpi.db beside the main database.

Feeding it. Add a destination of type mpi (The patient index in the builder) to the channels that carry registrations. It reads the PID of each HL7 v2 message, or a FHIR Patient on a raw channel. The record's own identifier is the PID-3 repetition of type MR (id_type changes it); the others, and PID-19, become its other identifiers. An assigning authority with a universal identifier (PID-3.4.2 with type ISO) becomes urn:oid:...; one with only a namespace is mapped by authorities, or becomes urn:perfuse:aa:<namespace>. An A40, A34, A18 or A39 merges the person holding MRG-1's identifier into the person holding PID-3's (no_merge ignores them).

destinations:
  - name: index
    type: mpi
    mpi:
      authorities: { HOSP: "urn:oid:1.2.840.114350.1.13.1" }

Matching. A new record is linked in this order:

  1. Trusted identifiers. A record sharing an identifier from a system listed as

trusted (a national identifier, an enterprise number) is the same person, with no scoring. Never list a system a family shares, such as a payer's subscriber number.

  1. Score. Otherwise the people whose records could match are found by blocking

(the same-sounding family name and birth year, the same birth date, or the same phone number) and scored with Fellegi-Sunter weights over family and given name (Jaro-Winkler and Soundex, accents and punctuation ignored, swapped names noticed), birth date (a day and month swapped is partial agreement), sex, postal code, phone and address. Only fields both records have count. The score is the evidence found as a share of the evidence possible. Blocking uses five keys, so one error does not hide a person: family-name sound and birth year, birth date, phone, both names' sounds (in either order), and postal code with the given name's sound.

  1. Decision. By default nothing links on a score alone. A record scoring at

or above the review threshold (60%) becomes a new person and the possible match waits on the review queue; those at or above the automatic threshold (90%) with family name and birth date both agreeing are marked *certain*. Linking two people into one (an overlay) puts one patient's results in another's chart, so automatic linking is a switch an administrator turns on (Link certain matches without asking anybody) once the queue has shown the certain ones are right. With it on, a certain match links when no second person scores as high. Below the review threshold the record is a new person.

National numbers. The NHS number (and Scotland's CHI and Northern Ireland's Health and Care number), Ireland's PPSN and IHI, Germany's KVNR, Austria's SVNR, Switzerland's AHV number, the Netherlands' BSN, Belgium's national register number, Luxembourg's matricule, France's INS (NIR and NIA), Italy's codice fiscale, Spain's DNI/NIE and CIP-SNS, Portugal's SNS number, the Nordic personal numbers (Sweden, Denmark, Norway with its D number, Finland, Iceland), the Baltic codes, Poland's PESEL, the Czech and Slovak birth numbers, and the Hungarian, Romanian, Bulgarian, Croatian, Slovenian and Greek numbers are known to the index. Each is stored one way: under its scheme's system, spaces and dots removed, so the NHS number sent as urn:oid:2.16.840.1.113883.2.1.4.1|999 000 0018 by one system and as https://fhir.nhs.uk/Id/nhs-number|9990000018 by another is one identifier. An HL7 v2 PID-3 repetition whose identifier type is NN and a country's three-letter code (NNDEU, NNNLD) is read as that country's main number, and type NH from the NHS authority as the NHS number. Every number's check digit is tested: one that fails is kept on its record, reported (masked) in the answer, and never used to link or to find anybody, since a mistyped digit usually makes somebody else's number. Denmark's CPR is checked for its date only, because numbers issued since 2007 need not pass the old modulus 11.

The settings list every scheme with its format, its check and the system it is stored under. Systems taken from the country's own FHIR guide (UK Core, fhir.de, Nictiz, CH Core, ANS, HL7 Italia, MedCom, the Swedish, Norwegian and Finnish OID registers) are marked as such; the others are defaults to change to the national value, which a site can do per scheme (the old one is still recognised). Trusting a scheme is one tick. National numbers are shown in full to editors and above by default and masked (the last few characters) to everyone else; Who sees numbers in full changes that, down to viewers or up to administrators only. Where a country's law limits a number's use (France's Référentiel INS, Ireland's PPSN, Belgium's national register number, Germany's §290 SGB V), the settings say so. GET /api/mpi/schemes lists the schemes and POST /api/mpi/check checks one number without storing it. FHIR $match and PIXm answer authorised clients with numbers in full.

A date written day- or month-first (08/15/1972) is kept as written and does not match a year-first one: the US and European orders disagree, and guessing would link the wrong people.

The Patient index screen (Build) shows the counts, finds people by name, number, EUID or birth date, and opens each person: their records, every identifier, and the history of what happened to them and who did it. Editors decide reviews (The same person links them; Different people keeps them apart for good), move a record to another person, take a record out (Not this person), and merge two people. Administrators set the thresholds, the trusted identifier systems, the national numbers and the EUID's system. Every search and every person read is written to the audit log.

Answering others. POST /fhir/Patient/$match (FHIR's operation, as IHE PDQm ITI-119 uses it) takes a Patient and returns the people who could be it, best first, each a Patient whose id is the EUID, with every identifier, search.score, and the match-grade extension (certain, probable, possible); onlyCertainMatches and count are honoured. PIXm (/mhd/Patient/$ihe-pix, ITI-83) answers from the index: every identifier of every record linked to the person, and the EUID. So do PIX and PDQ in HL7 v3 (/pixv3, /pdqv3; see IHE document sharing), which also feed it.

The API is /api/mpi (summary and search), /api/mpi/persons/{euid}, /api/mpi/find, /api/mpi/reviews, /api/mpi/records (register, link, unlink), /api/mpi/merge and /api/mpi/settings.

Weights. The default Fellegi-Sunter weights come from the record-linkage literature for hospital registration data. Estimate from our records (in the settings, administrators; POST /api/mpi/estimate) fits them to the site's own index with expectation-maximisation, no reviewed pairs needed: m (agreement among true matches) from the pairs blocking brings together, u (agreement by chance) from 20,000 pairs drawn at random, since blocked pairs agree on names and dates by construction. The estimate is shown beside the weights in use and changes nothing until it is used and saved. A field compared on too few pairs keeps its default. The fit assumes the fields are independent given the class, which they are not quite, so treat a large change with suspicion.

Measured. On FEBRL dataset 3 (5,000 synthetic records of 2,000 people, the duplicates carrying typing errors, swapped fields, missing values and nicknames; PERFUSE_TEST_FEBRL), registered in file order with a reviewer answering every queued pair correctly:

Linked on their ownPut in front of a reviewer with the right personMissedWrong links
Automatic linking off (the default)02,649 of 3,0003510
On, default weights1,6371,0123510
On, weights estimated from the records1,6221,0703080

No record was ever linked to the wrong person, and none of the 2,000 new people was queued needlessly. The missed ones differ on nearly every field (a birth date and an address both mistyped, say) and score below the review threshold. FEBRL is synthetic and has few namesakes; a real index has more, so its review queue will be longer, and these numbers are a floor on care, not a promise.

8.13 Delimited

CSV and its relatives. The options are on the source in the builder, and the format reference lists the file keys.

Paths address columns. A named column is available when a header row is declared or the names are given; otherwise columns are positional.

8.13.1 Naming the columns

There are two ways, and they are exclusive. Either the first row of the file names them — the first row names the columns — or you list them yourself, in order. Declaring a header means the row is not treated as data.

If neither is set, columns can only be addressed by position, so a filter written against a name has nothing to match. Turning the header on removes the list of names rather than keeping both: with a header the names come from the file, so a list left behind would be ignored, and a channel that reads as though it chose the names when the file chose them is harder to debug than one that refuses.

8.13.2 What a filter or a script sees

One delimited document becomes many messages, so this needs stating before you write either.

With splitting on, everything runs per row. Declarative steps, the filter and the transformer script all see one row, and a path means *this* row's column. A filter that rejects a row rejects that row and no other, and the received count is rows rather than documents.

With splitting off, the message is the whole document and a write that crosses rows is refused rather than applied to the first one. That refusal is deliberate: silently writing to row one is the behaviour that produces a file which looks transformed and is not.

8.13.3 The three that change what counts as an error

Accept rows with the wrong number of columns is off by default. A row with too many or too few columns is then an error, which is what catches a file whose shape changed without warning. Turning it on accepts the row and leaves the missing columns empty.

Trim spaces around each value fixes the common case of a value padded to a column width. Leave it off where trailing spaces are significant, which they occasionally are in an identifier.

Keep blank lines as rows is off by default and usually should be. On, a blank line becomes a row of empty columns, which matters only when a count has to match the file exactly.

8.14 Raw

No parsing at all. The message is bytes.

Use this when the content is not something Perfuse understands and does not need to: a PDF being moved between systems, a proprietary format, an already-formed document being forwarded unchanged.

The cost is that nothing which depends on structure works — no field-level transformation, no filter on a field, no field-level comparison in the parity checker. A raw channel is a pipe, and it is honest about being one.

8.15 Choosing

If the message is HL7 v2, use hl7, even if you only intend to forward it unchanged. Parsing gives you the trace, the field-level statistics, the ability to filter later and the ability to compare. raw on parseable content buys nothing and loses all of that.

Use raw when parsing would fail or would be a lie about the content.

CMS's *Kill the Clipboard* asks providers to accept a patient's records from a QR code at check-in, and to hand the visit record back the same way. The QR code holds a SMART Health Link (shlink:/...): an address, a 256-bit key and, optionally, a label, an expiry and a passcode flag. Exchange → Health links reads one.

Receiving. Paste the link, or scan it with the camera where the browser can (Chrome and Edge). Perfuse fetches the manifest, decrypts each file with the key from the link, summarises any FHIR bundle - who the patient is, how many of each resource - and checks any SMART Health Card inside against its issuer's published key. *Deliver to the channel* hands the FHIR content to a channel as a message, which is how it reaches the EHR. The fetch is held to the egress policy at connect time, plain HTTP is refused, and the response of a server that is not a manifest server is never shown, because the address came from a stranger's QR code.

Sharing. *Share* encrypts a FHIR bundle, hosts it, and shows the link and its QR code once. The server keeps the ciphertext and not the key - it cannot open what it hosts, and cannot show the link again. A passcode is hashed; ten wrong ones disable the link. Links expire after 30 days by default, can be revoked, and list how often and by whom they were fetched. Start the server with -public-url set to the https address patients' phones reach; the manifest URL it builds must fit the specification's 128 characters.

Verifying a card. *Verify a card* takes a numeric shc:/ QR (one chunk or several), a .smart-health-card file or a JWS, and says whether the signature matches the issuer's key.

8.17 UN/EDIFACT

dataType: edifact reads UN/EDIFACT interchanges (ISO 9735): ORDERS, INVOIC, DESADV, IFTSTA and the rest, including the European healthcare claim and laboratory messages. The service string advice (UNA) is honoured, with the release character and, for syntax version 4, the repetition separator; functional groups (UNG/UNE) are optional. The interchange is delivered exactly as the sender wrote it.

name: supplier-orders
dataType: edifact
edifact:
  envelope: require      # require (default), warn, or ignore
  split: false           # true: one interchange per message
  acknowledge: contrl    # none (default) or contrl
filter: 'UNB-2.1 == "5412345000013"'
source:
  type: as2              # or http, sftp, file, email ...
  # ...
destinations:
  - name: orders
    type: file
    dir: /data/orders
    filter: 'UNH-2.1 == "ORDERS"'

The envelope. UNT's segment count and reference, UNE's message count and reference, and UNZ's count and reference are compared with what the interchange holds. With require, the default, a mismatch refuses the interchange (it is how a transfer cut short shows); warn accepts it and logs a warning; ignore does not check.

Paths, for the channel and destination filters, count elements and components from 1 in the order the segment is written, as implementation guides number them: BGM-2 (the document number), BGM-1.1, NAD(2)-2.1 (the second NAD's party), LIN-3(2).1 (a repetition), UNB-2.1 (the sender), UNH-2.1 (the message type). A path without an occurrence addresses every one.

Splitting delivers one interchange per message, each with the original UNB (and group header) and trailers that count one, so an order can be filtered, searched and retried on its own. Destination filters see each piece. A file destination names files by message type (ORDERS_INVOIC-20261017.edi), and ${control_id} is the interchange reference.

CONTRL. With acknowledge: contrl, a sender posting over HTTP is answered with a CONTRL syntax and service report: UCI action 7 when the interchange was read and delivered, 4 when it was refused or could not be delivered (HTTP 400 or 502 then). It cannot be combined with split, since a CONTRL answers a whole interchange. Over AS2 the partner's receipt is the acknowledgement.

Not offered: the message directories (which segments ORDERS D.96A allows), so the structure is checked, not the content; transformations, which would rewrite a partner's document; and filter or transformer scripts (a preprocessor runs, before parsing). Checked against pydifact 0.1.8, an independent parser, in both directions.

9 Acknowledgements

An acknowledgement tells the sender what happened. For HL7 v2 over MLLP it is an ACK message; the sender will not send the next message until it arrives, and will usually resend if it does not.

Getting acknowledgements wrong is the most common way an integration silently loses data, because a sender that is being acknowledged has no reason to complain.

9.1 Timing

Covered in message flow, and the summary is: ack.when is on_delivery by default, meaning the acknowledgement is sent only after every enabled destination accepted the message. on_receipt sends it as soon as the message is parsed and queued.

9.2 What Perfuse sends

An AA when the message was accepted, an AE when it was rejected for a reason the sender could act on, and an AR when it was rejected for a reason the sender cannot.

The distinction between AE and AR is who has to do something. AE means the message had a problem — a field that would not parse as a date, a required segment missing — and the sender can correct it and resend. AR means Perfuse could not accept it regardless of content, which usually means a configuration or availability problem at this end. A sender that resends after an AR will get the same answer.

9.3 Identifying this engine

ack.application and ack.facility populate MSH-3 and MSH-4 of the acknowledgement.

When both are empty the acknowledgement mirrors the original receiver — it uses whatever the incoming message had in MSH-5 and MSH-6. That is what most senders expect, and it is the default for that reason.

Set them explicitly when the sender validates them, or when several Perfuse instances handle the same feed and you need to know which one answered.

9.4 The trigger event

ack.include_trigger_event sends ACK^A01 rather than a bare ACK.

Some receivers require it and others reject it, which is why it is an explicit option with no clever default. If a sender is refusing your acknowledgements and the content looks right, this is the first thing to try.

9.5 Negative acknowledgements are not failures to hide

A NACK treated as a success is one of the most damaging misconfigurations possible, and it is common enough to be worth naming. It happens when a destination sends back a negative acknowledgement and the sending side records the *transmission* as successful because the bytes went out and something came back.

Perfuse reads the acknowledgement it receives from an MLLP destination and records the outcome accordingly. An AE or AR from a downstream system is a delivery failure, is retried according to the destination's policy, and counts towards the failure rate that alerts fire on.

If you are migrating from another engine, this is worth verifying explicitly on your own traffic rather than assuming. The parity checker will show it: a destination that the old engine recorded as delivered and Perfuse records as failed usually means the old engine was ignoring a NACK.

9.6 Timeouts

Waiting for an acknowledgement is bounded. When it does not arrive in time the delivery is a failure and is queued for retry.

A timeout is not the same as a rejection and is recorded differently. A rejection means the far side considered the message and declined it; a timeout means you do not know whether it was processed. That distinction decides whether a retry is safe: retrying after a rejection is pointless, and retrying after a timeout risks a duplicate. Perfuse retries after a timeout, because a missing clinical message is generally worse than a duplicate one, and the receiving system is generally able to detect a repeated message control ID.

Whether that trade is right for a given feed is a judgement, and it is worth making deliberately for anything where a duplicate has a clinical consequence — an order, a medication administration — rather than accepting the default silently.

9.7 Sources that do not acknowledge

Not every transport has the concept. A file source has nowhere to send an acknowledgement; a broker source acknowledges to the broker rather than to the original sender; an HTTP source answers with a status code.

Where the source cannot deliver the acknowledgement mode a channel asks for, the channel is refused at load rather than starting and silently not acknowledging. A channel that believes it is acknowledging and is not is worse than one that does not start.

9.8 X12 acknowledgements

Everything above is about HL7 v2. X12 has its own acknowledgements, and unlike v2 they are off by default: an empty setting means none.

That default is the safe one, because a trading partner who is not expecting an acknowledgement and receives one may treat it as an unsolicited interchange. The acknowledge key on an X12 channel takes one of four values:

ValueWhat is sent
noneNothing. The default.
999Implementation acknowledgement. Supersedes the 997 for HIPAA transactions.
997Functional acknowledgement. Older, and still what many payer connections expect.
ta1Interchange acknowledgement, about the envelope rather than its contents.

Which one to send is a property of the trading partner relationship rather than of the message, which is why it has to be configured and cannot be inferred. A 999 supersedes a 997 for HIPAA transactions, but plenty of partners — older payer connections especially — are set up to expect a 997 and will treat a 999 as an unrecognised file.

Sending the wrong acknowledgement is worse than sending none. It answers a question nobody asked while leaving the real one open, and the sender has no way to tell the difference.

9.9 HL7 v3 acknowledgements

An HL7 v3 channel sends an acknowledgement by default, which is the opposite of X12 and worth knowing.

The reason is the transport rather than the standard: a v3 interaction generally arrives over a synchronous connection with the sending application waiting on a reply, and a sender that receives nothing will usually retry. That is how a patient gets registered three times.

Turning it off is possible and has to be done explicitly — "not set" and "set to false" are deliberately different, so the default stands unless somebody said otherwise.

10 Delivery, Queueing and Retry

A destination that cannot be reached is the normal case, not the exception. Systems restart, networks drop, and a hospital interface engine is unavailable during its own maintenance window. Delivery is built around that.

10.1 Queue per destination

Attempt Delivered queue on disk — per destination 1, 2, 3, 4 … Retry growing backoff Dead letter kept, never discarded the same message, at the front Each destination has its own queue, so one slow receiver does not hold up the others. The queue is on disk, so a restart does not lose it. Order is preserved: message 2 is not attempted before message 1 succeeds or is given up on. A message that exhausts its attempts is dead-lettered and kept. Nothing is deleted to make a queue look healthy.

Each destination has its own queue. A destination that is failing does not affect the others, and each has its own depth, age and retry state.

Queueing is per destination rather than per channel because the failure is per destination. A channel-wide queue would hold up delivery to three working systems because a fourth is down, and the two situations — one destination unavailable, all four unavailable — would look the same in the statistics.

10.2 Queue settings

queue on a destination:

KeyWhat it does
enabledWhether failed messages are queued at all.
max_attemptsHow many times to try before giving up.
backoffWait before the first retry.
max_backoffCeiling on the growing wait.
max_depthMost messages to hold. Unset means no limit.
retain_hoursHow long a delivered message is kept. Unset keeps it indefinitely.

The last two were missing from this table and from the builder, and they are the pair that stops a disk filling. A queue with neither grows until the filesystem is full, and that failure arrives as something unrelated breaking — a write failing somewhere else entirely — rather than as a queue problem.

Backoff grows between attempts up to max_backoff. Growing rather than fixed, because a destination that is down is usually down for minutes rather than seconds, and retrying every second for ten minutes produces several hundred log entries that bury the one that matters.

10.3 Ordering is preserved

Within a destination, messages are delivered in arrival order, and a message that fails holds its place. Later messages wait behind it rather than overtaking.

This is the right default for clinical data — an admission and a discharge applied in the wrong order is worse than both being late — and it has a consequence worth understanding: one message that always fails will hold up everything behind it.

That is why queue depth and queue age are separate alert conditions. A deep queue that is draining is a busy system. A queue whose oldest message keeps getting older is stuck, and the alert kinds queue-depth, queue-age and queue-stuck distinguish them.

A poison message — one that will never succeed no matter how often it is retried — is the specific failure this design is vulnerable to. max_attempts is what limits the damage: after it, the message is recorded as failed and the queue moves on. Setting max_attempts very high in the hope of eventual success converts a single failed message into a stopped feed.

10.4 When queueing is disabled

With queue.enabled false, a failed delivery is a failed delivery. It is recorded and not retried.

That is the right choice when late data is worse than no data — a real-time display, a paging system, anything where a message delivered forty minutes after the event would be misleading rather than merely delayed.

It is the wrong choice for anything that goes into a record, where the message is just as valid an hour later.

10.5 Retry versus queue

There are two mechanisms and they operate at different levels.

retry on a destination governs immediate attempts within one delivery — a connection that fails is tried again after a short pause, without leaving the delivery path. This handles transient faults: a dropped connection, a momentary refusal.

queue governs what happens once those immediate attempts are exhausted. The message leaves the delivery path and is retried later on a longer schedule.

Both exist because the two failures are different. A connection reset on the first attempt is usually gone by the second, and going through the queue for it would add minutes of latency to a fault that lasted milliseconds. A destination that has been switched off for maintenance will not come back within three immediate attempts, and hammering it is pointless.

10.6 What an HTTP endpoint counts as delivered

An HTTP destination has three settings that decide whether a message is treated as sent, and all three are on the destination in the builder.

Status codes that mean delivered. Empty means any 2xx. Some endpoints answer 202 Accepted for something they have not processed yet, and some answer 200 with an error in the body — the second is what *Treat as failed if the reply contains* is for. Setting an explicit list is worth doing when a partner has told you which code means what, because the alternative is discovering the difference from a message that was never delivered and never queued.

Follow redirects. Off by default, and deliberately. A redirect can point at a different host, so following one sends the message somewhere nobody configured and reports success.

Bearer token. Sent as an Authorization header. It is stored with the channel and never shown again, so leaving the box empty when editing an existing channel keeps the token already saved rather than clearing it.

10.7 Naming and pruning an archive folder

A file destination appends to one file per day and message type by default (20261012-ADT_A01.hl7), MLLP-framed so the file replays with perfuse send. Four settings on the destination change that.

File name (file_name) makes one file per message instead, named from ${date}, ${timestamp}, ${control_id}, ${message_type} and ${destination}. A name that is already taken (the same control ID twice) gets a unique suffix rather than overwriting the message there.

Append (append: true) writes every message into the file File name names, so ${date}.hl7 is one file a day for every type.

Suffix while writing (temp_suffix, one file per message only) defaults to .part and is removed by a rename once the file is complete. Whoever collects these files should not pick up a half-written one, and a rename within a folder is atomic. Change it only if the collector filters on a different extension.

Delete after (retain_hours) prunes files in the folder older than the given number of hours, checked at most every ten minutes while messages arrive. Only plain files directly in the folder are deleted, never a subfolder. Empty or zero keeps them forever, which is the default and is a decision rather than an oversight — but an archive nobody prunes fills the disk, and a full disk stops the channel rather than the archiving.

10.8 Sending to a FHIR server

Three settings decide how much is checked before a bundle leaves.

Claim US Core conformance adds the profile to each resource. Only claim it when the resource carries what the profile requires, because a receiver may validate against the claim and reject a bundle that would otherwise have been accepted.

Validate before sending checks each resource here rather than learning from the receiver. A rejection at the far end tells you less, later, and often only in a log you cannot read.

Treat warnings as rejections only has an effect when validation is on, and the control is unavailable until it is. It is strict enough to stop a bundle a receiver would accept, so it belongs on a feed being brought up rather than one in service.

10.8.1 Hosted FHIR services

A fhir destination's auth block signs in to the hosted services, where a pasted bearer token stops working within the hour:

fhir:
  url: https://healthlake.us-east-1.amazonaws.com/datastore/<id>/r4/
  auth: {type: aws, access_key_id: ${AWS_ACCESS_KEY_ID}, secret_access_key: ${AWS_SECRET_ACCESS_KEY}}

aws signs each request for AWS HealthLake with Signature Version 4, with the region read from the URL. azure gets a Microsoft Entra ID token for Azure Health Data Services from tenant_id, client_id and client_secret - an app registration holding the FHIR Data Contributor role - with the FHIR service URL as its audience. client_credentials does the same against any OAuth 2.0 token_url. Tokens are reused until a minute before they expire. The same choices are under the destination's Hosted service sign-in in the channel builder.

10.8.2 Public health case reporting

Tick Report reportable conditions to public health on a FHIR destination and it sends case reports rather than bundles. Each message is checked for a reportable-condition trigger code: a diagnosis, a lab test or a coded result. If it has one, the message becomes an HL7 eCR 2.1.2 eICR, wrapped in an eCR message and posted to the URL's $process-message. If it has none, nothing is sent and nothing fails; the destination counts it as not reportable.

fhir:
  url: https://ecr.agency.example/fhir
  ecr:
    rctc: /etc/perfuse/rctc.json
    source: https://fhir.springfield-general.example/fhir
    facility: {name: Springfield General Hospital, phone: +1-217-555-0100, city: Springfield, state: IL}

rctc is the Reportable Conditions Trigger Codes, as a FHIR ValueSet or the eRSD Bundle. It needs a UMLS licence, so it is not included; without it a short built-in sample is used, for testing only. source is where the agency sends its Reportability Response. The facility is required because eCR asks for its phone and address, and a v2 message has neither.

A report says what the message lacked rather than inventing it, and each gap is logged. Try a message in the FHIR lab's Public health case report panel before pointing a live feed at an agency.

10.8.3 Lab results to public health: ELR

perfuse elr -config elr.yaml results.hl7 turns a lab's results into the HL7 2.5.1 ELR messages state health departments receive, one for each message with a reportable result, and only the orders that carry one: a complete blood count beside a positive SARS-CoV-2 test is not public health's business. The trigger codes are the same as case reporting's, the RCTC you load or the built-in sample.

ELR names everybody by OID or CLIA number, which no lab feed carries for the sender and the receiver, so those come from the file:

sending_application: {namespace: Perfuse, id: 2.16.840.1.113883.19.5.9, type: ISO}
sending_facility: {namespace: Springfield Lab, id: 2.16.840.1.113883.19.5, type: ISO}
receiving_application: {namespace: ELR, id: 2.16.840.1.113883.19.6.1, type: ISO}
receiving_facility: {namespace: State DOH, id: 2.16.840.1.113883.19.6, type: ISO}
processing: T
ordering_facility: {name: Springfield Clinic, phone: "+1 217 555 0100", line: 1 Main St, city: Springfield, state: IL, postalCode: "62701"}
performing_lab: {name: Springfield Lab, line: 2 Lab Rd, city: Springfield, state: IL, postalCode: "62701"}
performing_lab_clia: 14D0000000
placer_authority: {namespace: SPRINGFIELD-EHR, id: 2.16.840.1.113883.19.5.1, type: ISO}
filler_authority: {namespace: SPRINGFIELD-LAB, id: 2.16.840.1.113883.19.5.2, type: ISO}

The ordering facility, performing lab and assigning authorities are used only where the lab's message leaves them out, and each use is a note. What only the lab knows is never filled in: a result with no specimen type goes with SPM-4 empty and a note saying so, because a guessed specimen in a report to public health is worse than a gap the state can ask about. Start with processing: T while the state's onboarding team tests the feed.

What the lab got right is kept as it was sent. A child order (a susceptibility panel or a reflex test whose OBR-29 names its parent and which has no ORC of its own) goes as received, with no ORC or specimen invented for it, and a child with no reportable result of its own goes with its parent's, since a susceptibility means nothing without the culture it belongs to. A note after the PID stays with the patient, and ORC-14, the call-back phone number, is copied from OBR-17 when the lab only put it there. All 28 example messages ONC publishes for its ELR certification tool come out valid with no errors.

MSH-2 is written as ^~\&#, the five characters ELR Release 1's errata require and ONC's certification tool checks. If your state's guide shows the older four, add encoding_characters: "^~\\&" to the file.

To send them from a channel rather than from the command line, give the destination that reaches the state an elr block naming the same file. Each lab result is reshaped on the way out, and one with nothing reportable is not sent at all, which is counted as delivered, not failed: the state asked for reportable results only.

destinations:
  - name: state-elr
    type: mllp                 # or http, sftp, file: whatever the state's onboarding team gives you
    address: elr.health.state.example:6661
    tls: {enabled: true}
    elr:
      config: elr.yaml         # beside this channel file
      rctc: rctc.json          # optional; the built-in sample is for testing only

The block applies on an HL7 v2 channel, to destinations that deliver the message itself (mllp, http, tcp, file, sftp, ftp, S3, Azure Blob, soap, the message brokers, or another channel); the channel refuses to load otherwise, and when the file is missing or lacks the sending facility, receiving application or receiving facility. The notes are written to the log with the message's control ID.

The ELR file is the channel's companion, not a channel: perfuse check and the Channels screen skip it rather than reporting a channel that will not load. In the channel builder the same two settings are under the destination, in Lab reporting (ELR 2.5.1); the trigger codes field opens once an ELR file is named, and saving refuses a file that is not beside the channel.

10.8.4 DICOMweb archives

type: dicomweb stores each object with STOW-RS: Orthanc, dcm4chee, the cloud imaging services, or a Perfuse server's own archive. Give the service root (https://pacs.example/dicom-web); /studies is added, and a URL already ending in /studies is refused rather than posting to /studies/studies. Authenticate with token (a bearer token) or username and password; headers adds others, such as a cloud service's project header. The channel needs dataType: dicom.

The server's answer decides the outcome, not its status code alone. An object the answer lists as failed is a failed delivery with the DICOM failure reason, and so is a 202 that lists nothing as stored: the server took the request and kept none of it. Timeout defaults to two minutes, because a large series is slow.

10.8.5 Document repositories (IHE XDS.b)

type: xds publishes each clinical document to an XDS.b repository with ITI-41 Provide and Register: a CDA document, or one an HL7 v2 message (MDM, ORU) carries. The metadata comes from the document and the destination's settings (source_id, patient_domain, facility_type, practice_setting), and the repository's RegistryResponse decides the delivery. See IHE document sharing.

10.8.6 Immunization registries

Every US state immunization registry (IIS) takes HL7 2.5.1 messages on the same SOAP interface, the one the CDC published in 2011 (namespace urn:cdc:iisb:2011). It has two operations: connectivityTest, which proves the endpoint, and submitSingleMessage, which carries the message. A VXU^V04 reports a shot and comes back as an ACK; a QBP^Q11 asks for a patient's history and forecast and comes back as an RSP^K11.

Choose Immunization registry (CDC IIS web service) as the service of a SOAP destination, or write profile: cdc-iis:

destinations:
  - name: state-registry
    type: soap
    soap:
      profile: cdc-iis
      url: https://iis.state.example/iis-ws/cdc
      username: clinic-submitter       # the registry account for this submitter
      password: "..."
      facility_id: FAC-1234            # optional in the WSDL; many registries require it
    queue: {enabled: true}

Perfuse builds the envelope, so there is no body to write. The username and password go inside the request, where the CDC WSDL puts them, not into HTTP authentication. The version is SOAP 1.2, the WSDL's binding, unless you set version: "1.1" for a registry whose guide asks for it. Where a registry requires a client certificate, set the destination's tls as for any HTTPS call.

The registry's acknowledgement decides whether the message was delivered, not the HTTP status. A registry answers a rejected shot with HTTP 200 and an AR inside, so a destination that trusted the status would report a lost immunization as delivered.

The registry answersPerfuse records
AA (or CA)Delivered.
AE whose ERR segments are all warnings (ERR-4 W or I)Delivered. The warnings are in the reply.
AE with any ERR-4 E, or an ERR with no severityFailed, with each ERR's location, code and text in the error.
AR or CRFailed, the same way.
A SOAP fault (wrong password, message too large)Failed, with the registry's reason.
Something that is not an HL7 messageFailed.

warnings_are_errors: true (Treat warnings as failures) makes every AE wait for a person. The reply, which response scripts and an HTTP caller waiting on the channel read, is the registry's HL7 message: the ACK, or the RSP with the history and forecast. examples/iis holds three channels to start from: VXU from an EHR over MLLP, QBP queries over HTTP, and a local stand-in for a registry.

The Connections dashboard asks a registry its connectivityTest when nothing has been submitted for seven days. That operation exists for this purpose and submits nothing, so it is the one application-level check Perfuse makes. It proves the address, TLS and the SOAP binding, not the username and password; only a real submission tests those.

A SOAP source can answer the same way: Answer as an immunization registry, or profile: cdc-iis on the source. It echoes connectivityTest, takes the message from submitSingleMessage, returns the channel's acknowledgement in return, and serves the CDC WSDL at ?wsdl. A username and password, when set, are checked against the ones in the request, and a wrong pair gets the WSDL's SecurityFault. A message the channel rejects goes back as a NAK, never as a fault, as the CDC guide says. Use it as a test double, or to receive submissions the way a registry does.

Not covered: the state-specific parts of onboarding. Each registry has its own endpoint, account process and local profile of the 2.5.1 guide (required RXA and OBX fields, VFC eligibility, refusal codes). Those are the messages' content, which the channel's transformations shape; no state's endpoint is built in. Perfuse has been tested against itself and against recorded registry answers, not against a live state registry.

10.9 When the transport says yes and the message did not arrive

"Did this arrive" is often not a question the transport can answer. An MLLP receiver returns an application acknowledgement whose meaning is in its text. An HTTP receiver returns 200 with an error document. In both cases the transport succeeded and the message did not arrive.

Inspect the reply on a destination takes a script given the receiver's response. Return false, or throw, and the delivery is marked failed — which means it retries, queues, and appears in the failure count instead of being silently counted as delivered.

It is offered only on destinations that receive a reply. On one that cannot, it is refused at load rather than left never running.

10.10 What a delivery outcome means

OutcomeMeaning
DeliveredThe destination accepted the message. For MLLP, a positive acknowledgement was received.
QueuedDelivery failed and the message will be retried.
FailedDelivery failed and will not be retried, either because queueing is off or attempts are exhausted.
FilteredThe destination declined the message by its own filter. Not an error.

These are recorded per destination per message, so a message that reached three of four destinations is recorded as exactly that. There is no channel-level "success", because it would not mean anything.

10.11 Slow delivery

A destination that is accepting messages but taking a long time about it is a distinct problem from one that is failing, and it is easy to miss because every outcome is a success.

The slow-delivery alert kind exists for this. It is worth setting on anything where the sender's acknowledgement waits on delivery, because a destination whose response time has quietly doubled will eventually cross the sender's timeout and start producing resends — and at that point the symptom appears upstream, in a system you may not administer.

10.12 Writing to a database

type: database runs a statement per message with values bound from the message.

Placeholders are the driver's own — $1 for postgres, ? for mysql and sqlite, @p1 for sqlserver — and Perfuse does not rewrite them. That is deliberate: a translation layer that got a placeholder wrong would bind a patient's name to the wrong column, and the result would look like valid data rather than an error.

A statement written in the wrong dialect is refused when it is saved, naming the style the driver wants. Before that check existed the mistake surfaced as syntax error at or near "," from the server when a message arrived, which names a statement you did write and so sends you looking for a typo instead of at the dialect.

destinations:
  - name: warehouse
    type: database
    database:
      driver: postgres
      dsn: ${WAREHOUSE_DSN}
      statement: INSERT INTO messages (mrn, family_name, birth_date) VALUES ($1, $2, $3)
      params:
        - PID-3.1
        - PID-5.1
        - PID-7

A quoted parameter is a constant rather than a path, which is how a channel writes its own name or a source system code into a row beside the message data.

Values are always bound as parameters. A ${...} reference inside the statement is refused: a name containing an apostrophe would break the statement, and a hostile value would rewrite it.

10.13 Sending to a raw socket

type: tcp writes to a socket with framing you choose, which is what a device or a laboratory instrument usually wants. MLLP is one framing among several; this destination offers the others.

destinations:
  - name: instrument
    type: tcp
    tcp:
      address: instrument.lab:9100
      framing: delimited
      delimiter: \r
      expect_reply: true
      timeout: 30s

Framing has no default, deliberately. Writing with the wrong framing does not fail. The far end reads messages split in the wrong places, or waits for a terminator that never comes, and the symptom appears at the receiving system rather than here.

framingWhat it writes
mllp0x0b before, 0x1c 0x0d after. The same framing an MLLP destination uses.
delimitedA delimiter after each message, and an optional start_block before it.
fixedRecords padded or truncated to record_length.
lengthA length_bytes prefix. big_endian and length_includes_header both have two conventions in the wild and the difference is silent.
wholeOne message per connection, closed to mark the end.

Only the settings the chosen framing uses are accepted. A record length on a delimited stream is refused rather than ignored, because a setting that is silently ignored is one somebody believes is in effect.

expect_reply changes what delivered means. Without it, success means the bytes reached the operating system's send buffer — which a peer that crashed a moment later never read.

10.14 Amazon S3, SQS and SNS

A channel can read from an SQS queue or an S3 bucket prefix, and deliver to SQS, SNS or S3. All five are signed HTTP with no AWS SDK in the binary, and all five take the same access settings: region, access_key_id, secret_access_key (write ${AWS_SECRET_ACCESS_KEY} to read the environment), an optional session_token, and endpoint for LocalStack, MinIO or a VPC endpoint. Under Channels → New channel, choose *We read from an Amazon SQS queue*, *We collect objects from an Amazon S3 bucket*, or the SQS, SNS and S3 destination types.

Nothing is acknowledged to AWS until the channel has handled it. An SQS message is deleted afterwards and otherwise reappears when its visibility timeout ends, so a queue's redrive policy is what moves one that keeps failing to a dead-letter queue. An S3 object moves to processed/ afterwards, or to error/ if it could not be handled, so one bad file does not stop the rest.

On a FIFO queue or topic, messages are grouped by patient by default - SQS keeps order within a group and nowhere else - and deduplicated by content, so a retry is not delivered twice.

An S3 archive can be written straight to Glacier with storage_class, and as JSON lines for Amazon Athena with format: ndjson; perfuse athena prints the table. The reference has every setting.

10.15 AMQP 1.0 and Azure

AMQP 1.0 is what Azure Service Bus and Event Hubs speak, and RabbitMQ 4, ActiveMQ and Artemis beside their other protocols. An amqp source receives from a queue or a subscription and accepts each message only once the channel has handled it; one that could not be handled is released as a failed delivery, so the broker redelivers it and, past the queue's limit, dead-letters it. An amqp destination reports success only when the broker accepts the message. For Service Bus: addr is <namespace>.servicebus.windows.net:5671 with tls, username the shared access policy name, password its key, and address the queue name, or <topic>/subscriptions/<name> to receive.

Azure Blob Storage: an azure_blob destination writes a blob per message in the access tier chosen - Hot, Cool, Cold or Archive - and an azure_blob source picks blobs up from a prefix, moving them to processed/ or error/ as the S3 source does. Prefer a SAS token limited to the container over the account key.

10.16 MQTT

An mqtt source subscribes to a topic filter (+ matches one level, # every level below) on any MQTT 3.1.1 broker: Mosquitto, EMQX, HiveMQ, AWS IoT Core, Azure Event Grid. At QoS 1, the default, each message is acknowledged only once the channel has handled it. MQTT has no way to refuse a single message, so one that could not be handled is left unacknowledged and the connection is made again; the broker keeps the session under the client id (perfuse-<channel> unless client_id says otherwise) and sends it again, first in line. QoS 0 is at most once: anything sent while Perfuse is down is lost. An mqtt destination publishes and, at QoS 1, reports success when the broker has the message.

source:
  type: mqtt
  mqtt:
    addr: broker.hospital.local:8883
    topic: hl7/+/adt
    username: perfuse
    password: ${MQTT_PASSWORD}
    tls: {enabled: true, ca_file: /etc/perfuse/broker-ca.pem}

AWS IoT Core wants a client certificate: give tls.cert_file and tls.key_file.

10.17 IBM MQ

ibm_mq reads and writes IBM MQ queues through the queue manager's messaging REST API (mqweb), which IBM MQ 9.1.5 and later ship on every platform, z/OS included, so no MQ client is installed beside Perfuse. The user needs the MQWebUser role and put, get and browse authority on the queue. A source browses the first message, hands it to the channel, and removes it by its message id only once it was handled; a message that fails stays first on the queue. Run one reader per queue. A destination puts persistent messages (unless non_persistent), with the HL7 control id as the correlation id.

destinations:
  - name: to-mainframe
    type: ibm_mq
    ibm_mq:
      url: https://mq.hospital.local:9443
      queue_manager: QM1
      queue: HL7.ADT.OUT
      username: perfuse
      password: ${IBM_MQ_PASSWORD}
      tls: {enabled: true, ca_file: /etc/perfuse/mq-ca.pem}

api_version is v3 for MQ 9.3 and later (the default), v2 for 9.1.5 to 9.2. A queue manager with an AMQP channel can also be reached with the amqp connector.

10.18 Google Cloud Pub/Sub and Cloud Storage

A pubsub source pulls from a subscription and acknowledges each message once handled; one that fails is handed back at once (its acknowledgement deadline set to zero), with the rest of its batch, so Pub/Sub delivers it again and, past the subscription's dead-letter policy, dead-letters it. A pubsub destination publishes with the HL7 message type and control id as attributes; order_by: patient sets the ordering key from PID-3.1, so a subscription with message ordering keeps each patient's messages in order.

A gcs destination writes an object per message, named as an S3 key is, in the storage class chosen (STANDARD, NEARLINE, COLDLINE, ARCHIVE). A gcs source picks objects up from a prefix and moves them to processed/ or error/ as the S3 source does.

Both sign in as a service account: credentials_file is its JSON key. Leave it empty on Google Cloud (a VM, GKE or Cloud Run) to use the service account attached there. endpoint names an emulator (the Pub/Sub emulator, fake-gcs-server), which takes no credentials.

source:
  type: pubsub
  pubsub:
    project: hospital-integration
    subscription: adt-to-perfuse
    credentials_file: /etc/perfuse/gcp-key.json

10.19 Reading a mailbox (IMAP and POP3)

An email source reads a mailbox and turns what it finds into messages: each attachment (take: attachments, the default, narrowed by attachment_names patterns), the text of the email (take: body), or the whole email (take: raw) for a script to take apart. Laboratories, imaging centres and small practices still send results this way.

Each check signs in, reads the unread mail oldest first, and signs out. Over IMAP an email is fetched without being marked read, handed to the channel, and only once every message from it was accepted is it moved to move_to (Processed), marked read (after_read: flag) or deleted. Mail that failed, or that was not taken (a sender outside allowed_senders, no attachment matching attachment_names, larger than max_message_size), moves to error_folder (Error) with the reason in the log. Both folders are created when missing. POP3 has no folders or flags, so it deletes: deletions are committed by the QUIT at the end of the check, so a session that breaks off deletes nothing and a handled email may be read again (never lost). POP3 mail that is not taken stays on the server and is reported once.

Sign-in is a password (${MAIL_PASSWORD}; an app password for Gmail) or OAuth 2.0 over SASL XOAUTH2, which Microsoft 365 requires (it has turned password sign-in off) and Gmail prefers:

source:
  type: email
  email:
    host: outlook.office365.com        # imap.gmail.com, pop.gmail.com, or your server
    username: results-feed@hospital.example
    oauth:
      provider: microsoft              # microsoft, google or custom (with token_url)
      tenant: hospital.onmicrosoft.com
      client_id: 00000000-0000-0000-0000-000000000000
      client_secret: ${MAIL_CLIENT_SECRET}
    attachment_names: ["*.hl7"]
    allowed_senders: [lab.example, results@partner.example]

With client_secret and no refresh_token, Microsoft 365 app-only access is used (the client-credentials grant, scope https://outlook.office365.com/.default): an administrator registers the app, grants it IMAP.AccessAsApp and limits it to the one mailbox with an Exchange service principal. With a refresh_token (a person's consent), the delegated scope IMAP.AccessAsUser.All or POP.AccessAsUser.All is used, and a rotated refresh token is kept for the life of the process. Gmail needs a refresh_token: Google offers no app-only access to a mailbox.

allowed_senders takes addresses and domains. The From line of an email can be forged, so it keeps out accidents and stray mail, not an attacker; a feed that must be trusted belongs on Direct (S/MIME signed), SFTP or a VPN. security is tls (993 or 995, the default), starttls (143 or 110) or none, which is allowed only to localhost. Checks are at least 10 seconds apart (60 by default), because mail services throttle accounts that check faster.

Tested against GreenMail 2.1.3 (IMAPS, IMAP, POP3S and POP3: sign-in refused with a wrong password, fetch without marking read, MOVE, flag, delete, a too-large email refused before it is read) and against Gmail's IMAP with an app password (create a folder, append, fetch, MOVE, delete). OAuth was tested against a stand-in token endpoint only: not against Microsoft or Google.

10.20 AS2

AS2 (RFC 4130) is how many payers, clearinghouses and suppliers exchange EDI: an HTTP POST whose body is signed with the sender's certificate and encrypted to the receiver's, answered with a receipt (an MDN) that names a digest of what arrived. The S/MIME is the same code Direct messaging uses. Both ends are on the channel builder: Trading partners send us EDI over AS2 as a source, A trading partner over AS2 as a destination.

# receive: partners post here
source:
  type: as2
  as2:
    listen: ":4080"            # path /as2 by default
    id: HOSPITAL-AS2           # our AS2 identifier (AS2-To)
    cert_file: as2/ours.crt    # partners encrypt to this
    key_file: as2/ours.key     # and receipts are signed with it
    tls: { enabled: true, cert_file: tls.crt, key_file: tls.key }
    partners:
      - id: PAYER-AS2
        cert_file: as2/payer.crt
dataType: x12
# send
destinations:
  - name: payer
    type: as2
    as2:
      url: https://as2.payer.example/as2
      from: HOSPITAL-AS2
      to: PAYER-AS2
      cert_file: as2/ours.crt
      key_file: as2/ours.key
      partner_cert_file: as2/payer.crt
      file_name: "${control_id}.x12"

Receiving. A message to any other AS2-To, or from a sender not in partners, is refused with 403 and no receipt. A partner's message must be signed and encrypted unless its entry sets allow_unsigned or allow_unencrypted. The payload is decrypted, its signature checked against the partner's certificate (partners usually exchange self-signed ones, which is fine: the certificate is the trust), and handed to the channel as its message format says. The partner gets the receipt it asked for: in the response, or posted to its Receipt-Delivery-Option URL; signed when it asked for a signed one. A message that fails gets a receipt naming why (decryption-failed, authentication-failed, insufficient-message-security, unexpected-processing-error).

Sending. Each message is signed (SHA-256) and encrypted (AES-256-CBC). With receipt: sync, the default, it counts as delivered only when the receipt verifies against partner_cert_file, says processed, and carries the same digest (MIC) as what was sent; anything else is a failed delivery and is retried. receipt: async asks for the receipt at receipt_url, which must be an AS2 source on this server that lists the partner: the message counts as delivered once the partner accepts the request, and a receipt that later disagrees is logged as an error. receipt: none asks for nothing. unsigned, unencrypted and unsigned_receipt relax each part for an agreement that says so; username and password add HTTP basic authentication for gateways that require it. The content type follows the channel's format (application/edi-x12 for X12) unless content_type sets one.

Triple DES, AES-128 and AES-192 are read as well as AES-256, since partners still send them, and so is the streaming BER (indefinite lengths) that Java gateways write.

Compression (RFC 5402). A compressed message is read whichever way it was built: compressed before signing or encryption, or a signed message compressed whole. The receipt's MIC follows the RFC: over what was signed when it is signed, over the uncompressed content when it is not. A message that would expand past ten times the source's max_message_size is refused with decompression-failed, so a few kilobytes cannot claim gigabytes of memory. A destination compresses with compress: true, before signing and encryption, as most gateways do; ask the partner first.

A message sent twice. A partner that never got our receipt sends the message again with the same Message-ID (RFC 4130 section 7.5). The source remembers each partner's Message-IDs for duplicate_window (default 720h, 30 days; an hour at least) and answers a resend with a processed receipt without passing it on again. The record is kept in the database, so a restart does not forget it. A message whose processing failed is not remembered, so the partner's retry goes through. Copies arriving at the same moment wait for one another, and only the first is passed on. If the record cannot be read, the message is refused with unexpected-processing-error and the partner retries later, rather than risking a second copy. A message with no Message-ID cannot be recognised and is logged as such.

Interoperability is tested against pyas2lib 1.4.3 in both directions, with Triple DES and AES-256, compressed and not, signed synchronous receipts included.

10.21 OpenPGP

Payers, clearinghouses and state agencies often take and send files only under OpenPGP. A pgp block on a source opens what arrives; one on a destination protects what leaves. Both are on the channel builder: Open PGP-encrypted messages under the source, Encrypt with OpenPGP on each destination.

source:
  type: sftp
  sftp: { host: sftp.payer.example, user: hospital, key_file: keys/id_ed25519, dir: outbound }
  pgp:
    decrypt_with: keys/our-secret.asc      # our secret key, armored or binary
    passphrase: ${PGP_PASSPHRASE}
    verify_with: [keys/payer.asc]          # the senders' public keys
    require_signature: true
destinations:
  - name: claims
    type: sftp
    sftp: { host: sftp.payer.example, user: hospital, key_file: keys/id_ed25519, dir: inbound,
            file_name: "${date}-${control_id}.x12.pgp" }
    pgp:
      encrypt_to: [keys/payer.asc]
      sign_with: keys/our-secret.asc
      passphrase: ${PGP_PASSPHRASE}

Key files are read relative to the channel file, and are read and unlocked when the channel loads: a missing key or a wrong passphrase stops the channel there, not on the first file.

opened first and then split into messages, so a batch encrypted as one file works. One that will not open is refused: a file source moves it to its error folder, an HTTP sender is answered 400 with the reason. require_signature refuses a message not signed by one of verify_with; allow_unencrypted accepts one that is only signed; pass_plain lets a message that is not PGP at all through unchanged, for a folder that receives both.

encrypt_to) and signed (with sign_with), after every other step. A file, SFTP, FTP, S3, Azure Blob or GCS destination still names the file from the plain message, so ${control_id} works, and any framing is inside the encryption. armor: true writes .asc text rather than binary; no_compress turns off compression; file_name is the name carried inside the message.

Messages are written with AES-256 in the RFC 4880 format older receivers read, and the newer RFC 9580 format is read as well. The cryptography is ProtonMail/go-crypto; Perfuse implements no primitive. Interoperability is tested against GnuPG in both directions: gpg decrypts and verifies what Perfuse writes, and Perfuse opens and verifies what gpg writes.

10.22 Delivery to another channel

A channel destination hands the message to another channel by name. The receiving channel treats it like any other arrival: it records it, filters it, transforms it and fans it out.

This is the supported way to build a chain. It is deliberately visible — two channels, two sets of statistics, two entries in the message history — because a chain hidden inside one channel is a chain nobody can monitor.

The message is handed over in memory rather than over a network, so there is no framing, no acknowledgement and no timeout between the two.

10.23 What happens when the process is killed

The question anyone who has run an interface engine asks first, and the one that is rarely answered, because the honest answer is usually embarrassing.

It was answered by killing the process with SIGKILL part way through a batch — which is what a power failure, an out-of-memory kill and a hypervisor reset all look like from inside — and counting what survived against what had been promised.

The property under test is not "nothing is lost". That is not achievable and claiming it would be a lie. It is the narrower promise a sender actually relies on: a message that was positively acknowledged is a message that arrived.

With ack.when: on_delivery, which is the default, every destination is written before the acknowledgement goes out, so an AA is a statement about the destination rather than about a queue. A message killed before its acknowledgement may well be lost, and that is correct: the sender was never promised anything and will send it again.

10.23.1 Acknowledging on delivery

Five runs, killed after 5, 25, 40, 75 and 110 messages of 120.

AcknowledgedPresent downstream afterwardsAcknowledged but missing
2552550

The promise held at every kill point, and nothing was duplicated. A duplicate would have been acceptable and a loss would not: a resent A08 is a nuisance receivers absorb, and a lab result that silently never arrived is a patient safety event.

10.23.2 Acknowledging on receipt

on_receipt answers as soon as the message is queued, before any destination has been written. This chapter has always said an acknowledged message can still be lost that way. Here is the size of it.

Killed afterAcknowledgedPresent downstreamLost
5514
2525124
7575273
1101102108

Essentially everything in flight. The setting is not a defect and there are feeds where it is the right choice, but it should be chosen knowing that a crash discards what has been acknowledged and not yet written, and that this is almost all of a burst.

Nothing recovered on restart, because perfuse run has no durable store. A destination that must not lose messages during an outage needs queue.enabled, which needs the database that perfuse serve provides — and perfuse run now refuses such a channel rather than starting it without the queue.

10.23.3 A sender that dies half way through a message

The most dangerous of these faults, because a truncated HL7 message usually still parses. The segments before the cut are complete and well formed, so a receiver has no way to know that PID and PV1 arrived and the OBX segments carrying the results did not.

A frame was opened, six tenths of a message sent, and the connection reset rather than closed. The partial message was not delivered, the listener survived, and a well-formed message sent immediately afterwards was accepted normally, so the fragment was not carried into the next message either.

The connection log now reports how many bytes of an unfinished frame were abandoned, for every failure mode rather than only a tidy close. Previously a crashed sender, a killed process and a pulled cable all logged messages=0 and nothing else, which cannot distinguish an empty health check from a lab result thrown away nine tenths of the way through.

10.23.4 A full disk

Space exhaustion is detected and logged rather than swallowed. Two things about it are worth knowing.

The sender does not get a prompt rejection. The delivery retries on the usual schedule — one second, two, four, eight — and the negative acknowledgement arrives about fifteen seconds later. A sender whose own timeout is shorter than that sees a stalled connection rather than an AE, and will conclude the network is at fault rather than the disk.

And a volume reporting zero bytes free can still accept small appends for a while, because adding a couple of hundred bytes to a file whose last block has room needs no new allocation. The first write failure is later than the moment the disk filled.

10.23.5 What was not tested

A destination directory losing write permission. It could not be injected with chmod: the engine holds the output file open, and changing a file's mode does not affect a descriptor already opened against it. Testing it properly needs the volume remounted read-only or removed underneath the process, which has not been done. It is named here rather than omitted, because a gap nobody mentions reads as a gap nobody looked for.

11 Alerting

Nothing outside Perfuse will tell you a feed has stopped. The sender is being acknowledged and is satisfied; the receiver has no way to know a message was sent. Alerting is the only thing standing between a broken interface and somebody noticing weeks later.

11.1 The alert kinds

KindFires when
error-rateFailures exceed a proportion of traffic.
queue-depthA queue has more than N messages waiting.
queue-ageThe oldest queued message is older than a duration.
queue-stuckA queue is not draining at all.
no-trafficNothing has arrived for a fixed period.
below-rhythmTraffic is well below what this feed normally does *at this time of week*.
channel-downA channel that should be running is not.
script-errorsA script is failing.
rows-quarantinedA database source is setting rows aside.
slow-deliveryDeliveries are succeeding but taking too long.
contractA message violates the channel's declared contract.
phi-volumeA channel carries several times its usual rate for the hour.
phi-after-hoursA channel that only carries daytime traffic carries some at night.
phi-new-destinationA channel delivers to an address it has not sent to before.
phi-bulk-readsOne person reads more than N different patients' records within a minute.

The last four watch access to patient data, and are described below.

11.2 Silence is the hardest thing to detect

A channel that has stopped receiving produces no errors. There is nothing to count. This is why no-traffic exists, and why it is not good enough on its own.

no-traffic is a fixed threshold: alert if nothing has arrived for, say, two hours. On a feed that genuinely never goes quiet, that works. On a clinic's laboratory feed it is useless — the feed is silent every night and all weekend, so a two-hour threshold pages somebody at 1am every night, and a threshold long enough to survive the weekend will not notice a Tuesday morning outage until Tuesday afternoon.

There is no single number that is both. That is not a tuning problem; it is that the question "has this feed stopped" cannot be answered by a constant when the feed's normal varies by a factor of a hundred across the week.

11.3 Rhythm-aware detection

below-rhythm compares current traffic against what this channel normally does in this hour of this day of the week.

Perfuse learns each channel's week from its own recorded history: 168 hourly buckets, Monday first so a working week is contiguous. For each bucket it holds a median, a low and a high, a floor, and how many observations it is based on.

The threshold is a share of normal, defaulting to 0.8 (80 per cent in the console) — alert when traffic falls below four fifths of what is normal for this hour.

11.3.1 Median, not mean

A single backlog flush of forty thousand messages against a normal three hundred moves a mean far enough that the feed can go completely silent afterwards without ever falling below it. The median is unmoved by one extraordinary hour, which is exactly the property needed.

11.3.2 The floor is a percentile, not the minimum

Whether a channel's rhythm is reliable enough to alert on is decided by its low end, not its high end. A shortfall alert fires when traffic is *low*, so only the low end can tell you whether it will produce false positives. An early version of this compared the high against the median and got it wrong in both directions at once: it disqualified a perfectly steady feed for a month after one backlog, and it passed a genuinely erratic feed that swung between two and nine hundred messages in the same hour because its high was only three times its median.

The floor is the twentieth percentile rather than the minimum, because the minimum is dragged to zero by a single bank holiday. And the percentile is not interpolated — every number in an alert at two in the morning should be a count the feed actually produced, not an average of two counts it did not.

11.3.3 It will say when it cannot tell

A channel with no history at all is omitted entirely rather than treated as one that has stopped. A new channel must not look like a broken one.

Where the history is too thin or too erratic to support a judgement, the rule declines to fire rather than guessing. "I cannot honestly say" is a first-class answer here, and it is preferred to an alert that is right slightly more often than chance.

Absent hours inside the learning window are recorded as real zeroes, not as missing data. Half the signal in a clinic feed is *when it is legitimately quiet*, and treating those hours as unknown would throw it away.

11.4 Setting alerts up

From the interface, under Alerts, per channel or across all of them. Each rule has a kind, a threshold and where to send the alert. The rules are saved to alerts.yaml beside the database (or the file -alerts names) and take effect at once; until the first save the built-in rules apply. The file can also be edited by hand: the change is picked up within thirty seconds, and a file that no longer loads is reported in the log and the rules in force are kept.

Worth doing before a feed goes live rather than after the first incident. The specific set worth having on any clinical feed:

  1. below-rhythm — the feed has gone quiet when it should not be quiet.
  2. error-rate — messages are arriving and failing.
  3. queue-age — something is stuck rather than merely busy.
  4. channel-down — the channel is not running at all.

The first is the one that catches the failure nobody else will report.

11.5 Watching access to patient data

A breach rarely announces itself. It looks like a feed carrying more than usual, traffic at three in the morning on a channel that only works office hours, a destination nobody set up, or one person opening patient after patient. Perfuse watches for those four, on by default (serve -watch-access=false turns it off), and reports what it sees through the alert rules above, so it is configured, acknowledged and delivered like any other alert.

Nothing is judged for a channel until it has carried 200 messages, because "unusual" means nothing without a pattern. The pattern is learned from the last fortnight of the message store when the server starts, so a restart does not make every channel new again. Anything seen is held for fifteen minutes, then the alert clears itself.

It only watches and reports. It never holds up a message or refuses a read: these are heuristics, and a feed stopped because a guess was wrong is a clinical problem. Each alert says what was seen and what it could innocently be, because most of them will be innocent.

Administrators see what the monitor has learned on the Alerts screen, under Watching access to patient data: each channel's pattern (still learning, daytime only, or day and night), how many destinations it knows, and everything seen in the last fifteen minutes, including what is under a rule's threshold.

11.6 Where alerts go

Alerts can be sent as JSON to a webhook (-alert-webhook: Slack, Teams, Mattermost, PagerDuty, Alertmanager or anything that accepts a POST), sent as SNMPv2 traps to SNMP managers (-snmp-traps, see SNMP), or written to the log for a collector to pick up. There is no email delivery of its own: point the webhook at a relay if alerts must arrive as email.

An alert that is only written to a log nobody reads is not alerting. If Perfuse is the only thing that will notice a stopped feed, the alert has to reach a person.

12 The Message Store

Every message Perfuse receives is recorded, as received, before anything modifies it. So is what was sent to each destination and what happened to it. The store is a SQLite file given by -db.

12.1 What is kept

Per message: the raw bytes as they arrived, when they arrived, which channel, the parsed message type and trigger event, and the filter outcome.

Per destination per message: the bytes that were sent, the outcome, the time taken, and any error or negative acknowledgement received.

This is the shape an investigation needs. "What did they send us", "what did we send them" and "what did they say about it" are three different questions and each of them gets asked.

12.2 Searching

By channel, by time range, by outcome, by message type and trigger event, and by field value.

Field search works on the parsed message, so searching for a record number finds it wherever in the message that field lives, rather than matching the digits anywhere in the text. A text search for 12345 in a message containing a quantity of 12345 finds a message that has nothing to do with the patient you are looking for.

12.3 Replay and reprocessing

Because the original is kept, a message can be run through the current configuration again.

Trace takes a recorded message and shows what the channel would do with it now, without sending anything. Covered in debugging.

Reprocess actually re-runs it, including delivery. This is what fixes a batch of messages that failed because a destination was misconfigured: correct the configuration, select the affected messages, reprocess.

Reprocessing delivers again. If the messages partly succeeded the first time, reprocessing will produce duplicates at the destinations that already had them. Perfuse records which destinations succeeded, so reprocessing can be limited to the ones that failed — do that rather than reprocessing everything, unless the receiving system deduplicates on message control ID.

12.4 Staleness

A trace of an old message against the current configuration is answering a hypothetical: what *would* happen now. That is usually what you want, but not always — if you are investigating what went wrong last Tuesday, the channel may have changed since.

Perfuse detects this and says so. A trace of a message that arrived before the channel was last edited is marked as such, so the answer is not mistaken for a reconstruction of what actually happened.

12.5 Retention

The store grows. cleanup.periodDays controls how long messages are kept.

Two things to weigh. Clinical messages may fall under retention requirements that are longer than anything you would choose for operational reasons, and the store is not the system of record — the receiving system is. Perfuse's copy exists to diagnose and to reprocess, and both of those needs fall off sharply after a few weeks.

Retention also decides how much history the rhythm learner has to work with. See alerting: a retention window shorter than a few weeks leaves it unable to distinguish a quiet Sunday from a stopped feed.

12.6 The store holds clinical data

This is the part of Perfuse that contains patient information, and it should be treated as such: on encrypted storage, backed up as clinical data, and access-controlled. See security.

The generated test messages described in testing exist partly so that this data does not have to leave the environment in order to reproduce a problem elsewhere.

13 Debugging

The question is almost always the same: this message came in, something wrong went out, where did it change? Perfuse answers it by replaying a recorded message through the current configuration and showing every step.

Nothing is sent while tracing. The channel keeps running untouched, and no redeploy is needed.

13.1 Tracing a message

Select any recorded message and trace it. The result shows, in order:

13.2 Step by step

The step-through view runs each transformation on its own and snapshots the whole message after it. A numbered rail of steps, coloured by what happened, and the message as it stood at each point.

The whole message rather than only the change, because the change is often not where the problem is — a step that correctly modified the field it was aimed at, on a message where an earlier step had already put the wrong thing there, looks perfectly correct in isolation.

13.3 Four outcomes, and the distinction that matters

OutcomeMeaning
changedThe step ran and modified the message.
no-effectThe step ran, and the message is unchanged.
skippedThe step's when condition was false, so it did not run.
failedThe step errored.

no-effect and skipped are reported separately, and this is the point of the whole feature.

From outside they are identical: the message is the same afterwards either way. They mean opposite things. skipped means the condition worked as written and this message was not one it applied to — usually correct. no-effect means the step ran and found nothing to do, which usually means it is aimed at a field that is not there.

An engine that reports both as "nothing happened" leaves you unable to tell a working conditional from a broken path, and that is the most common transformation bug there is.

A step reporting no-effect on every message is nearly always a wrong path. The step-through header counts how many steps found nothing, which is the fastest way to spot it: a channel with three steps and three no-effect results is not transforming anything at all.

13.4 Testing a change before saving it

The builder can run a candidate configuration against recorded traffic and report what would differ. This is the safest way to change a live channel: make the edit, see what it does to the last few hundred real messages, then save.

It is also how to find out that a change which looks obviously correct affects messages you had not thought about — a step conditioned on one trigger event, on a feed that turns out to carry four.

13.5 What tracing cannot tell you

It runs the current configuration. If the channel has been edited since the message arrived, the trace is a hypothetical rather than a reconstruction, and Perfuse marks it as such rather than letting the distinction pass silently.

It also does not exercise the transport. A trace shows what would be sent, not whether the destination would accept it — a message that is correct and a destination that is refusing connections produce a clean trace and a failed delivery. Those are visible in the delivery record instead.

13.6 Scripts

A script is one opaque step in the trace. Perfuse can show what went in and what came out, and cannot show what happened in between or which fields were read.

This is the concrete cost of using a script rather than declarative steps, and it is worth weighing when choosing. See transformation.

14 Testing

Standard advice for testing an interface is to build sample messages that reflect your own environment — your laboratory's codes, your case mix, your sender's habits — and to strip the patient data out by hand. That is slow, and doing it incompletely is the normal outcome.

Perfuse offers three things instead: generated traffic that matches a feed's shape without its content, a channel proposed from a sample somebody sent you, and a comparison against the engine you are replacing.

14.1 Generated test traffic

If a channel has handled messages, Perfuse can produce more messages like them.

What is faithful:

What is not faithful, and this is stated rather than implied: clinical coherence. A generated patient has an unrelated record number, a name-shaped string, and a diagnosis with no relationship to the observation. These messages exercise an interface. They are not a clinical test set and cannot be used to validate clinical logic.

14.1.1 Why it contains no real data

The argument is structural rather than a claim about effort. The generator's only input is the channel's traffic profile, and a profile holds fill rates, shapes, lengths, repetition counts and segment frequencies. It holds no values.

The single exception is code-table fields, and a code identifies nobody.

This is a stronger guarantee than de-identifying real messages, which is a process that can be done incompletely and usually is. Here there is no path by which a name could reach the output, because the name was never in the input.

Every generated message is parsed before it is returned — an unparseable corpus would send somebody hunting for a bug in their interface that is really a bug in the generator — and every one is marked T in MSH-11, with a deliberately synthetic sending application and facility.

A test message that is not marked as one will eventually be found in a production system by somebody who has no way of knowing it was not real. The T and the synthetic sender are not cosmetic.

14.2 Building a channel from a sample

Paste the message a laboratory, hospital or vendor emailed you and Perfuse proposes a channel for it. Nothing needs to be running first.

It handles what people actually paste: several messages in one block, any line ending, MLLP framing that survived the copy, and the covering note above the message. Refusing a sample because it arrived with Windows line endings would be an obstruction rather than a check.

14.2.1 Three lists

The proposal comes with what the sample shows, what has been guessed, and what the sample cannot tell you — three separate lists, weighted equally in the interface.

They are separate because a reader who cannot tell which is which has to verify everything or trust everything, and both are worse than knowing where to look.

From a single message the limits are named specifically rather than generally: which fields are optional, which segments repeat, which codes exist beyond the ones here, whether the sender ever sends a different trigger event. A general caution is one people skim.

Confidence is a word — low, moderate, reasonable — never a percentage. A percentage implies a calculation, and this is a judgement about how many samples there are.

Z-segments are called out by name, because a local segment is the thing no specification mentions and the commonest reason a channel built from a standard template does not work.

14.2.2 The separator warning

If the sample's field separator is not the usual |, that appears first, and confidence drops to low.

The reason is a genuine silent failure. HL7 takes its field separator from the message itself, so a sample whose pipes have been replaced by typographic look-alikes — which is what a word processor does — parses perfectly into fields that are all wrong. Nothing errors anywhere.

Refusing such a sample would be wrong, because a sender may legitimately use another separator. So it is flagged, the character is named, and asking for a plain text attachment is suggested.

14.3 Comparing against the old engine

Before moving a live feed, the question is whether Perfuse produces the same output as the thing that has been running for years. No amount of design quality answers that. See migration.

14.4 Verifying against the real thing, in containers

Three checks run against real implementations rather than against Perfuse's own idea of a protocol. They live outside make check because they need a container runtime, and a check that needs Docker is a check people stop running.

scripts/interop-up.sh starts all of them and prints what each one unlocks.

ContainerChecksWhat it found
Keycloak 26web/e2e-saml/, and internal/saml fixturesA defect. The canonical form a signature covers was computed with namespace prefixes dropped, so no real identity provider could ever have signed anybody in
Microsoft Entrainternal/saml live tests and fixturesTwo defects, both in what surrounds the protocol. An account was created under Entra's opaque NameID, because the readable-name logic wanted an email claim and Entra sends none. And a SAML account was recorded as OIDC, because the store derived the protocol from an issuer that looks identical for both
HAPI FHIRgo test ./internal/engine/ -run HAPIA defect. An unmapped assigning authority produced an identifier system announcing an OID and carrying a word, which HAPI accepted without complaint
ActiveMQgo test ./internal/engine/ -run RealBrokerCorrect, including the null-byte framing case. Asserted through the broker's own management interface rather than through a function returning nil
nginx, client certificatesgo test ./internal/engine/ -run ClientCertificateCorrect
Orthanc (a real PACS)go test ./internal/engine/ -run RealPACSCorrect. A C-STORE arrives and the patient name, identifier, study date, modality and SOP class all survive
PostgreSQLgo test ./internal/engine/ -run RealPostgresCorrect, and it prompted a better error: a statement in the wrong dialect's placeholders is now refused when saved rather than becoming a syntax error at the server when a message arrives
OpenSSH sftp-servergo test ./internal/engine/ -run OpenSSHServerCorrect. The existing SFTP tests use the same Go library on both ends; OpenSSH is a different implementation and the one on the far end of nearly every real feed
Mirth 4.5.2go test ./internal/mirth/ -run RealMirthCorrect now. The only fixture used to be hand-written and Mirth would not load it; scripts/mirth-author-channel.sh has Mirth author one instead, and Perfuse reads it

Every one skips rather than fails when its container is absent, so make check passes on a machine with no Docker.

Why this is separate from the rest of the suite. Every other test of these features had both halves written here: a document this codebase signed, read back by the code that signed it. That is agreement with oneself, and it is worth less than it appears.

The SAML canonicaliser is the case that proves the point. It carried a thousand lines of tests, five of them specifically about canonicalisation, and all of them passed while no real identity provider could ever have signed anybody in — the canonical form that a signature covers was being computed with namespace prefixes dropped, and the only way to find that was to hand it a document Perfuse had not written.

Two of the eight checks found defects outright, and a third prompted a real improvement. Three found nothing wrong. That ratio is the argument for keeping all of them: a check that passes has told you something you could not otherwise have known, and there was no way to tell in advance which ones they would be.

One pattern worth naming, because it appeared in both defects: acceptance is not verification. HAPI accepted the malformed identifier system and stored it; the defect only appeared on reading the resource back. A receiver that tolerates something is not evidence that it is right, only that this receiver was lenient.

Two captured assertions are committed, at internal/saml/testdata/keycloak-assertion.xml and entra-response.xml, with tests over both — so that particular regression is caught without a container.

Both are needed rather than one being spare. Keycloak prefixes its assertion and signature, Entra does not, and those are the two opposite cases in canonicalisation — which is precisely where the defect was. A test asserts each document still covers its own case, because a replacement capture that happened to be prefixed would quietly halve the coverage without failing anything. The general lesson does not generalise so cheaply: for anything else, the container is the check.

14.5 Diagnosing a test that fails once and never again

A test that fails in a full run and passes alone sixteen times is not a flaky test. It is a test that shared something with the run, and the way to find out what is to measure the shared thing rather than the test.

Two instruments exist for this, and both are meant to be run alongside a full suite:

./scripts/e2e-watchdog.sh /tmp/watchdog.log     # the HTTP server and the shared session
./scripts/e2e-mllp-probe.py /tmp/mllp.log       # the fixture channel's listener

Each discovers the run's ports from the setup's own state file and samples every 500 milliseconds until the server exits. They log every sample rather than only the failures, which is what makes them useful: a blip shows up in a run where nothing failed, so a fault that appears once in three runs becomes measurable on every one.

Set PERFUSE_E2E_KEEP=1 to keep the temporary directory, which holds the database as it was left and the server log for the whole run. The server log is usually the answer. In one investigated case it recorded a channel stopping and its listener rebinding 421 milliseconds later, and the probe's one refused connection fell inside that window.

Read the trace rather than error-context.md, which has been empty on more than one occasion where the trace held the real message.

Copy the artefacts out before re-running anything. A passing run clears test-results, so the trace of the failure you are investigating disappears the moment you try to reproduce it. Once lost, the question it would have answered cannot be answered.

Stop the interop containers first. Stop them before a full browser run, and start them with ./scripts/interop-up.sh afterwards if you still need them.

Eight containers hold around 9.8GB of memory, and on a machine without much more than that there is too little left for Chromium's processes, node and the Go toolchain together. What it produces is not an out-of-memory error but a renderer that stops being scheduled: one recorded trace shows a 60.2 second gap with no frames at all, during which Playwright waited for a button to become "visible, enabled and stable" and could not get two consecutive animation frames to compare. The server was healthy throughout, the slowest request in the whole run being 154 milliseconds.

That failure reads as a defect in whatever test happened to be running. It is worth knowing the shape of it: every request fast, the page visually frozen, the retry loop never reaching a second iteration, and the whole thing passing in isolation.

Only one run at a time. The suite shares one server, one signed-in session in web/e2e/.auth/admin.json, and one state file naming the server's process. None of that is per-run, so a second run starting while the first is going overwrites the state file, and whichever teardown finishes first kills the other run's server and deletes its session.

What that looks like is nothing like the cause. It produced a run of 279 failures whose first message was ENOENT: no such file or directory, open './e2e/.auth/admin.json', a run that stopped early reporting 217 passed, and two tests failing with bind: address already in use — none of which suggest two runs fighting. The suite now takes a lock and refuses to start, naming the process that holds it. A lock left by an interrupted run is taken over rather than blocking for ever, because a safety measure that wedges the suite is one somebody deletes along with the safety.

A related trap, now fixed: two specs named their listening ports outright, so a server left behind by an interrupted run made them fail on something they had nothing to do with. Ports are asked of the operating system now.

Do not answer any of this with retries. A test that passes on the second attempt found a real race and hid it, and playwright.config.ts sets retries: 0 for that reason.

One caution: sampling every 100 milliseconds spawned twenty processes a second, stretched a run by half again, and failed two tests on timeouts that had nothing wrong with them. An instrument that changes what it measures produces findings about itself.

14.6 What still needs real infrastructure

Several parts of Perfuse are implemented and have never been exercised against the real thing. They are listed here rather than left to be discovered:

The features in this chapter and the next exist so that you can gather your own evidence rather than taking anybody's word.

14.7 Measured against public conformance corpora

A test suite whose fixtures were written here proves agreement with oneself. These are other people's data.

14.7.1 FHIR R4, the specification's own examples

All 2,912 example files published with the FHIR R4 specification — every example the authors of the standard wrote.

ResultCount
Resources validated with no findings13,723
Resources reported invalid0
Files refused as an unimplemented resource type753
Bundle entries skipped as an unimplemented type1,348

Not one resource was validated incorrectly. Every failure is an explicit refusal naming the type it cannot read — 672 ValueSet, 80 ConceptMap and one Parameters. A validator that quietly passes what it does not understand is worse than one that says so.

The corpus found a defect, which is the reason for running it. Validating a bundle printed "Bundle validation from a file is not supported yet" and carried on. That was false in both directions: bundles whose entries happened to be types the parser could hold were already being validated, and a bundle that was genuinely invalid produced the same line, counted nothing, and exited zero — including under -strict, which exists so this can gate a build. Forty-two of the specification's own bundles were being skipped.

Bundles are now validated entry by entry, with three outcomes kept apart: an entry that is wrong fails and names the field, an entry of a type this build cannot read is counted as skipped and reported, and a bundle carrying no resource bodies is neither. The same corpus went from 2,100 resources checked to 13,723.

14.7.2 HL7 v2, the HAPI test corpus

The message fixtures from HAPI, the reference Java HL7 v2 implementation — deliberately awkward material including uuencoded payloads, escaped delimiters and repeating groups.

59 messages, 59 parsed, nothing refused. Seven message types in a corpus any single system would describe as one thing, which is the point perfuse profile exists to make.

14.7.3 What has not been run

DICOM against a public conformance set, and X12 against a published corpus. Neither has been done, and neither should be inferred from the two above.

15 Migration from Mirth

Two things are needed to replace a working engine: the channels have to come across, and somebody has to be able to show that the replacement produces the same output. Perfuse does the first by importing Mirth's own exports, and the second by comparing the two engines on your own traffic.

Everything in this chapter applies equally to the Open Integration Engine and BridgeLink. Mirth Connect stopped being open source at 4.6 in March 2025; OIE (hosted by the Eclipse Foundation) and BridgeLink (Innovar Healthcare) are the forks that continue the open-source line, and both write the same export formats. The importer is tested against all three, below.

15.1 Importing channels

Perfuse reads every export these engines write:

ExportFrom the AdministratorWhat comes across
A channelExport ChannelThe channel
A channel groupExport GroupEvery channel in it, each keeping the group
Code templatesExport Code TemplatesEach library as one script file
A server backupBackup Config, or GET /api/server/configurationAll of the above at once

Point perfuse translate at any of them, or drop one on Administer → Migrate:

perfuse translate -o ./channels server-backup.xml

A code template library becomes lib/<library>.js beside the channels, and every channel Mirth had the library enabled for gets scripts.include: [lib/<library>.js] — so a transformer calling a site function such as formatMRN() still finds it. A channel the library was not enabled for does not include it, because its scripts could not see it in Mirth either. Drag-and-drop snippets are left out: Mirth only ever pastes those into a step by hand, so they are not code that runs on its own. In the browser, importing a channel saves the libraries it includes first.

A Channel Writer names the channel it delivers to by id. In a group or a backup the target is in the same document, so it becomes a channel destination naming that channel; from a single-channel export it is reported for you to fill in. Server-wide global scripts have no Perfuse equivalent and are reported rather than dropped.

The report says, per channel, whether it translated cleanly, translated with warnings, or could not be read.

The headline is a fraction with its denominator — "37 of 40 ready" — because "37 ready" tells you nothing about what you still have to do.

A channel that could not be read is a different problem from one that could not be translated. The first means the XML itself did not parse and is usually a truncated or partial export; the second means the channel's content has something without a Perfuse equivalent. The import separates them.

15.2 What translates and what needs a decision

Sources, destinations, filters and the ordinary transformer steps translate directly. The connectors that translate: TCP/MLLP (and raw TCP with its framing), HTTP, File, Database, JavaScript, DICOM, Web Service (SOAP), SMTP and Channel Reader/Writer. JMS and the Document Writer do not, and are reported as blockers.

A Mapper step that sets a variable puts it into the map its scope names — channelMap by default — exactly as Mirth does, so later steps reading it with $('name') find it.

JavaScript transformers do not, in general. Mirth channels commonly hold substantial code in a transformer, and there is no automatic conversion from arbitrary JavaScript to declarative steps. The importer reports these rather than attempting a translation that might be subtly wrong: a mistranslated transformer is worse than one flagged for a human, because it will run.

Where a script genuinely has to remain a script, Perfuse can run it — see the scripts key in the channel reference — at the cost of the step-level trace described in debugging.

15.3 What the importer has been tested against

Five engines, each authoring its own documents: Mirth 4.5.2, OIE 4.5.2, 4.6.0 and 4.7.0, and BridgeLink 26.9.0. scripts/mirth-engine-corpus.sh compiles a small program against each engine's own jars and has its own serialiser write three ordinary channels — an MLLP feed with a mapper, a script calling a code template library and a filter; an HTTP feed into a database; a file poll out over MLLP — plus the library and a channel group. It loads them into the running engine and downloads the engine's own server backup. The results are committed under internal/mirth/testdata/engines/, and the test suite holds the importer to every one of them on every build: each backup is read, translated, loaded, and the ADT channel is run with two messages to check the library pads the record number and the filter drops the result.

Perfuse's own exports go the other way — Perfuse channels imported into each running engine and checked that the engine did not store them as invalid. All five accept every transport Perfuse exports. Open Integration Engine 4.7.0 added two HTTP settings with no equivalent here: an HTTP Sender bound to a chosen local address is reported as a warning, and an HTTP Listener request-header limit only when it is above the 1 MB Perfuse accepts.

The first run against this corpus found four defects, all fixed: the translator refused HTTP and File sources as "only MLLP is supported" long after Perfuse had both; it read an HTTP Sender's address from a field none of the engines writes; it translated Mirth's socket buffer size as a 64 KB message limit; and a Mapper's variable became a JavaScript global that $('mrn') never saw, so the library padded an empty string.

The single-channel fixture that started this was also exported by Mirth 4.5.2, authored by Mirth itself rather than written here. That distinction earned its place: the only fixture in this repository used to be hand-written, and a real Mirth will not load it — the API accepts the POST and then stores the channel as This channel is invalid. Verify all required extensions are loaded correctly, with every destination connector discarded.

Hand-writing one was never going to work, and the reason is worth knowing if you are producing exports of your own. Mirth's serialiser ignores elements it does not recognise, so a wrong element name produces no error whatsoever — the connector simply is not there afterwards. There is nothing to read and nothing to correct against.

scripts/mirth-author-channel.sh builds one using Mirth's own model classes and its own serialiser, then posts it to a running server and fails if the server rejects it. Perfuse's importer reads the result: name, source transport and destinations all survive.

15.4 Proving it matches

This is the part that decides whether a migration happens, and it needs evidence rather than confidence.

Perfuse compares its own output against the old engine's, message by message, on your traffic. What it needs is pairs: the message as it arrived, and the message the old engine produced from it. Both are in Mirth's own message store and can be exported from its message browser.

Perfuse cannot reach into Mirth, and this manual will not pretend otherwise. A tool that claimed to verify a migration without ever seeing the old engine's actual output would be verifying nothing. Supplying the pairs is the honest shape of the exercise.

15.4.1 Four numbers, not a pass rate

ResultMeaning
IdenticalByte for byte the same.
Cosmetic onlyDiffers in ways that carry no information — trailing empty fields, a final terminator.
DifferA field's value is different.
Would not runPerfuse could not process the message at all.

They are four numbers because they mean four different things and collapsing them hides the ones that need a decision.

Identical means byte for byte, not equivalent. A weaker test would let differences in segment order or trailing separators through, and those are exactly what a fussy downstream system rejects.

Cosmetic differences are counted separately rather than folded into the good column. They genuinely carry no information, so treating them as failures would bury real findings under thousands of nothing — but they are not identical either, and a receiver doing its own strict parsing may disagree.

"Would not run" is separate from "differ" because it is usually a configuration gap the importer could not fill, not a mapping decision. If everything is in that column, the verdict says so and tells you to look at the configuration before reading anything else.

15.4.2 Differences are grouped by field

A run over fifty thousand messages reporting four thousand differences has told you nothing you can act on. The same run reporting that PID-8 differs in every message because the old engine wrote Male where Perfuse writes M has told you exactly one thing to decide.

So each finding is a field, with a count, and it says whether the difference is systematic — the same value pair every time — or whether the field's content differs.

That distinction is the most useful thing in the report. One distinct value pair across four thousand messages is a single mapping decision. Four thousand distinct pairs is the content differing, which is a different and much worse problem, and the two must not read alike.

Findings are ordered by how many messages they affect, so the largest single cause is first.

15.4.3 The verdict does not round

It gives exact counts: 49,993 of 50,000, not "over 99%". The seven are the whole point of running it.

It is written to be quotable in a change request, because that is where the number ends up.

15.5 A suggested sequence

  1. Import the channels and resolve anything reported as unreadable.
  2. For each channel, review the warnings. Most will be scripts.
  3. Export a few thousand message pairs from Mirth for the busiest channel and run the comparison.
  4. Work through the findings. Systematic ones are one decision each.
  5. Re-run until the only differences are ones you have decided are correct.
  6. Run Perfuse in shadow alongside Mirth on live traffic, delivering nowhere, and compare again over a period that includes a weekend and a month end.
  7. Cut over one channel, not all of them.

15.6 What only runs on one vendor's software

The migration screen answers a second question, and it is worth asking before you have any opinion about Perfuse: how much of your integration logic can only run on the software you have.

Every channel gets a Portability verdict, from the same scan that produces the migration notes. It runs against a channel export, so it needs nothing installed and changes nothing.

15.6.1 The distinction is the whole point

Most Java in Mirth scripts is not lock-in. SimpleDateFormat, HashMap, Apache Commons — all of it working around a JavaScript engine from 2009, all of it ordinary, all of it with direct equivalents. A channel with three hundred of those references is fully portable, and a tool that reported "300 Java references, you are trapped" would be lying to you.

Lock-in is specifically the calls that need the vendor's own server: com.mirth.connect.* and vendor jars. Five of those and you are stuck. Five hundred of the other kind and you are free.

So the verdict leads with the number that matters and says in the same sentence that the larger number is not it. Where there are vendor calls, they are listed by name — a count is an assertion, and a list is something your own engineer can go and check.

A third category is kept separate: capability that exists but not from a script. Starting a channel from JavaScript has no equivalent here, and there is an API endpoint that does exactly the same thing. Calling that lock-in would overstate the finding.

15.6.2 Java calls that run unchanged

The commonest of those Java classes run as they are written. Perfuse has no Java runtime, so these are JavaScript classes with the same names and methods, enough for the way channel scripts use them; importPackage(java.util) and importClass(...) bring them into scope by their short names, as in Rhino (JavaScript's own Date, Map, Set, Math, String and Boolean are never replaced: the Java ones are still there by full name). The migration report counts each of these calls as already supported.

PackageClasses
java.utilArrayList, LinkedList, Vector, List, Collection, HashMap, LinkedHashMap, Hashtable, TreeMap, Properties, HashSet, LinkedHashSet, TreeSet, Arrays, Collections, Date, Calendar, GregorianCalendar, UUID, Base64, regex.Pattern (with Matcher), concurrent.ConcurrentHashMap
java.langString, StringBuilder, StringBuffer, Integer, Long, Short, Byte, Double, Float, Boolean, Math, System (time and line separator only)
java.text, java.math, java.net, java.nioSimpleDateFormat and DateFormat (Mirth's pattern letters), BigDecimal (as a double), URLEncoder, URLDecoder, StandardCharsets
Apache Commonslang3.StringUtils and lang.StringUtils (about sixty methods), StringEscapeUtils, codec.binary.Base64

They behave as Java does where scripts depend on it: Integer.parseInt("x") throws NumberFormatException, String.replaceAll and split take regular expressions while replace is literal, maps key strings by value, TreeMap and TreeSet iterate in order, and SimpleDateFormat.parse returns a date getTime() works on. A method a script calls that the equivalent lacks fails at that line, naming it; any other Java class fails by name, as before. BigDecimal is a double here, so it does not keep Java's exact decimal arithmetic: check scripts that use it for money.

15.6.3 External scripts are reported as unknown

A step that runs an external script file references a path on the old server, and the file is not in the export. Nothing can be said about what it contains, so it is counted as unknown rather than assumed either way. Fetch those files before concluding anything — in both directions.

15.6.4 Perfuse's own lock-in, stated in the same breath

This section would be dishonest without it.

Perfuse is Apache 2.0, which is irrevocable for code already released. You can fork what exists today and nobody can withdraw that. Channels are YAML files and scripts are ordinary JavaScript, so there is no proprietary surface to call — Mirth's flavour of Java dependency is structurally unavailable here, which is the one place a limitation is genuinely a feature.

What that does not promise: that a future version stays open, that a hosted service exists at a price you like, or that migrating away from Perfuse costs nothing. Moving any integration layer means re-testing every channel against real traffic. What you are choosing is whether the artefacts you build are readable and portable, and whether the exit depends on a licence somebody else controls.

15.6.5 Setting up step 6

One message real traffic Live channel unchanged Candidate the version you are testing Receiver delivers nothing Comparison field by field The candidate sees the same real messages as the live channel and delivers none of them. Only the live channel reaches the receiver. What you read afterwards is where the two disagreed, field by field — not a pass or a fail.

The Shadow section starts a comparison. Choose the channel that is running, choose the candidate to compare against from the list, and start.

Three things about it are worth knowing before you rely on the numbers.

The candidate is chosen from a list, not typed. A shadow names a channel file, and a name that does not resolve makes the *live* channel invalid — Perfuse refuses a channel wholesale when anything it references is broken. So a typo does not produce a warning, it makes the running channel disappear.

The share is a percentage. Lower it only on a busy feed, and know what it costs: sampling reduces work and confidence in the same proportion, and the message that would have shown the difference is the unusual one.

Fields to ignore is usually needed. A channel that stamps a timestamp or a sequence number differs on every single message, which reports a difference rate of 100% and tells you nothing. MSH-7 and MSH-10 are the common pair.

A comparison is written to the channel file as soon as you start it, and begins observing when the channel next loads — the screen says so rather than claiming it is already running. Stopping a comparison leaves the candidate channel in place, because the candidate is the thing meant to go live.

Step 6 is the one that cannot be shortened. The differences that matter are usually in traffic that only appears occasionally — a rare trigger event, a month-end batch, a sender that behaves differently at midnight — and a sample taken over an afternoon will not contain them.

15.7 Going back the other way

A channel can be exported as a Mirth channel file, from the browser: Channels, then To Mirth on the channel you want.

This exists because the objection to replacing a working engine is rarely whether the replacement is any good. It is what happens if the decision turns out to be wrong, and an export that goes back the way it came is the cheapest answer to that.

Read what the dialogue tells you before you take the file. Mirth's format has nowhere to put a Perfuse filter, a transformation chain, a contract or a shadow comparison, so an exported channel does less than the one it came from. The transports and their addresses convert; the logic does not. Each loss is listed by name before the file is offered, and the same list goes into the channel's description so that whoever imports it sees it too.

A channel Mirth cannot express is refused rather than approximated. An S3 destination has no Mirth equivalent, and a channel that imported cleanly while delivering somewhere other than the bucket would be worse than no file at all. The refusal names the destination at fault.

The connector definitions are not written from a reading of Mirth's format. scripts/mirth-dump-connector-templates.sh has Mirth's own serialiser produce the defaults for each connector class, and Perfuse substitutes values into those documents — refusing any setting the template does not already contain. This matters because of how Mirth answers a channel it cannot parse: it does not refuse the import. It stores a channel whose description has been replaced with "This channel is invalid. Verify all required extensions are loaded correctly" and whose destinations have been silently discarded. An exporter checked against its own output rather than against a server passes every test while producing documents that are thrown away, which is exactly what happened here before the exports were tried against a running Mirth.

What crosses today: MLLP and TCP in both directions, HTTP in both directions, file reading and writing, database reading and writing, DICOM in both directions, and SMTP, SOAP and JavaScript destinations. Each pair is verified by importing into a real Mirth 4.5.2 and checking the server did not quietly replace the channel.

16 CMS-0057 for payers

The CMS Interoperability and Prior Authorization final rule (CMS-0057-F) requires impacted payers — Medicare Advantage organisations, state Medicaid and CHIP programs and their managed care plans, and QHP issuers on the federal exchanges — to run four FHIR APIs from 1 January 2027, and to post prior authorisation metrics on their public website every year from 2026.

Perfuse provides the four APIs on its FHIR endpoint and the conversions that fill them: adjudicated claims as CARIN Blue Button, prior authorisations as Da Vinci PDex, and the yearly metrics in the layout of CMS's own template. The CMS-0057 view, under Exchange, says which of the four this instance can answer and what each still needs, and does all three conversions in the browser.

Drugs are out of scope throughout, as they are in the rule's prior authorisation provisions: no pharmacy claims, no formulary, and a metrics row naming a drug is refused rather than counted.

16.1 The four APIs

APIWhat it servesGuidesTurned on by
Patient AccessA member's claims, clinical data and prior authorisations, to an app they chooseCARIN Blue Button 2.2.0, PDex 2.2.0, US Core, SMART App Launch-fhir and a SMART issuer (-smart-issuer)
Provider AccessThe same for a provider's attributed members, in bulkPDex 2.2.0, Da Vinci ATR 2.1.0, Bulk Data-fhir-payer-apis and -fhir-bulk-export
Payer-to-PayerA member's record to another payer, after matching themHRex 1.2.0 $member-match, PDex 2.2.0-fhir-payer-apis
Prior AuthorizationWhat an order needs, the documentation, then the request and decisionDa Vinci CRD, DTR and PAS 2.2.x (2.2.1, 2.2.0, 2.2.1) and 2.1.0, with X12 278 translation-fhir, writable, and -crd-rules

The Patient Access API needs no operation of its own: it is the FHIR endpoint's ordinary read and search, protected by SMART, with CARIN and PDex resources in the store. Searching ExplanationOfBenefit supports the parameters CARIN names — patient, identifier, type, service-date, billable-period-start, provider, insurer, coverage — and Coverage supports subscriber-id, identifier and payor.

16.1.1 Two releases of the guides: 2.1.0 and 2.2.x

CMS-0057-F recommends CRD, DTR and PAS 2.0.1 and 2.1.0. The 2.0.1 releases rest on US Core 3.1.1, which expired on 1 January 2026, so 2.1.0 is the release an EHR built for the rule is likely to speak; the newest are CRD 2.2.1, DTR 2.2.0 and PAS 2.2.1. Perfuse answers in both. It works out which a caller speaks, most specific first:

  1. from the request: CRD's CDS Hooks extension davinci-crd.requestedVersion ("2.1"), or the version on a Da Vinci profile a DTR

or PAS input claims (profile-pas-request-bundle|2.1.0);

  1. from the caller's registration: davinci_version: "2.1" on a SMART client (-smart-clients) or a CDS client (-cds-clients);
  2. from the server: serve -davinci-version 2.1; the default is 2.2.
clients:
  - issuer: https://ehr.example.org
    jwks_url: https://ehr.example.org/.well-known/jwks.json
    davinci_version: "2.1"

Every answer is decided and kept in the 2.2.x form and rewritten for a 2.1.0 caller as it is sent, so the rules, the review queue, the X12 278 link and the metrics are the same for both. The rewrite covers what the releases spell differently: CRD 2.1.0 has no indeterminate (Perfuse says conditional, with the reason) and no category on a coverage detail; DTR 2.1.0 names the package PackageBundle and leaves the QuestionnaireResponse to the app; PAS 2.1.0's $inquire answers with one Bundle, and it lacks the reviewer and transmission extensions. The full list, and the check against each 2.1.0 guide's own examples, is in conformance/davinci21. A PAS notification follows -davinci-version, since a subscription does not say which release its owner reads.

16.2 Coverage requirements and documentation: CRD and DTR

The Prior Authorization API is in practice three Da Vinci guides together. CRD tells the clinician, while the order is still a draft, whether it is covered, whether it needs prior authorisation and what documentation is wanted. DTR hands the EHR the questionnaire that gathers that documentation. PAS submits the request and returns the decision. Perfuse serves all three as the payer.

-crd-rules rules.yaml serves CRD over CDS Hooks at /cds-services: all six of CRD's hooks, order-sign, order-select, order-dispatch, appointment-book, encounter-start and encounter-discharge, each with a prefetch for the patient's active Coverage. The two encounter hooks also prefetch the encounter, and answer about the encounter itself, matched by its type and service type, as appointment-book answers about an appointment. The rules are the payer's policy, kept in a file because they change without software releases:

payer: Springfield Health Plan
rules:
  - description: Home oxygen equipment
    codes: [E0424, E0431]
    covered: covered            # covered, not-covered, conditional
    pa: auth-needed             # no-auth, auth-needed, satisfied, performpa, conditional
    documentation: [clinical]
    questionnaire: https://www.springfield-health-plan.example/fhir/Questionnaire/home-oxygen

Each order gets a systemActions update carrying CRD's coverage-information extension - which the EHR stores on the order and later sends on with the prior authorisation - and a card the clinician reads. An order whose code has no rule is answered conditional, never assumed covered; a request with no Coverage at all gets a card saying so. examples/crd/ has a rules file and a matching questionnaire.

A rule can also carry the rest of what CRD lets a payer say:

own invariant crd-ci-q4 rejects it.

What the EHR did not prefetch, Perfuse reads from the EHR's fhirServer with the token it sent. If that fails, the answer is indeterminate with CRD's technical reason, never a guess. A Coverage that has ended or is not active is answered not-covered, no-active-coverage.

members: fhir in the rules file resolves the coverage against the payer's own records in this server's FHIR store (start it with -fhir), the same records $member-match reads:

with that number.

Discovery declares davinci-crd.version and a coverage-info configuration option. When a client turns the option off, the answer is empty. EHR JWTs are checked as CDS Hooks defines them, with iss, aud, exp, iat and jti and no sub, and a repeated jti is refused.

Calls authenticate with the JWT an EHR signs for each call, from an issuer named in -cds-clients with its JWKS URL - which is how CDS Hooks describes it - or with a Perfuse API token. Discovery is open, as CDS Hooks requires.

DTR: load the questionnaires into the FHIR endpoint, and POST /fhir/Questionnaire/$questionnaire-package returns them as DTR package bundles, with the CQL Libraries they name. The request can name the questionnaire, or send the order and let its coverage-information say which.

Trying it: Payers → Claims & auth → Coverage requirements (CRD) sends an order to this server's rules, or to a payer's CDS service URL, and shows the cards and the coverage information.

16.3 Claims as CARIN Blue Button

perfuse cms0057 carinbb pairs each claim in an 837 with its payment in an 835, by the patient account number the 835 is required to echo back, and writes a FHIR transaction for each: the ExplanationOfBenefit with the Patient, Coverage, Organizations and Practitioners it references.

perfuse cms0057 carinbb -system https://fhir.your-plan.org/identifier -network innetwork \
    claims.837 remittance.835

The profile follows the claim: professional claims (837P) become C4BB-ExplanationOfBenefit-Professional-NonClinician, and institutional ones (837I) inpatient or outpatient by the type of bill. Every entry is a PUT with an id taken from the source identifiers, so loading the same claim twice updates it rather than making a second copy.

Three things are not in either X12 file, and each is handled by saying so rather than by guessing:

Diagnoses are written with their decimal point (J02.9, not J029), because that is how the code system publishes them and a terminology server asked about the other spelling says it does not exist. A dependent's member identifier is the subscriber's, which is what the 837 carries, and the notes say so. An NPI whose check digit is wrong is reported before US Core rejects it.

16.4 Prior authorisations as PDex

perfuse cms0057 priorauth converts a Da Vinci PAS ClaimResponse — or the PAS response bundle that carries one — into a PDex prior authorisation ExplanationOfBenefit, which is what all three data APIs serve.

perfuse cms0057 priorauth pas-response.json pas-claim.json

The PAS Claim is optional and nearly always needed. An ExplanationOfBenefit must say which coverage the decision was made under, and only the request carries that; without it the conversion stops and says so. The review action — approved, denied, pended, with its authorisation number and reason — moves from PAS's extension to PDex's, and the X12 service type keeps its code under the URI PDex binds it to.

16.5 The Provider Access and Payer-to-Payer APIs

With -fhir-payer-apis the FHIR endpoint answers three more operations:

The exports apply the rule's exclusions on the way out. Claims lose the payment and every cost-sharing and remittance amount, and keep the services, codes, dates and providers; the CARIN profile is removed from meta.profile because the resource no longer meets it. Payer-to-Payer also leaves out denied prior authorisations. Members holding an active PDex Provider Access opt-out Consent are left out of a provider export, and the log records how many. The patient parameter can only narrow to members of the Group; naming anyone else is refused.

Access to a Group's export is decided by the endpoint's authentication. SMART scopes say what kinds of resource a token may read, not which Group, so each provider needs a token that can reach only its own attribution list. An API token can be limited to Groups when it is made — perfuse token create -label riverside-provider-access -fhir-groups riverside-attributed, or the Limit to FHIR Groups field under Users → Machine credentials — and on the FHIR endpoint it can then read those Groups, run $davinci-data-export or $export on them and fetch what the export produced, and nothing else. Another provider's Group answers not-found rather than forbidden, so a token does not learn which other attribution lists exist; every other request, member matching and ordinary search included, is refused. The limit applies to API tokens; a token from a SMART issuer carries no Group limit. When SMART is configured as well, API tokens are refused unless Settings → FHIR → Accept Perfuse API tokens beside SMART tokens is on (see Security, *Who may call the FHIR endpoint*); a provider's Group-limited token needs it.

16.6 A provider directory: Plan-Net

CMS-0057 also asks payers for a provider directory, and the FHIR endpoint serves one to Da Vinci Plan-Net 1.1.0 with no option to turn on: load the Practitioners, PractitionerRoles, Organizations, OrganizationAffiliations, Locations, HealthcareServices, InsurancePlans and Endpoints, and members' apps can search them. Every Plan-Net search parameter is indexed - a role's network and service, a plan's coverage area, an affiliation's participating organization, an organization's partof - along with the includes and reverse chains (_has) the guide's queries use, and the CapabilityStatement lists the Plan-Net profiles.

Inferno's Plan-Net suite passes 199 tests. The 10 it fails are forward chains where the kit takes its search value from the first Organization it has seen rather than one the searched resources refer to; that is reported to the kit (its issue 34), and the server answers those queries correctly when given a value the data holds.

16.7 Prior authorisation metrics

perfuse cms0057 metrics reads a CSV of decisions exported from the utilisation management system and produces the public page CMS requires, in the layout of its *Prior Authorization Metrics Reporting — Overview and Template*.

perfuse cms0057 metrics -year 2025 -org "Springfield Health Plan" -services services.csv \
    decisions.csv > prior-authorization-metrics-2025.html

Columns are found by header: priority, received, decided and decision are required, and request_id, line_of_business, extended, appealed, appeal_decision, service_code and service_description are read when present. A request belongs to the year it was decided in; one still pending is listed as excluded, never counted. Every row that cannot be read is reported with its line number.

The arithmetic follows the template: for standard and expedited requests separately, the share approved and denied, approved and denied within the decision timeframe, approved after the review time was extended, and approved after appeal — out of appeals, not out of requests — with the mean and median time from receipt to decision. A median under a day is written in hours rather than rounded to "0 days", which the template asks for explicitly. Partly approved requests are counted as approved, since the template has no category for them, and the page says how many there were. Metrics are reported per line of business, which is the level CMS requires.

The page is self-contained — no script, no external stylesheet or font — because it is posted on a public website by whoever maintains that, and anything it loads from elsewhere is something else that can break or track a visitor. -format csv and -format json give the same numbers for other uses. Without -services the page carries a notice that the list of items and services requiring prior authorisation is missing, because CMS requires it on the same page.

16.8 Drugs: the formulary, Real-Time Prescription Benefit and electronic prior authorization

CMS-0062-P (proposed 14 April 2026) would bring drugs into prior authorization: impacted payers would support NCPDP SCRIPT electronic prior authorization, the NCPDP Formulary & Benefit standard and NCPDP Real-Time Prescription Benefit from 1 October 2027, decide drug requests within 24 hours (expedited) and 72 hours (standard), give a specific reason for every denial, and report drug metrics from 2028. Medicare Part D already requires SCRIPT ePA. One file, the formulary, answers all of it:

perfuse serve -fhir -pas -crd-rules payer-rules.yaml -formulary formulary.yaml

examples/formulary/formulary.yaml is a sample whose drugs are real RxNorm concepts with NDCs RxNorm lists for them (checked against RxNav). A file names the payer, its plans (each with the formulary it uses, its group IDs and its cost shares by pharmacy benefit and tier) and its formularies. Each drug is an RxNorm SCD or SBD with its RxNorm group (the SCDG or SBDG the Drug Formulary API requires), its NDCs, a tier, and its limits: prior_authorization, step_therapy, quantity_limit (a quantity in a rolling number of days, and a daily maximum), not_covered, alternatives (RxNorm codes, most preferred first), notes, and questionnaire, the canonical URL of the FHIR Questionnaire a prior authorization asks. cost_30_day is the plan's cost of a 30-day supply, which a coinsurance is a percentage of; without it a coinsurance is quoted as a percentage, never as an invented amount. The whole file is checked when it loads, every problem listed at once. A member is found by the file's members list, by group, or through the FHIR store: a Coverage whose subscriberId is the member ID and whose class of type plan is the plan's id. Payers › Drugs shows the formulary, prices a drug and runs a prior authorization end to end.

16.8.1 The Da Vinci US Drug Formulary API

Published at /formulary/fhir as Da Vinci US Drug Formulary 2.1.0: each plan a PayerInsurancePlan, each formulary a Formulary (both InsurancePlan), each drug a FormularyDrug (MedicationKnowledge) and each entry a FormularyItem (Basic). The guide's searches are served (Basic?code=formulary-item&formulary=...&drug-tier=...&pharmacy-benefit-type=...&period=..., subject:MedicationKnowledge.code, InsurancePlan?formulary-coverage=..., coverage-type, MedicationKnowledge?code=...&doseform=...) with _include=Basic:subject, Basic:formulary and InsurancePlan:formulary-coverage. It is public, with no credential, as CMS requires of a payer's formulary: it is served from a store of its own, in memory, that holds the formulary and nothing else, so being open cannot reach a patient's record. It is read-only. An excluded drug is not published, since the API lists what a formulary covers; RTPB and ePA still answer it as not covered. Verified with the HL7 FHIR validator against the guide's 2.1.0 package with terminology checked: every resource generated from the sample has no errors.

16.8.2 Real-Time Prescription Benefit

POST /ncpdp/rtpb takes an RTPBRequest (NCPDP RTPB v13) with an API token of the operator role and answers an RTPBResponse from the formulary: the coverage status, the formulary status, the member's estimated share and the plan's, and the coverage restrictions, with up to five covered alternatives priced the same way. Restriction codes and the status each maps to follow NCPDP's public RTPB Implementation Recommendations: 75 prior authorization required, 608 step therapy, 76 plan limits exceeded, 7X days supply over the plan limit (a drug not available by mail, say), 70 not covered, all *covered with restrictions* (CR) except 70 (NC). A member with an approved prior authorization on file gets CC and AB8, as the Surescripts guide asks, so the prescriber is not sent to ask again. A request that cannot be priced is NotProcessed with an NCPDP reject code: 52 for a member not found, E7 and 19 for a missing quantity or days supply. The pharmacy benefit is read from the request: a fill over 34 days is a three-month supply, and pharmacy type M is mail order.

16.8.3 Electronic prior authorization (NCPDP SCRIPT)

POST /ncpdp/script takes PAInitiationRequest, PARequest, PAAppealRequest and PACancelRequest with an API token of the operator role, and answers each with its response:

RequestAnswer
PAInitiationRequestClosed when none is needed: CC no prior authorization required, FN within the quantity limit, FM not covered or not on the formulary, FQ an approval on file, CD no such member, CG one already in process. Otherwise Open, with a case ID and the question set.
PARequestDecided as a PAS request: Approved with the authorization number and period, Denied with the reason, or Open under review.
PAAppealRequestA denied case decided again, on the reason in PANote (an appeal without one is asked for it).
PACancelRequestApproved while the case is open or under review, and the PAS request is cancelled; Denied once it is decided.

The question set is the drug's FHIR Questionnaire from the FHIR store, the same one DTR fills in: a boolean becomes a yes/no choice, a choice a choice, a number numeric, a date a date, and anything else free text; groups are flattened in order, and enableWhen is not carried, since SCRIPT branches by choice and not by condition. A drug with no questionnaire is asked three free-text questions: the diagnosis, what was tried, and why this drug. A PARequest becomes a Da Vinci PAS request: a Claim for the drug (RxNorm and NDC), the MedicationRequest it asks for, the patient, prescriber, payer and coverage, and the answers as a QuestionnaireResponse, with any attachments as DocumentReferences. It is then decided as every prior authorization on this server is: the rules file, a decision service (-pas-decider-url), or the reviewer queue, where it is marked Drug and held to the drug timeframes. A decision service sees a drug request exactly as it sees one from an EHR's PAS client. We recommend Vaglio, BioDream's source-available (PolyForm Shield) decision engine, which has drug policies with step therapy; a payer's own engine plugs in through the same -pas-decider-url, and none is needed for the rules file and reviewers to decide.

A decision made after the request was answered (a reviewer's, an appeal's) is sent to the prescriber as an unsolicited PAResponse or PAAppealResponse, routed to the channel named by -epa-reply-channel, which delivers it to the network. Without one, or when delivery fails, the case says the prescriber has not been told and the screen sends it again.

Payers › Drugs › Prior authorization sends the messages a prescriber's system would to this server's own endpoint (start, answer the questions, see the decision), lists every case with what the prescriber was told, and shows the drug figures CMS-0062-P proposes: requests approved, denied and pending, appealed and approved or denied on appeal, the mean and median hours from request to decision, and how many were decided late. Medical-benefit drugs requested through PAS (a HCPCS J code with a MedicationRequest, or an item coded in RxNorm or NDC) are counted as drugs in the PAS figures too.

What this rests on, stated plainly. NCPDP's schemas and implementation guides are licensed. The element names and order here follow the public Surescripts ePA and RTPB companion guides and their examples, and NCPDP's public RTPB recommendations. Two elements the public material never shows are written to the SCRIPT pattern used elsewhere and should be checked against a licensed copy before certification: Closed/ReasonCode and Denied/DenialReason. No message has been checked against NCPDP's XSD or a network's certification. The NCPDP Formulary & Benefit file is not produced: its full layout is in NCPDP's licensed guide, and a partial file would be refused by the network that loads it.

16.9 What was checked, and against what

The CARIN and PDex outputs were validated with the official HL7 FHIR validator, loaded with the published packages (hl7.fhir.us.carin-bb#2.2.0, hl7.fhir.us.davinci-pdex#2.2.0): professional, inpatient and outpatient claims and two PAS decisions, with no errors. The PDex inputs were the PAS guide's own examples rather than shapes written here to agree with the converter. The validator found five defects on the first run — among them that CARIN requires the profile version in meta.profile and the claim-type code system version on institutional claims — and each is fixed and covered by a test.

Building this found a defect older than it: the FHIR store dropped every element its internal model did not declare, so an ExplanationOfBenefit lost its items, diagnoses, adjudication and payment on the way in. The store now keeps whatever a resource carries, and a test loads a CARIN claim through the server and reads it back whole.

CRD's output - the updated order, and the CDS Hooks response against CRD's own logical model - and DTR's package output validate in the official HL7 validator against CRD 2.2.1 and DTR 2.2.0 with no errors.

DTR here is the payer's side of 2.2.0. $questionnaire-package takes the questionnaires named, those CRD named on an order, or the coverage assertion id CRD returned (context, while the same process is serving CRD), and returns each as a package bundle with a QuestionnaireResponse to fill in, the Libraries its CQL needs and their dependencies. The value sets its answers use travel in the package when they are loaded (PUT /fhir/ValueSet/{id}), and $expand lists their codes. $log-questionnaire-errors writes what a DTR app reports to the server log.

Adaptive questionnaires: write the whole questionnaire once, with SDC's questionnaireAdaptive extension and enableWhen on the questions that only apply sometimes. The package hands the app an empty shell, and each $next-question call adds the next question that applies to the answers so far, until there is nothing left to ask and the response is marked completed. The order and the branching are the payer's own rules; questions that do not apply are never shown.

Signing in as a client: DTR and PAS clients reach the payer with SMART Backend Services - a signed JWT exchanged for a token at the payer's authorization server. Point Perfuse at that server with -smart-issuer (and -smart-jwks if its address does not resolve from Perfuse), and pass -smart-backend-services so the discovery document says so. The Inferno DTR suite passes this way with Keycloak as the authorization server.

Perfuse can be that authorization server itself: serve -fhir -smart-clients clients.yaml issues Backend Services tokens at /auth/token to the clients listed, each with the public keys it signs its assertions with and the system scopes it may be granted, and the FHIR endpoint accepts them. No separate identity server is needed for a payer's machine-to-machine clients.

With -smart-users users.yaml as well, members and clinicians sign in there to authorize apps, which the Patient Access API needs: the app sends the person to /auth/authorize, they sign in, a clinician chooses the patient, and both see what the app asks for and can untick any of it. A member is only ever given their own record, whatever the app asks for. Each person in the file names the FHIR resource they are, Patient/… or Practitioner/…, and a password hash from perfuse smart hash; the password itself is never written down.

People can sign in at the organisation's own identity provider instead: -smart-oidc oidc.yaml names it (issuer, client id and where the secret is), and the sign-in page offers "Sign in with" it beside the password. Each person is linked by the provider's subject, oidc_subject in the users file, so the file still decides who may authorize apps and which FHIR resource they are; an account linked to nobody is refused. Codes, refresh tokens and revocations are kept in the database, so a restart signs nobody out.

Users → SMART apps in the console lists the registered apps and the people, and adds, edits and removes them. Each save is checked as the files are at start-up, written to them, and used at once. A confidential app saved without a secret is given one, shown once; a password is set there and never shown back. Removing an app, or a person, stops the access tokens already issued to it or granted by them at once, not when they expire, and their refresh tokens with them.

Scopes may be granular, as SMART App Launch 2 and US Core define them: patient/Observation.rs?category=laboratory reads only the Observations that search would find. A grant that is filtered for a type applies its filter to every search and every read of that type; an unfiltered grant for the type, or for *, lifts it. A filter that cannot be read grants nothing rather than everything.

16.10 Deciding prior authorisation requests: PAS

serve -fhir -pas -crd-rules rules.yaml answers Da Vinci PAS 2.2.1 at POST /fhir/Claim/$submit and POST /fhir/Claim/$inquire. The decisions come from the same rules file as CRD's answers, so the order a clinician is told needs authorisation is decided by the rule that said so:

  - description: Physical therapy
    codes: ["PT"]                       # matched on the item's productOrService, then the code of the order it requests
    system: https://codesystem.x12.org/005010/1365
    covered: covered
    pa: auth-needed
    pa_decision: approve                # approve, deny or pend; empty: not-covered is denied, no-auth approved, the rest pended
    details:
      - {code: allowed-quantity, value: "6"}   # asked for more, it is certified for six (A6, with the authorised detail)
  - description: MRI
    codes: ["62"]
    pa_decision: approve
    pa_alternative: {system: "https://codesystem.x12.org/005010/1365", code: "4"}  # approved as an X-ray instead (an added item)
  - description: Surgery
    codes: ["2"]
    pa: auth-needed                     # pended for a reviewer, asking for:
    questionnaire: https://payer.example.org/Questionnaire/surgery   # a DTR questionnaire
    pa_attachments: ["18776-5"]         # and documents, by LOINC attachment code

Each item gets an X12 review action: A1 certified, A3 not certified, A4 pended, A6 modified, C cancelled (certification type 3). A line that names no service is not decided but answered with an error (X12 901 AG, correct and resubmit). The response echoes what a 278 response echoes — item trace numbers, the requested service dates, the encounter's dates, the transmission identifiers with sender and receiver swapped — and gives each line the payer's own administration reference number. A service no rule names is pended, never approved: a decision nobody wrote down is a decision for a person.

Pended requests wait for a reviewer, who answers with POST /fhir/Claim/$decide:

{"resourceType": "Parameters", "parameter": [
  {"name": "claimResponse", "valueString": "<the ClaimResponse id>"},
  {"name": "decision", "valueCode": "approve"},
  {"name": "reason", "valueString": "Reviewed with the plan of care."},
  {"name": "reviewer", "valueString": "<the reviewer's NPI>"}]}

decision may also be modify: certified, but not as asked, with quantity (valueDecimal, the units certified) and/or alternative (valueCoding, the service approved instead). The response then says A6 Modified, with the certified detail or the alternative as an added item, and an authorization number. item (valueInteger, repeatable) limits the decision to some lines. The decision becomes a new version of the response, marked as reviewed by a person, and with -fhir-subscriptions it is delivered on the PAS topic (PASSubscriptionTopic, filtered by org-identifier, the requesting organisation's identifier) as the full PAS Response Bundle, in the same transaction that records it. $inquire returns the current response to every request that provider made about that member; another provider's inquiry finds nothing. $decide needs a write scope; $inquire is a read.

Requests and every version of their responses are kept in pas_requests and pas_responses in the server's database.

16.10.1 The reviewer queue

In the web console, Payers → CMS-0057 → Reviewer queue lists the pended requests soonest due first: an expedited request (Claim.priority stat) is due 72 hours after it arrived, a standard one 7 days. Choosing one shows the member, the provider, each service with the units asked for and where it stands, what the response asked for, and every document received for it, with plain-text documents shown inline. An editor records the decision there: approve, deny, or modify (units certified, or another service approved instead), with a reason and the reviewer's NPI. A modification has to certify fewer units than were asked for; as many or more is an approval, and is refused as a modification. A service approved instead starts in the code system of the one asked for. It is the same decision $decide makes, so the provider's subscription delivers it. Reading a request and deciding it are both written to the audit log.

Monitor → Team dashboards → Prior authorization shows, for the last 30 days, how many standard and expedited requests were decided within CMS-0057's timeframes, decided late, still waiting in time and overdue now, with the median time to decide, and how the services asked for were answered: approved, modified, denied or still pended. These two tiles are read from the requests themselves, so they are not in the dashboard's Grafana export, which says so in its description.

16.10.2 Receiving the documents a pended request asks for

A pended response asks for documents with a Task and one CommunicationRequest per document. A questionnaire is asked for as PAS describes it: a CommunicationRequest with the single LOINC code 102089-0, under the trace number of the line it is for (itemTraceNumber, which the ClaimResponse echoes; a line sent without one is given one). That trace number is the DTR context: $questionnaire-package with only context set to it returns the questionnaire. The provider sends what was asked for with Da Vinci CDex $submit-attachment (POST /fhir/$submit-attachment): the TrackingId is the request's own trace number (the ClaimResponse's first identifier, which is also the Task's), or a CommunicationRequest's identifier, AttachTo is preauthorization, and MemberId must be the request's member. Each attachment's DocumentReference or QuestionnaireResponse is kept with the request and shown to its reviewer; a document arriving decides nothing.

16.10.3 Deciding in an X12 utilization management system

Most payers decide prior authorizations in a UM system that speaks X12 278. With -pas-278-url, Perfuse is the FHIR front of that system: each $submit is sent as one 278 request (005010X217), every item a patient event. Each carries what PAS names for it: the review type (extension-serviceItemRequestType, UM01: health services, admission or specialty care), the certification type (extension-certificationType, UM02), the service type, the place of service (UM04), the level of service (extension-levelOfServiceCode, UM06: elective, urgent or emergency; a stat priority stands in for urgent), the date, an admission review's admission and discharge dates, the diagnoses, the care team's provider who will do the service, and the procedure and quantity, as a professional (SV1) or, on an institutional claim, a hospital (SV2) service line. The 278 response's action code and authorization number become the item's review action: certified, in part or modified, denied, pended, cancelled, or not considered (pended for a reviewer, with the reject reasons). Local rules are not consulted.

perfuse serve -fhir -pas -pas-278-url https://um.example/x12/278 -pas-278-receiver ACMEUM \
  -pas-278-token-env UM_TOKEN

A UM system that pends first and decides later posts its later 278 response to POST /fhir/Claim/$decide-278; each event is matched to its request by the trace number Perfuse sent, and decided as $decide would decide it. If the UM system cannot be reached, or answers with something that is not a 278 response, every item is pended for a reviewer and the provider is told only that; the reason is in the server log.

Inquiries the UM system answers

A provider's $inquire must find an authorization however it was asked for: by fax, through the payer's portal, by phone, or in a 278 another submitter sent. Perfuse holds only the requests that came through it; the UM system holds them all. So with a UM system that answers in real time (every transport but sftp), each $inquire is also sent there as a 278 inquiry (005010X215: BHT02 28, the member, the provider, the authorization number from the Claim's insurance.preAuthRef when it has one, and the event dates from billablePeriod or the items' service dates, else a year either side of today). Every authorization the answer holds that Perfuse does not (matched by the trace numbers it sent and the authorization numbers it already gave) comes back as one more Claim Inquiry Response Bundle: the UM system's decision for each service line, the authorization number and period, and a note saying where it was found. No Claim is made up for an authorization nobody submitted in FHIR: its ClaimResponse.request carries a data-absent-reason, as PAS's profile allows. An inquiry only reads; nothing in Perfuse's own records changes. If the UM system is down or answers with a TA1 or 999, the provider gets what Perfuse holds, and the link's status says why. The shape follows the 278 inquiry companion guides UnitedHealthcare, Cigna and Blue Cross Blue Shield of Massachusetts publish; it has not been tried against a real UM system.

How the 278s reach the UM system

The UM system's connectivity guide says how it takes 278s, and -pas-278-transport (Settings, Payers, Utilization management link) follows it:

TransportWhat is sentAuthentication
x12 (default)The 278 POSTed as application/edi-x12; the answer is the response bodyA bearer token, OAuth, HTTP Basic, a client certificate
core-soapThe CAQH CORE real-time SOAP 1.2 envelope (COREEnvelopeRealTimeRequest, payload type X12_278_Request_005010X217E1_2, or X12_278_Request_005010X215 for an inquiry): Connectivity Rule vC2.2.0 with the payload in the envelope, which most payers and CMS esMD run, or -pas-278-core-version C4.0.0 with the payload as an MTOM attachmentvC2.2.0: a user name and password (WS-Security UsernameToken) or a client certificate. vC4.0.0: a client certificate and an OAuth token
core-mimeThe CAQH CORE vC2.2.0 HTTP MIME multipart envelope, the payload as a file partThe envelope's UserName and Password, or a client certificate
sftpEach 278 put in a folder on the UM system's SFTP server (-pas-278-sftp, -pas-278-sftp-outbound); its answers collected from another (-pas-278-sftp-inbound, every -pas-278-sftp-every)An SSH key or password; the server's key must be in -pas-278-sftp-known-hosts

A client certificate is -pas-278-cert and -pas-278-key; -pas-278-ca trusts a payer's own certificate authority. OAuth is -pas-278-oauth-token-url, -pas-278-oauth-client-id and the secret, fetched with the client-credentials grant and kept until a minute before it expires. Passwords, tokens and secrets are never flags: each is named by an environment variable (-pas-278-password-env, -pas-278-token-env, -pas-278-oauth-secret-env, -pas-278-sftp-password-env), or typed into the settings screen, where it is kept in the settings file (readable by the server's account only) and never shown again.

UM_PASSWORD=... perfuse serve -fhir -pas -pas-278-transport core-soap \
  -pas-278-url https://um.example/CORE/RealTime -pas-278-receiver ACMEUM \
  -pas-278-user perfuse -pas-278-password-env UM_PASSWORD

Over SFTP every request is pended until its answer arrives: the 278 response decides the items, and Perfuse puts a 999 accepting it in the outbound folder. A file it cannot read stays in the inbound folder for a person, and is offered again at the next read.

When the UM system cannot read a request

A UM system that cannot read a 278 answers with a TA1 (the envelope) or a 999 (the transaction) instead of a decision. Perfuse reads which and what it says, pends every item ("utilization management could not read the request"), and records it. Payer, CMS-0057, Reviewer queue shows the link: what was sent in the last seven days, each interchange by its control number, and what became of it (answered; acknowledged, answer to come; rejected, with the 999's segment, element and code; not reached). Each interchange has its own control number, one more than the last, which is what a TA1 or 999 names when it answers.

16.10.4 Deciding on the clinical evidence, with a decision service

A rules file decides by service code: a code needs no authorization, is not covered, or goes to a reviewer. Whether this patient meets the criteria for it is a different question, answered from the conditions, questionnaire answers and documents the provider sent with the request. With -pas-decider-url, every item the rules leave to a reviewer is first put to a decision service, such as Vaglio, which reads the same PAS request Bundle through Perfuse's published pas package. A public Vaglio, on invented requests only, runs beside Perfuse's test instance at https://vaglio.perfuse.health:8443 (sign in as demo, password try-vaglio-demo; it can look at everything and change nothing): every decision there has its proof, a plain-language letter and a receipt anyone can check.

perfuse serve -fhir -pas -crd-rules payer-rules.yaml -pas-decider-url https://vaglio.example/api/pas/decide \
  -pas-decider-token-env VAGLIO_TOKEN

The URL must be https, or plain http to this machine (localhost, 127.0.0.1), because it carries a patient's record and the token. Vaglio itself answers on https only; when it uses a certificate from its own authority, pass that authority with -pas-decider-ca DATA/tls/ca.crt (or any payer's internal authority the service's certificate comes from). The service is sent the request Bundle as the provider sent it, POST <url>?item=<sequence> (application/fhir+json), and answers {"decision": "approve" | "deny" | "pend", "why": "...", "requested": ["..."]}, or 404 when it decides nothing for that item, which leaves the rules' answer standing. why is the response's process note and is required for a denial, which is otherwise pended; requested is said to the provider as the information a pended request needs. A pend may also carry "questionnaire": "<canonical>" and "attachments": ["<LOINC>", ...], which are asked for in the response's CDex Task and CommunicationRequests beside whatever DTR questionnaire or attachments the rule asks for (the rule's questionnaire wins, since a Task asks for one; a code that is not LOINC's shape is dropped and logged). A rule that approves or denies is the payer's coverage policy and is not sent to the service. A failure, a timeout or an answer that is not one of the three decisions pends the item for a reviewer, with the reason in the server log: nothing here turns a failure into an approval. It cannot be combined with -pas-278-url, where the UM system answers every item.

Advice before submitting. With -crd-advisor-url beside -crd-rules, CRD also asks a decision service what a prior authorization request for each order needing one would be told as the record stands, and shows the answer as one more card: "Before you submit: a request for this would need: ...", or "would be approved", with what is still needed, the questionnaire that asks for it, and what would approve it. Vaglio answers at /api/pas/advise, with the same token and authority as above:

perfuse serve -fhir -pas -crd-rules payer-rules.yaml \
  -crd-advisor-url https://vaglio.example/api/pas/advise -crd-advisor-token-env VAGLIO_TOKEN

CRD sends POST <url> with {"service": {"system": "...", "code": "..."}, "resources": [ ... ]}, the resources being the hook's prefetch (the patient first, the coverage left out), and reads {"decides": true, "answer": "approve" | "pend" | "deny", "summary": "...", "missing": [...], "questionnaire": "...", "toApprove": [[...]], "policyTitle": "..."}. It is advice and changes nothing: the coverage-information, the other cards and the PAS decision are what the rules say. It waits at most three seconds, and no answer, a failure, or "decides": false leaves the response exactly as it is without it. Perfuse never needs a decision service.

The request carries X-PAS-Request-Id, the id the response is known by. A service with its own reviewers that pends an item can send their decision later as any reviewer's is sent: Claim/$decide with that id as claimResponse, the item's sequence, and the decision and reason. The provider's subscription delivers it as it would one made in the reviewer queue.

A subscription with id-only content tells the provider which answer changed without saying what it is: the notification's focus is Bundle/<id>. GET /fhir/Bundle/<id> returns the latest PAS Response Bundle, GET /fhir/Bundle/<id>/_history/<n> the version a notification sent, and GET /fhir/ClaimResponse/<id> the ClaimResponse alone. A token bound to one patient is told they do not exist, as for any record outside its patient.

Verified with the official Inferno PAS Server v2.2.1 suite (see verification). Not sent, though PAS marks them must-support: ClaimResponse.adjudication with a review action, ClaimResponse.extension:authorizedProvider and ClaimResponse.item.extension:communicatedDiagnosis, because the extensions they need are not allowed there by their own context in PAS 2.2.1 and the validator rejects a response carrying them; and request.extension:DataAbsentReason, because Perfuse always knows the request. The 278s sent to a utilization management system (above) are checked against PAS 2.2.1's own request examples, CMS's published esMD 278 samples and a 278 checker written from public companion guides, and every transport against a stand-in UM system written from CAQH's Connectivity Rules (see verification); not yet against a real UM system or clearinghouse, and not CORE certified. Not implemented: attachments sent back with CDex.

A rules file is checked against CRD's own invariants when it loads: a questionnaire needs documentation, a service that is not covered cannot need authorisation, satisfied belongs to one case and not a rule, and anything conditional must give a reason.

Not verified: the exports and member matching against another payer's implementation, the metrics against a payer's published figures, and CRD against a real EHR's CDS Hooks client.

16.11 Sending prior authorization requests to a payer (for providers)

Everything above answers requests, as a payer. A provider's side is the other way round: an EHR or a DTR app builds a Da Vinci PAS request Bundle, and something has to send it to the payer, keep the answer and pass it on. A pas destination does that.

name: prior-auth-out
dataType: raw
source:
  type: http
  http:
    listen: 127.0.0.1:8095
    path: /pas
destinations:
  - name: acme-health
    type: pas
    pas:
      payer: https://pas.acme-health.example/fhir
      bearer_token: ${ACME_PAS_TOKEN}     # or auth: { type: client_credentials, token_url, client_id, client_secret }
      results_to: pas-answers             # optional: a channel that takes each answer back to the EHR

Each message must be a whole PAS request Bundle on a raw channel. Perfuse posts it unchanged to {payer}/Claim/$submit: the payer decides on the Claim together with the patient, coverage and supporting resources in the Bundle, and a Claim rebuilt from a summary would lose them. Off this machine the payer's address must be https.

A decision is a delivered request, whatever it says. An approval, a denial and a pend are all recorded and none is retried, because retrying a denial submits the same request twice. What fails the delivery, and is retried, is a payer that did not decide: an error status, an OperationOutcome (its text is the error) or a ClaimResponse whose outcome is error. The decision is read item by item from the PAS review action codes (X12 306: A1 approved, A2 partly, A3 denied, A4 pended, A6 modified); a response with no code is listed as "no decision code" rather than taken as an approval. A denial with no reason anywhere is refused as not meeting CMS-0057.

The payer's answer is the destination's reply, so Inspect the reply can judge it, and results_to hands it to another channel. Every request is listed on CMS-0057, Requests sent to payers: when it went, the patient, the service, the decision and the payer's reference (preAuthRef), which a later inquiry names. The list is kept in prior-auth.json beside the database (at most 10,000 requests; the oldest decided ones go first, a pending one never does), and each tenant sees only its own.

Verified against the HL7 Da Vinci PAS payer reference implementation (docker image hlseven/davinci-prior-auth): a request sent through a running channel is pended by it with its reference, and the answer shows on the screen (see verification). Not yet tried against a production payer. Not built: following up a pended request with $inquire, or receiving the payer's later answer by subscription; a channel can do either today with an http or fhir destination.

16.12 CMS Aligned Networks

The CMS Interoperability Framework (CMS Health Technology Ecosystem) asks health data networks to self-attest to 26 criteria and be listed as CMS Aligned Networks; EHRs, providers, payers and apps connect to them. Criteria 13 to 16 have been in effect since 4 July 2026: FHIR APIs on US Core with a full CapabilityStatement and Bulk Data, documents as human-readable FHIR attachments, appointment and encounter notifications by FHIR Subscription, and a record locator.

Administer › CMS Aligned Network lists every criterion with what Perfuse provides for it and where this server stands, read from what is switched on here:

StatusMeaning
Ready herePerfuse provides it and it is on.
Ready, with a gapIt is on, and part of the criterion is outside it (named on the screen).
Built, not onPerfuse provides it; the screen names the setting or flag that turns it on.
Yours to doThe organisation's part: an agreement, a certification, publishing to the National Provider Directory.
Not builtSoftware would do it, and Perfuse does not.

What the four in effect need from this server:

CriterionIn PerfuseTurn on
13 FHIR APIs, US Core, Bulk DataThe FHIR endpoint with its CapabilityStatement, and $export-fhir, -fhir-bulk-export
14 Documents as FHIR attachmentsDocumentReference with Binary, and IHE MHD over the XDS registry-fhir (MHD also -xds)
15 Encounter and appointment notificationsTopic-based Subscriptions (R5 Backport for R4) from ADT and SIU feeds-fhir-subscriptions
16 Record locatorThe XDS.b registry, XCA for other communities, PIXm-xds, -xds-home-community

The screen is a worksheet for the attestation, not a certification: CMS reviews networks it suspects do not meet the criteria, and the framework's own text is the authority. Not built today: open quality gap queries (criterion 8), a payer's query for a claim's encounter data (9), reporting query metrics to the National Provider Directory (19), checking providers against that directory (6), reading mobile driver's licences or digital insurance cards (3, 23), and enforcing a general consent policy on every read (5). IAL2 identity proofing is the identity service's; Perfuse accepts the tokens of the one you choose.

17 Security

Perfuse handles clinical data. This chapter is what it does about that and what it leaves to you.

17.1 Transport security

TLS on the web interface is configured with -tls-cert and -tls-key. Without them the interface is plain HTTP, which is acceptable on a loopback address for a first look and nowhere else — it carries credentials and message content, both readable on the wire.

TLS on channel transports is per source and per destination, under a tls block. The available settings are in the source and destination references.

insecure_skip_verify accepts any certificate. It exists because hospital systems routinely present certificates that will not verify, and refusing to connect would mean refusing to integrate. It is named to be conspicuous, and using it means the connection is encrypted but not authenticated — you have protection against passive interception and none against an interposed server.

An unverified TLS connection is not the same as a verified one, and the distinction is worth recording somewhere your auditors will find it. The better fix is almost always to obtain the far end's certificate and trust it explicitly rather than trusting everything.

17.2 Authentication and roles

Users are held in the database. Each has one of five roles, and each role includes everything in the ones above it:

RoleCan
MonitorSee whether things are working: channel status, counts, the flow map, alerts, dashboards, metrics and the queue's depth. No message contents, channel files or queued messages.
ViewerAlso read messages, channel files, traces and the activity log.
OperatorAlso resend: reprocess a message, replay a channel and retry queued deliveries.
EditorAlso create and change channels, and run comparisons.
AdminAlso start and stop channels, clear queues, and manage people, tokens and the server's settings.

A sixth role, Platform, belongs to whoever runs a shared server: it creates tenants and sets their limits, and holds no channels of its own.

Monitor is for a help desk, or for an operations screen on a wall: everything needed to see that a feed has stopped, and nothing that names a patient. Viewer can read message content, which is clinical data; it is a low-privilege role for *configuration*, not for privacy. Operator separates fixing a failed delivery at night from changing what a feed does: the person who resends need not be able to rewrite the channel.

17.2.1 Repeated wrong passwords

Wrong passwords and failed passkey sign-ins are counted by the address the connection comes from, and after a few in a row that address waits before it may try again (the answer is 429, with Retry-After). Accounts are not locked by name, because that would let anybody lock out an administrator on purpose.

The address is the connection's own, never one a request claims in X-Forwarded-For, which the sender writes. Behind a reverse proxy on the same machine Perfuse uses the last X-Forwarded-For entry instead, the one the proxy added, so each client is still counted apart. A proxy on another machine reaches Perfuse from one address, and all its clients share one count: rate-limit sign-ins at that proxy as well.

17.2.2 Channel groups

A person (or an API token) can be limited to some channel groups: they then see and act on only the channels whose group is one of theirs. This is the arrangement of a hospital group on one server, each facility's analysts looking after their own feeds and a central team, with no limit, looking after all of them. Rhapsody calls it a locker.

The limit applies everywhere at once, because it is applied to the request rather than remembered by each screen: the channel list, the dashboard and flow map, messages and their search, counts and charts, the queue, alerts, metrics, certificates, contracts and code-set use. A channel outside the person's groups does not exist for them; asking for it by name answers *not found*, so its name is not confirmed either. A channel with no group is seen only by people with no limit.

Saving a channel keeps it inside the person's groups: they cannot create a channel in another group, or move one out of their own. What every facility shares - code tables, script libraries, prior-authorisation decisions - they can read but not change. Server-wide figures (prior authorisation, token use, tunnels) are shown only to people with no limit, and so are metrics and alerts about the whole server rather than a channel.

An administrator limited to groups is a facility's own administrator. They manage the people and tokens of their groups, start and stop their channels and clear their queues. A colleague they add gets their groups unless they name some of them, they cannot give a group they do not have, and they never see or change anybody with no limit. Everything else an administrator can do changes the whole server - settings, sign-on, restarting, alert rules, branding, fleet, the SMART server - and is refused to them.

Groups are set in Users (and for tokens in Tokens), as a comma-separated list. Each change is recorded in the activity log and takes effect at the person's next request.

A different role in each group

A person can hold a different role in each of their groups: lab=editor, adt in Users edits the lab's channels and has the role chosen beside it - say viewer - in the admissions ones. They see both groups' channels on every screen. Saving, starting or resending one in a group where their role is too low is refused with the role they hold there; it does not pretend the channel is missing, since they can see it. Their role at the top of the screen is the strongest they hold, which opens the screens that role uses, and every action on those screens is still narrowed to the groups where they hold it.

Only people limited to channel groups can have one. Somebody who sees every channel has one role, because "editor of the lab's channels" for a person with no limit would be editor of everything that belongs to no group, and of the server-wide pages. A facility's administrator can give a role per group only in the groups they administer, and never above their own role there.

17.3 Who may call the FHIR endpoint

The FHIR endpoint (serve -fhir) checks one of two kinds of token:

limited to FHIR Groups. This is the default when no SMART authorization server is configured.

carries scopes, and the endpoint holds every request to them.

With SMART configured, only SMART tokens are accepted, so every caller is held to its scopes. An API token has a role and no scopes: a viewer token can read every patient's record. A payer who turned SMART on to limit each app to what it was granted should not have a way in that is not limited.

Some sites need both all the same - an internal claims feed that holds an API token and has no SMART client, beside member apps that sign in through SMART. For those, Settings → FHIR → Accept Perfuse API tokens beside SMART tokens (-fhir-api-tokens at start-up) lets API tokens in as well. It is off by default, it takes effect on the next request in either direction, and changing it is written to the audit log. A token is told apart by its shape: a SMART token is a signed JWT, and an API token never is, so one is never checked as the other. Where you can, give such a system a SMART Backend Services client instead, with only the system scopes it needs.

17.3.1 A FHIR endpoint open to strangers

A public test server gives out credentials that can write, because testing prior authorization or subscriptions needs writes. Two limits keep one client from spoiling it for everybody, both under Settings → FHIR and both off by default:

write is answered 429 with Retry-After: 60. Reads and searches, including searches by POST, are never counted.

A hospital's own endpoint needs neither.

17.4 AI agents: the MCP server

With Settings › Security › AI agents (MCP) on (or serve -mcp), Perfuse serves the Model Context Protocol at /mcp, so an AI agent such as Claude, ChatGPT or an IDE assistant can ask it what is happening. It is off by default, and switching it off stops every agent on its next call.

An agent connects with an API token from Administer › Users › Machine credentials as a bearer token, never with a person's browser session (that is refused, so an agent is never acting with a person's full access). A client configuration looks like:

{ "mcpServers": { "perfuse": { "type": "http", "url": "https://perfuse.example/mcp",
    "headers": { "Authorization": "Bearer <token>" } } } }
ToolRoleWhat it does
server_status, list_channels, queue, alertsmonitorWhether things work, with no message contents
get_channel, search_messages, get_message, find_patient, fhir_searchviewerChannels, messages (with contents), a patient's messages across formats, a FHIR search
retry_delivery, reprocess_messageoperator, and mcp:writeRetry a queued delivery, send a message through its channel again
start_channel, stop_channeladmin, and mcp:writeStart or stop a channel

Every tool is answered by the same route or FHIR search a person's screen uses, called with the agent's own token, so the token's role, channel groups and tenant decide what it sees, exactly as they do in the console; and fhir_search gets the token's own access on the FHIR endpoint (which may refuse API tokens altogether; see the FHIR section above). An agent is offered only the tools its token may use. Reading is the default: a tool that changes something also needs the token's mcp:write scope ("Let an AI agent act with it" when issuing the token), so a token issued for a reporting agent cannot be talked into acting by a prompt. A monitor token's answers have message contents removed, whatever a route returns.

Every call is in Activity as mcp.tool with the token's name, the tool, its arguments and the outcome (ok, refused, invalid, rate-limited or the HTTP status); mcp.connect records which client connected. Each token may make 60 tool calls a minute (Tool calls a minute from one token); over that, a call is answered with an error the agent can read, and nothing is done. A request from a browser page on another origin is refused, which MCP asks of a server to stop DNS rebinding. The transport is MCP's Streamable HTTP, answered as JSON with no event stream; protocol versions 2025-06-18, 2025-03-26 and 2024-11-05 are accepted. Messages and FHIR resources hold patient data: an agent's answers go to whatever model it runs on, so give a token to an agent only where that model is allowed to see them, and give it the lowest role that does the job.

17.5 A demonstration account

serve -demo-account demo:PASSWORD creates a shared account for a public demonstration server, and the sign-in page shows its username and password with a button that signs in with them. The account is a viewer, so it can look at everything and change nothing. Unlike any other viewer it may also open the administrators' screens (users, machine tokens, settings, data protection, the fleet's peers), read-only: a demonstration is judged by its screens. It reads them as a viewer reads channels, with every credential shown as set or not, never its value, and every change refused. It may also press the checks that change nothing and reach nothing outside the server: a script's Check, and the channel editor's validate and parse. It cannot change its password or add a passkey, because the first visitor to do either would lock out everyone after them. Each start puts it back as a viewer with the published password. The password must be at least 12 characters, like any other. Only use it on a server holding test data: anyone who reads the sign-in page can read every message on the server.

17.6 Audit

Configuration changes, logins, and access to message content are recorded with who, what and when.

The audit log is in the same database as the messages. That is convenient and it is a limitation: somebody who can modify the database can modify the audit log. If your requirements include tamper-evident audit, the log needs shipping somewhere append-only, and Perfuse's own log output is the way to do that.

17.7 Secrets in configuration

Passwords, passcodes and keys appear in channel configuration, which means they are in the YAML files.

Two consequences. The files need filesystem permissions that reflect what they contain, and if they are in version control — which is otherwise recommended — the repository holds credentials and must be treated accordingly.

Where a value is a secret, the interface renders it as a secret and the API does not return it once saved. That protects it from being read over somebody's shoulder or leaked in a screenshot. It does not protect the file.

17.8 What is in the database

The message store holds messages as received, which for a clinical feed means patient-identifiable data. It should be on encrypted storage, backed up as clinical data with the retention that implies, and access-controlled at the filesystem level as well as through Perfuse's own roles.

cleanup.periodDays limits how long it is kept. See the message store for the tension between that and having enough history for rhythm-aware alerting.

17.9 Data protection (GDPR and UK GDPR)

The Data protection screen (Administer, administrators only) answers a person's requests under the GDPR and the UK GDPR. It decides nothing on the controller's behalf: whether a request must be granted, and whether an exemption applies, is the controller's decision, recorded with the request.

Finding what is held. A person is named by identifiers: system|value (an NHS number, an MRN with its assigning authority) or a number on its own. With a patient index, those widen to every number of every record linked to the same person, so a request quoting one hospital number finds the records filed under the others. See what is held then searches every store that keeps patient data:

StoreFound by
Messagesthe identity index (Settings → Data → Index patient identifiers); without it, messages cannot be found by identifier
The delivery queuereading each queued message's identifiers
The FHIR storethe Patient resources carrying an identifier, and every resource whose indexed references point at one
Prior authorization requeststhe identifiers of the request's patient
The patient indexthe identifier, or the EUID

What it cannot reach it says: traces sent to a collector (-trace-endpoint), the audit log (kept: it is the record of who did what, this request included), and backups.

Requests. A request is recorded with its kind (access, Article 15; portability, Article 20; erasure, Article 17; restriction, Article 18), who it is from as the controller's records refer to them, the identifiers, and a note (how the identity was confirmed, an extension and its reason). It is due one month after receipt (Article 12(3)); an overdue one is shown in red.

received with its record, every FHIR resource with every stored version, prior authorization requests and their answers, and the patient index's records, JSON throughout, with a README saying what each part is. Mark answered closes it.

Erase now deletes everything found: messages whole (record, body, deliveries, identifiers, attachments no other message uses), queued deliveries (they are not sent), the FHIR resources with their history, prior authorization requests with their answers, attachments and tracking numbers, and the person in the patient index with every linked record and its history (one event records that an erasure happened, and by whom).

it lasts.

A closed request keeps only the last characters of the person's numbers: it stays as the record of an answer without being another copy of them. Every step is in the audit log with the numbers masked.

Legal holds. A hold names a person by identifiers, with its reason and an optional end date. While it is in force an erasure is refused outright, and the message store's retention window spares the person's messages (records and bodies) until the hold is lifted.

Backups and the erasure ledger. A backup made before an erasure still holds what was erased until it expires. Every erasure is written to a ledger: when, by whom, approved by whom, how much went from each store, and the person's identifiers as salted SHA-256 hashes rather than in clear. After restoring a backup, Apply erasures again hashes the identifiers the restored stores hold with each entry's salt and erases every person the ledger names. Short numbers can be found from their hashes by trying every value, so the ledger is itself personal data and is kept with the database's own protection.

Retention. The screen lists how long each kind of record is kept here and what changes it: message records and bodies by their retention windows, the queue until delivered or removed, the FHIR store, prior authorization requests and the patient index until erased (clinical records are not deleted on a schedule), the audit log kept.

Record of processing (Article 30). Record of processing downloads, as Markdown or CSV, what this server knows of it: the controller, the data protection officer and where the data is kept (Settings → Data → Data protection), the retention of each kind of record, and one processing activity per channel with what it receives, from where, to which destinations, and the hosts it sends to. The purposes, lawful bases and categories of data subject are left for the controller to fill in, and say so. A CSV cell beginning with =, +, - or @ is written with a leading apostrophe, so a spreadsheet does not run it.

Where the data is kept. Where the data is kept (Settings → Data → Data protection) states, in words a regulator can check, where the server, its database and its backups run; the Data protection screen and the record show it. A draft data processing agreement for organisations BioDream hosts is in docs/legal/dpa-template.md; it is a draft for a lawyer, not legal advice.

The API is /api/privacy (summary), /api/privacy/find, /api/privacy/requests (and /{id}/approve, /{id}/export, /{id}/erase, /{id}/close), /api/privacy/holds, /api/privacy/reapply and /api/privacy/register.

17.10 Tenants

A tenant is an isolation boundary: channels, messages, users and settings belong to one, and nothing crosses.

This exists for a service provider running feeds for several practices. Do not use it to separate departments within one organisation that need to see each other's traffic: the isolation is real and there is no cross-tenant view. For that, use channel groups (above), which divide the people of one organisation while letting a central team see everything.

17.10.1 Limits

A platform operator sets each tenant's limits in Tenants, so one customer cannot crowd out the rest of a shared server. Leave a limit empty for none.

None of them loses a message. Over the rate or the storage limit, a received message is refused the way a busy receiver refuses one, before anything is stored or delivered: MLLP gets no acknowledgement (not a rejection, which some senders take as "do not resend"), HTTP gets 429 with Retry-After, SOAP gets 503, DICOM gets a retryable status, and a queue, bucket or file is left where it was. The sender resends, as every interface engine and EHR does. Nothing already stored is deleted to make room. A raised limit takes effect within a minute, and every change of limits is recorded in the activity log.

17.11 Test data

The generated messages described in testing exist partly as a privacy control. Reproducing a problem elsewhere, sending an example to a vendor, or populating a test environment are all things that otherwise get done with real messages.

The generator's output contains no patient data by construction rather than by redaction. Prefer it to hand-stripped real messages, which is a process that can be done incompletely and usually is.

17.12 Single sign-on

Three mechanisms, and local accounts alongside all of them.

OpenID Connect (-oidc), a directory over LDAP (-ldap), and SAML 2.0 (-saml). Each is configured from Administer → Sign-on and written to the file the flag names, so the screen and the file are two views of one thing rather than two places to keep in step.

Local accounts are always available and cannot be turned off. That is deliberate: an on-premises integration engine whose only way in is an identity provider becomes unreachable exactly when the identity provider is unreachable, and a clinical interface is not a good place to learn that. The sign-on screen tells you how many local administrators exist before it encourages you to depend on anything else.

17.12.1 What a group mapping decides

All three map groups from the directory to Perfuse roles, and somebody in groups matching more than one gets the most privileged. That is the only safe direction to resolve it — the alternative is a person losing access they are entitled to because they are also in a lesser group.

Any of the six roles can be granted: monitor, viewer, operator, editor, admin and platform.

A group can also be limited to some channel groups, in the second box of its row (channel_groups in the file). Once any group names one, every sign-in sets the person's channel groups from the groups that granted their role: somebody in a group that edits the lab's channels and in another that views every channel is an editor of the lab's channels and nothing else. The strongest role is never joined to the widest reach, because neither group grants that. A group with an empty box gives every channel. While no group names any, sign-in leaves channel groups as an administrator set them in Users.

When every group a person matches names channel groups, each channel group gets the strongest role among the groups that name it: with Lab-Interfaces (editor, lab) and Clinical-IT (viewer, lab and adt), the person edits the lab's channels and views the admissions ones, as if set by hand. When one of their groups gives every channel, there is no list to hold a role per group, and the rule above applies.

roles:
  editor: [Lab-Interfaces]
  operator: [Night-Desk]
  viewer: [Clinical-IT]
  channel_groups:
    Lab-Interfaces: [lab, pathology]
    Night-Desk: [adt]

That is the OpenID Connect and SAML file. The directory's file maps each group to one role (roles: {Lab-Interfaces: editor}), and channel_groups sits beside roles there.

A mapping that grants nobody anything is refused rather than saved. A configuration that authenticates people and turns all of them away looks identical to a broken product from the outside: the round trip completes and everybody is denied with nothing explaining why.

Creating an account on first sign-in is off by default. With it off, somebody has to exist in Perfuse before they can sign in, so the directory decides who they are and Perfuse decides who is allowed in.

17.12.2 SAML, specifically

The parts worth knowing before you configure it:

17.12.3 What each provider sends

The group attribute is where sign-ins go wrong, and the three common providers each name it differently.

ProviderUsually sendsNotes
KeycloakgroupsNeeds a group membership mapper added to the client.
Entra IDa claim URI ending /claims/groupsSends group object ids unless the application is configured to send names. Map the ids, or change the provider.
Oktawhatever the application was configured withCommonly groups, with a filter deciding which are sent.
ADFSa claim URISigns the Response rather than the Assertion, which Perfuse accepts.

A claim URI may be entered by its final segment: typing groups finds http://schemas.microsoft.com/ws/2008/06/identity/claims/groups. A name that merely ends with the word does not match — excluded_groups is not groups.

Group membership arriving as one comma-separated value is treated as a single group name and matches nothing. That is the correct reading of the standard, and splitting on commas would mean a group legitimately containing one silently became two. If that is what your provider sends, change it there.

Test on the sign-on screen checks everything that can be checked without a person: the settings, the certificate, and whether a sign-in request can be built. It says plainly that it cannot prove somebody can sign in, because that needs a real assertion about a real person and the only way to get one is for them to try.

17.13 Accounts provisioned by the identity provider: SCIM

With -scim (Settings → Sign-in → Automatic accounts), an identity provider such as Okta, Microsoft Entra ID or Google Workspace creates, updates and removes Perfuse accounts itself at /scim/v2/Users, so a person who leaves the organisation loses access when their directory account is disabled, not when somebody remembers. The provider authenticates with an API token holding the admin role, made under Users → Machine credentials, sent as a bearer token.

never told an account was disabled when it was not, because a provider does not retry what it was told succeeded.

a removed account is kept.

*viewer* (or *read-only*) or *monitor* gives that role, so APP-Perfuse-Editor and Perfuse Operators work as they are named. Anything else gets -scim-default-role, viewer unless set otherwise. Platform can never be granted this way.

would survive a disable there.

results. Bulk operations, sorting and ETags are not.

17.14 What Perfuse does not do

It does not encrypt the database at rest. Use filesystem or volume encryption.

It does not manage its own certificates or renew them. Point it at files and manage those files with whatever you already use.

SAML has been verified against two independent providers, each with a real browser sign-in, a real assertion and a real session: Keycloak 26 and Microsoft Entra. Two matters more than twice as much as one, because the two disagree in a way that turned out to be load-bearing — Keycloak writes its assertion with namespace prefixes and Entra writes the same elements without them, and the one defect this area has had was in prefix handling.

Start by reading your provider's metadata. Settings, then Sign-on, then the SAML tab: give the metadata URL or paste the document, and the sign-on address and signing certificate are filled in for you. Every SAML provider publishes one of these — Okta, Entra, AWS IAM Identity Center, Keycloak, ADFS — and it is the difference between configuring a provider and transcribing one. Doing it by hand means finding the certificate inside the XML, stripping the line breaks out of the base64 and wrapping it in PEM headers, where a single stray space produces a signature error that says nothing about formatting.

What comes back is described rather than dumped: each certificate's subject, expiry and SHA-256 fingerprint, so you can check it against what your provider's own console shows. An expired certificate is called expired rather than left as a date to compare by eye. Nothing is saved by reading — applying it to the form is a separate click, because a document somebody pasted is not yet a decision about what to trust.

A metadata URL is fetched over https only, and the address is checked against the same policy that governs channel destinations, so this cannot be used to make the server read cloud instance credentials. Redirects are not followed, because a permitted address redirecting to a blocked one would walk straight past that check.

If you are configuring Entra, five things will not match what the examples show you. It sends no email claim at all. Its NameID is an opaque identifier rather than an address. It sends no groups claim unless the application is explicitly configured to emit one, so a role mapping written against groups refuses every sign-in. When groups are emitted they are object GUIDs rather than names, unless your tenant synchronises from on-premises Active Directory. And its claim names live under schemas.microsoft.com/identity where most documentation shows schemas.xmlsoap.org. Perfuse handles all five; they are listed because the first four will otherwise look like faults in Perfuse when they are Entra being Entra.

That distinction is not theoretical here. The first genuine Keycloak assertion failed outright, because the canonical form the signature covers was being computed with namespace prefixes dropped. No real identity provider could have signed anybody in, and the package's own five canonicalisation tests passed throughout — each of them signed and verified with the same code, so they proved only that it agreed with itself. If you are the first to point a different provider at this, expect to find something, and the server log will name the stage that failed.

18 Operations

18.1 Deployment

One binary and two paths: a directory of channel files and a database file. There is no application server, no separate web tier and no message broker required.

perfuse serve -channels ./channels -db ./perfuse.db -addr 0.0.0.0:8443 \
  -tls-cert ./cert.pem -tls-key ./key.pem

Run it under whatever supervises services on the host — systemd, launchd, a container runtime. It expects to be restarted if it exits.

18.2 Where data may go (data residency)

serve -residency (Settings → Data → Where data may go) names the cloud regions this server's data may be sent to or read from: areas (eea, uk, ch) and region codes (eu-central-1, uksouth, europe-west2), comma-separated. eea is the AWS, Azure and Google Cloud regions in the European Economic Area (Frankfurt, Ireland, Paris, Stockholm, Milan, Spain; Netherlands, Germany, France, Sweden, Norway, Italy, Poland, Spain; Belgium, Netherlands, Germany, Italy, France, Finland, Spain, Poland), uk those in the United Kingdom, ch those in Switzerland.

With it set, a channel is refused, when it is saved and when it is loaded, if a connector names a region outside the list: an S3, SQS or SNS source or destination's region (or its endpoint, when that is an AWS hostname), or any destination whose address is an AWS hostname carrying a region (sqs.us-east-1.amazonaws.com, an API Gateway URL). The server refuses to start with a trace collector or alert webhook in another region. A connector pointed at a host that names no region (a hospital's own system, MinIO on the site's network) is not judged: the record of processing on the Data protection screen lists every host each channel sends to, for the controller to check. The dashboard and the Data protection screen say where data may go.

Hosting in the EU and the UK. deploy/hosted holds OpenTofu (or Terraform) for one hosted server per organisation in Frankfurt, Ireland or London: the server, its PostgreSQL database and backups, a backup bucket, logs and support-session records, all encrypted with a key in the Region, nothing replicated to another Region, support through Session Manager only, and Perfuse run with -residency. Its README says what it builds, what it does not cover, what has been verified (validated, and planned against a real account; not yet applied) and what it costs at list price in each Region.

18.3 Configuration as files

Channels are files, which means they can be in version control, reviewed before deployment, and diffed after an incident.

Files are re-read without a restart. A file that does not parse is reported and the previously loaded version keeps running, so a syntax error cannot take a working feed off the air.

A channel edited in the web interface is written back to its file. If those files are in version control, expect commits from configuration changes made through the interface, and do not hand-edit a file while somebody has it open in the builder.

18.4 Backup

Two things to back up, and they have different characteristics.

The channel directory is small, changes rarely, and is what you need to rebuild the service. Back it up as configuration; version control is usually sufficient.

The database holds messages, users and audit history. It is large, changes constantly, and contains clinical data — so it needs backing up as clinical data, with the retention and encryption that implies. Losing it does not stop the service, but it loses the ability to investigate anything that already happened and the history the rhythm learner depends on.

18.5 Restart behaviour

On restart, channels are loaded and sources begin listening. Queued deliveries resume.

The thing to understand is what happens to work in flight. Under the default on_delivery acknowledgement, a message that had not yet been acknowledged is not acknowledged, so the sender will resend it — which is the correct outcome. Under on_receipt, a message that was acknowledged but not yet delivered is in the queue, and resumes; if the queue is disabled, it is lost. See message flow.

18.6 Upgrading

Replace the binary and restart. The database schema is migrated forward automatically on start.

Migrations are forward-only. There is no downgrade, so a rollback to a previous binary after the schema has moved needs the previous database. Take a copy before upgrading — this is the one operational precaution worth being disciplined about.

18.7 Monitoring

Alerting from within Perfuse is covered in alerting and is what will tell you a feed has stopped.

From outside, monitor the process, the disk the database is on, and whether the web interface responds. The database grows and a full disk stops message recording, which is a failure mode with no other warning.

18.7.1 SNMP

For monitoring tools that speak SNMP (Nagios, Zabbix, PRTG, SolarWinds, LibreNMS), Perfuse runs a read-only SNMPv2c agent and sends a trap when an alert fires or resolves. Set it under Settings → Monitoring → SNMP, or with flags:

perfuse serve -snmp-listen :161 -snmp-community 'long-random-community' -snmp-allow 10.0.5.0/24 \
  -snmp-traps nms.example.org,10.0.5.9:1162

clear, so it is never open to everyone. A community under 6 characters, or public/private, stops the server at startup.

BASE.1 (channels, running, broken, received, delivered, failed, queued) and a channel table under BASE.2.1 (name, running or stopped, received, delivered, failed, queued, age of the oldest queued message). The numbers are the ones the dashboard and /metrics show.

severity, channel, destination and summary. Never message content. An acknowledged alert sends no trap.

enterprise number. Download the MIB on the Settings page (or GET /api/snmp/mib) gives a MIB for the base in use; the same panel says whether the agent is answering and how many traps went.

18.7.2 An inventory of every interface

-inventory interfaces.md (Settings → Monitoring → Inventory) writes a Markdown document listing every channel - its format, whether it runs, what it receives from and what it sends to - followed by each channel's own description, and rewrites it every -inventory-every (a day by default). It is meant to be committed beside the channel files, or handed to an auditor, and read without the application running.

18.8 Capacity

Perfuse's throughput ceiling has not been measured on real hardware under real traffic, and this manual will not print a number it cannot support.

What can be said: the architecture is one goroutine per connection with per-destination queues, so throughput scales with destinations rather than being serialised through one worker; message recording is a SQLite write per message, which makes the disk the most likely first constraint; and the message store grows linearly with traffic, so retention is the setting that governs long-term disk use.

If you are sizing this for a large feed, measure it with your own traffic using the generated test corpus described in testing. That is the honest answer, and it is also a better answer than a number from somebody else's hardware.

18.9 Where this installation got stuck

Activity carries a panel, visible to administrators, showing what Perfuse has refused to do — grouped by message, most frequent first — and how far the installation has got towards a working channel.

It exists because every other claim about whether this software is easy to use comes from the people who wrote it. A refusal does not: somebody wanted something, the server said no, and neither party was guessing. It is the only evidence in the product that does not come from BioDream LLC.

The funnel names the step that has not happened yet rather than leaving timestamps to compare. The state worth watching for is messages arrive and none has been delivered to a destination — a channel can take traffic for weeks and deliver none of it, and from every other screen that looks like it is working.

Nothing anybody typed is recorded. The table holds the request route with identifiers replaced by {id}, the status, and the server's own sentence. A validation message names a field and a rule, which is the useful part; the value that broke the rule is very often patient data, and a table of those would be a worse liability than the friction it measured.

Two deliberate exclusions. A 5xx is a fault in this software and is logged as one already — mixing the two would bury the cases where somebody could not work out what to type under the cases where nothing they typed would have helped. And an expired session produces a run of 401s that say nothing about usability and would drown every finding that does.

Reading the report also trims the table to its most recent five thousand rows. Refusals are 4xx responses so it grows slowly, and reading is a natural moment to tidy: no timer and nothing that runs when nobody is looking.

GET /api/friction returns the same thing for anyone who would rather read it as JSON.

18.10 Logs

Perfuse logs to standard output, which is where a supervisor or container runtime will collect it.

Message-level detail goes to the message store rather than the log, deliberately. A log line per message on a busy feed is noise that hides the lines that matter, and the store is searchable in ways a log file is not.

The log carries what the store cannot: startup and shutdown, configuration load results, channel state changes, and errors that are not attributable to a specific message.

18.11 Running two instances

Two or more servers can run as one cluster: when the one running a feed stops, another takes it over within a few seconds, and the sender's next attempt reaches it. They share one PostgreSQL database instead of each keeping a SQLite file, and agree between them, through it, which of them runs what.

perfuse serve -db 'postgres://perfuse:SECRET@db.hospital.internal/perfuse?sslmode=verify-full' \
  -cluster active-passive -node-name perfuse-01 -node-address https://perfuse-01.hospital.internal:8443 \
  -data /var/lib/perfuse

Start each server the same way with its own name and address. The database account needs to create tables and, once, the citext extension (or an administrator runs CREATE EXTENSION citext first). Each server keeps its own files in -data: its log, its node id, and its copies of the configuration.

Two ways of sharing the work.

others wait. The simplest to reason about, and what a site with one busy server and a standby wants.

to the rest; one that joins takes its share as channels come free, one at a time. Channels that route to one another (a channel destination) run on one server and move together, because routing happens inside a server. An administrator can move a channel by hand from Monitor → Fleet; it stays where it was put while that server lives.

Who runs what, and no moment with two. Each piece of work is a lease in the database, renewed every fifth of -cluster-lease (15 s by default). A server that cannot renew it (it crashed, its network failed, the database is unreachable) stops that work by itself once its own clock says the lease is nearly done, with a third of the lease to spare. Another server takes the lease only after its own clock has watched the lease's counter stand still for a whole lease. No clock is compared with another, so the servers' clocks and the database's need not agree; they need only run at about the same rate, which every clock does. Between the old holder stopping and the new one starting there is a short gap with nobody listening, which a sender sees as a refused connection and retries; there is never a moment with two listening on one feed or two workers draining one queue.

A server shutting down cleanly, or an administrator choosing Step down on the leader, hands over at once rather than after the lease.

What every server shares. Messages, the delivery queue, accounts and sessions, the audit log, the FHIR store, the terminology tables and the patient index are in the database, so whichever server runs a channel finds the queue the last one left. The channel files, the settings file, the alert rules, single sign-on files, the logo, Direct certificates and saved views are kept the same on every server through the database: each keeps its own copy on its own disk, a change on any server (in the console, by hand, by a git pull) reaches the others within two seconds, and a channel changed on another server restarts with its new file. The same file changed on two servers at once keeps the version that reached the database first, and the other beside it as NAME.conflict-SERVER. Settings that apply only at a restart (the message database, the ports the console listens on) are applied by each server at its next restart, and the log says so. What names something on one machine stays on that server's command line and out of the shared file: the certificate and key (-tls-cert, -tls-key), the SNMP listening address and the server's name in traces. Settings shows them as set on each server.

Start and stop. Starting or stopping a channel on any server applies to the cluster: the server that runs it starts or stops it within a second, and the answer says which server that is.

The load balancer in front. A feed's address must reach whichever server runs it. Point the sender at a virtual address (a load balancer, a floating IP, a DNS name with a short TTL) and have the load balancer check /readyz?channel=NAME, which answers 200 only on the server running that channel, and 503 on the others. For the console, /readyz is 200 on every live server; /readyz?role=leader only on the leader.

On an AWS Network Load Balancer, use a target group per feed with an HTTPS health check on /readyz?channel=NAME, a 5-second interval and a threshold of 2, and turn cross-zone load balancing on. With it off, the load balancer's node in a zone where no server runs the feed sees only unhealthy targets, sends to all of them anyway, and a sender that resolved to that node waits until the feed comes back to its zone (95 seconds in a test). Set the deregistration delay to 0, since a server that stops running a feed has nothing to drain.

What was tested. Two servers on PostgreSQL 16, a sender streaming 300 messages, resending until answered, and the server running the feed killed with SIGKILL twice (the first, then the second after the first came back). Every acknowledged message reached the receiver, in both shapes tried: delivered before the acknowledgement, and queued in the shared store while the receiver refused (185 acknowledged messages waited in the queue at the kill; the survivor delivered them all). Failover took 3 to 4 seconds on a 3-second lease. A duplicate is possible and was seen once in 600: a message the dying server delivered and could not acknowledge, which the sender resent. The coordinator's own tests cover a crash, a server cut off from the database while it runs on, step-down, spreading, moves, and shared configuration with conflicts. They run in CI against PostgreSQL with the race detector, with the whole suite.

The same was then done on AWS: two Linux servers (ARM) and one Windows Server 2022 in three availability zones, Amazon RDS PostgreSQL 16 over TLS with the certificate checked (sslmode=verify-full), an internal Network Load Balancer in front, and senders resending until answered:

TestMessagesAcknowledged but lostDuplicatesLongest a sender waited
Active-passive, the server running the feed killed twice (Linux, then Windows), receiver refusing in between2,0000015 s
A Linux leader cut off from the database (firewall), its process left running3,0000116 s
A Windows leader cut off from the database3,0000020 s
The leader killed while one server's clock ran 2 minutes fast and another's 90 seconds slow2,0000018 s
Active-active, four feeds over three servers, one killed and brought back6,0000224 s

With the default 15-second lease, a server cut off from the database stopped its feed 10 seconds after its last renewal, and the next server started it 6 to 8 seconds after that: never both at once. The skewed clocks changed nothing, since no clock is compared with another.

Not yet. A cluster serves one organisation: -cluster with -multi-tenant is refused. Listeners move with their channel, so the load balancer is the site's to provide.

A server on its own keeps working as it always has, on SQLite, with no cluster at all.

18.12 More than one server

A site with two instances has a question no single console can answer: is everything running? Mirth charges for the answer. This is the fleet view.

One instance is nominated as the place you look. It polls the others and shows every channel on every server in one table, with a rollup across all of them.

18.12.1 Adding a peer

Settings → Fleet, or PUT /api/fleet/peers. A peer needs a name, a URL, and a token issued by the peer itself.

The name is required and is not the URL, because a URL is not something anybody recognises at three in the morning.

The token must be created on the peer, under Users, and should be viewer-scoped. Adding a peer without one is refused, with those instructions, rather than accepted and then failing quietly on every poll. Reading another instance's health is the smallest privilege there is, and it is the only one the fleet view needs.

18.12.2 Controlling a peer, which is off

allow_control permits starting and stopping that peer's channels from here. It is off by default and every use is audited by name.

The two privileges are deliberately separate. Reading another server's health is minor; stopping its channels during a transfusion is not, and the second should never arrive silently attached to the first.

18.12.3 What the report says

Each server reports the total number of channels, how many are running, stopped or errored, the queue depth and the age of the oldest waiting message, how many alerts are firing, and whether it is draining for shutdown.

The rollup counts servers three ways: reachable, unreachable and undetermined. The third covers peers whose health nobody knows — not yet polled, refusing the token, or running a version this one cannot read. It is kept apart from unreachable because "we cannot tell" is not "it is down", and folding the two together is how a fleet page starts lying.

For the same reason the rollup carries knownFrom: how many instances the channel and queue figures were actually read from. Twelve channels running across a fleet means something different when two of five servers did not answer, and the total alone cannot say so.

An unreachable peer says why in terms an operator can act on. A refused connection is reported as the host being up with nothing listening on that port, which is a different problem from a host that does not answer at all.

18.12.4 Clock skew

The rollup reports the largest difference between the clocks of the servers in it. Worth a number of its own: correlating an incident across two instances whose clocks disagree by four minutes produces a sequence of events that did not happen.

18.12.5 What a peer learns about you

Counts and rates. The report one instance gives another carries no message identifiers, no channel-level detail and no patient data of any kind.

That is what stops a fleet view being a centralisation of clinical data: the aggregating instance learns how many channels are running and nothing whatsoever about what flowed through them. If you want the messages you open that server's own console, where the audit log records that you did.

18.12.6 A peer pointed at itself

Refused. An instance polling its own address through its own HTTP stack appears twice in its own fleet view and double-counts every channel it has.

18.12.7 TLS on internal networks

insecure_skip_verify accepts a peer's certificate without verifying it. It exists because hospital infrastructure runs on private certificate authorities, and refusing to work at all would push people onto plain HTTP, which is worse. It is named so it cannot be mistaken for a good idea.

19 The Command Line

Everything Perfuse does to a running system is reachable from the web interface, and that is deliberate: a feature that needs a terminal is treated as a defect rather than a design choice. The commands here are for the work that happens *around* a running system — before it exists, while you are deciding whether to trust it, and when you need to hand something to somebody else.

Several of them never touch a server at all. perfuse explain, perfuse profile, perfuse deident and perfuse translate read files and write files, so they are useful on a laptop against an estate that still runs entirely on something else.

Run any command with -h for its flags. This chapter says what each one is *for*.

The flags shown here are illustrative, not exhaustive. -h is generated from the code and cannot fall out of date; a list copied into prose can.

19.1 Running channels

19.1.1 perfuse serve

Runs the web interface and the API, and by default runs the channels too. This is the command an installation uses.

Every option it takes is listed in Server Options, generated from the program. It refuses to serve plain HTTP to anything except loopback unless you pass -insecure. That is not configurable politeness: clinical traffic and a session cookie over an unencrypted LAN connection is a reportable event, and the flag exists so that saying yes is a decision somebody made rather than a default they inherited. See security.

19.1.2 perfuse run

Runs channels from YAML with no web interface and no database. Useful in a container that should do exactly one thing, and useful when you want to see a channel's behaviour without anything else running.

19.1.3 perfuse check

Validates channel definitions and prints what they do, without running them. The second half is the point: a channel that loads is not necessarily the channel you meant, and reading a plain-English summary of what a file will do catches a mistake that validation cannot see.

Exits non-zero on a fault, so it belongs in whatever checks your configuration before it reaches a server. -quiet prints nothing on success, for that use.

-allow-metadata-egress permits destinations pointed at cloud instance metadata addresses. Those addresses hold the machine's own credentials, so a channel that can be talked into sending there is a credential leak; the check refuses them unless you say otherwise.

19.1.4 perfuse test

Runs tests written against channel definitions. A test sends a message through the channel's real filter, real transformations and real scripts, in the real order, with only the network replaced — so what passes here is what the channel does.

Tests live in *_test.yaml or *.test.yaml beside the channels. See testing.

19.2 Migrating from another engine

These three are the ones to run first, before deciding anything. See migration.

19.2.1 perfuse explain

Reads Mirth, Open Integration Engine and BridgeLink exports — a channel, a channel group or a whole server backup — and describes what each channel does and what would block a migration. It changes nothing and needs no Perfuse installation, which makes it the cheapest possible first step: point it at an export of your estate and read what comes back.

-strict exits non-zero if anything is blocked, so it can gate a pipeline. -json emits the same findings for a machine.

19.2.2 perfuse translate

Converts Mirth, Open Integration Engine and BridgeLink exports into Perfuse channel files: one channel, a channel group, a code template export or a whole server backup.

perfuse translate -o ./channels server-backup.xml

Code template libraries are written to lib/ beside the channels, and each channel Mirth had a library enabled for includes it. See migration.

Nothing is silently dropped. Every part of a channel is either translated, carried across as a script that runs unchanged, or reported as needing a human. The third category is the honest one — a translator that produced a clean-looking file for every input would be hiding the decisions you most need to make.

19.2.3 perfuse compare

Runs the same traffic through two engines and reports where they disagree, grouped by field.

This is the command that answers "why should I trust this with patient data", and it answers it with evidence you gathered rather than a claim made here. It is the recommended last step before cutting over, and the recommended first step in any argument about whether Perfuse is ready.

19.3 Understanding a feed

19.3.1 perfuse profile

Reads messages and reports what is actually in them: which fields are always populated, which never are, the real value sets of coded fields, repetition counts, and which Z-segments appear.

Values are only reported for fields whose values form a small code set, so a profile of real traffic does not become a file full of patient data.

Saving a profile and comparing against it later is how you find out that a sender changed something:

perfuse profile -save last-month.json corpus/ perfuse profile -against last-month.json new/

19.3.2 perfuse contract

Turns the output of that thinking into something enforceable. A contract says what a feed must look like, so that the day it changes you are told, rather than finding out weeks later from a receiver that fell over — the messages are still valid HL7 when a field disappears, so nothing else notices.

perfuse contract promote -o adt.contract.yaml corpus/ perfuse contract check adt.contract.yaml today/

promote generates a starting point, not an answer. Read it and delete most of it: every line records whether it was measured or decided, which is what tells you which lines to keep.

19.4 Producing messages

19.4.1 perfuse generate

Produces synthetic HL7 v2 messages. Everything is invented — obviously fictional names, sequential identifiers, made-up addresses — so the output is safe to commit, mail, and attach to a ticket.

It can send straight at a channel rather than writing a file, which is the fastest way to prove a listener works:

perfuse generate -n 100 -send 127.0.0.1:6661

19.4.2 perfuse deident

Reads real messages and writes a corpus that keeps their structure, their codes and their intervals, and none of what identifies a patient. This is the difference between being able to send somebody a reproduction and not.

It is not the same tool as perfuse generate and the distinction matters. Generated messages are invented and therefore safe but unrealistic; a de-identified corpus is *derived from real traffic*, so it reproduces the oddities that actually break interfaces.

Two properties worth understanding before using it:

-keep-local-segments passes Z-segments through unchanged and is unsafe. Z-segments are exactly where sites put names, notes and free text, so keeping them defeats the purpose of the tool. It exists because a structural problem is sometimes *in* a Z-segment, and then you need a corpus you must not share.

19.5 Moving messages by hand

19.5.1 perfuse listen

Accepts HL7 v2 over MLLP and acknowledges it. A receiver that exists for as long as you need it, for proving that a sender can reach this machine at all.

19.5.2 perfuse send

Sends HL7 v2 messages over MLLP from files.

MLLP has no authentication and no encryption, in Perfuse or anywhere else. Both of these commands are subject to that, and so is every MLLP source and destination: access is restricted at the network layer or it is not restricted.

19.6 FHIR

19.6.1 perfuse fhir

Converts HL7 v2 to FHIR, validates FHIR, or serves it, depending on the subcommand.

The validating subcommand is useful on its own: it will tell you whether a resource somebody sent you satisfies US Core, which is the profile American regulation is written against. Being able to answer that without adopting an engine is the point.

19.6.2 perfuse x12

Converts X12 (or other delimited EDI) to the XML Mirth Connect and Open Integration Engine write for it, and back: perfuse x12 xml FILE and perfuse x12 from-xml FILE, standard input when no file is given. The output is what OIE 4.6 writes, byte for byte, so it can be compared with what a Mirth server produced or fed to a system built against it. A channel does the same with x12.arrives_as: xml and a destination's x12_as: xml; see X12.

19.6.3 perfuse sof

Runs a SQL on FHIR ViewDefinition over FHIR files (the NDJSON of a bulk export, Bundles or JSON arrays; - for stdin) and writes the table: -view FILE (required), -format csv|ndjson|json|parquet (csv by default), -header=false, and -o FILE (required for Parquet). See SQL on FHIR views.

19.6.4 perfuse measure

Evaluates a quality measure with FHIRPath criteria over FHIR files (the NDJSON of a bulk export, Bundles or JSON arrays; - for stdin) and writes the MeasureReport. Flags:

See digital quality measures.

19.6.5 FHIR flags on perfuse serve

FlagEffect
-fhirServe the FHIR endpoint at /fhir. On by default.
-fhir-versionThe release served. R4 by default.
-fhir-read-onlyRefuse writes.
-fhir-api-tokensWith SMART configured, accept Perfuse API tokens as well as SMART tokens (off by default; also under Settings → FHIR).
-fhir-bulk-exportEnable $export. Off by default.
-fhir-subscriptionsEnable topic subscriptions: encounter and appointment notifications to endpoints FHIR clients choose. Off by default; needs R4 and a writable endpoint.
-fhir-subscriptions-allow-httpLet subscriptions deliver to plain http. For a test receiver, never for patient data on a network.
-fhir-patient-directory-searchLet a token limited to one patient search the directory (practitioners, organisations, locations), within its scopes. Off by default.
-fhir-declaresGuides this server declares in its capability statement, comma-separated: a guide's CapabilityStatement address, or Type=profile address. A statement for clients and test kits; it changes nothing the store accepts.

The store keeps documents as well as resources: a document, collection or message Bundle PUT to /fhir/Bundle/{id} is kept whole, read back with every entry in the release served, and found by type, identifier, timestamp and composition. A transaction PUT there is kept as a record and not applied; a POST to /fhir applies one.

Answers are JSON unless the client asks for XML (Accept: application/fhir+xml, or _format=xml). Perfuse reads JSON only: a body sent as XML gets 415. A document Bundle POSTed to /fhir is kept when this server holds, by identifier, the patient and encounter its Composition names, and refused with 422 otherwise. A Binary read with Accept set to its own content type answers with the content itself.

19.7 Setting up and operating

19.7.1 perfuse init

Creates the directory layout, an example channel, and a service definition for the operating system it is run on. Nothing existing is replaced unless you pass -force.

perfuse init -dir /opt/perfuse -service systemd

19.7.2 perfuse service

Registers Perfuse as a Windows service, removes it, or reports whether it is installed and running. Windows only; on Linux the equivalent is the unit file perfuse init can write for you. See operations.

19.7.3 perfuse token

Issues credentials for machines rather than people. A token does not expire and is not affected by anybody signing out, which is what a server polling its neighbour needs and what a browser session deliberately is not.

perfuse token create -db perfuse.db -label fleet-from-site-a -role viewer perfuse token list -db perfuse.db perfuse token revoke -db perfuse.db -label fleet-from-site-a

Only the hash is stored, so a token is displayed once when it is created and cannot be recovered afterwards. A stolen database therefore yields no usable token. Give a token the lowest role that does the job: a fleet view needs viewer, and nothing that only reads should hold anything more.

-fhir-groups riverside-attributed,riverside-north limits a token to those FHIR Groups, for a provider's CMS-0057 Provider Access export: on the FHIR endpoint it can read those Groups and export them, and nothing else. See CMS-0057 for payers.

19.7.4 perfuse athena

Prints the Amazon Athena CREATE EXTERNAL TABLE for an S3 archive written with format: ndjson, using partition projection on the dt=YYYY-MM-DD part of the key, so no crawler has to run as days are added.

perfuse athena -bucket hospital-archive -destination archive

Printed rather than run, because creating a table needs Athena and Glue permissions this program should not hold.

19.7.5 perfuse sbom

Lists everything linked into the binary, read from the build itself rather than from go.mod — so it describes the binary in front of you and not what somebody once asked for.

-json emits CycloneDX and -spdx emits SPDX tag-value, which is usually what a procurement or security review is actually asking for. The browser code embedded in the binary is included, because a reviewer matching a binary against advisories needs to know it contains a React application.

19.7.6 perfuse version

Prints the version and the commit it was built from. Worth putting in a ticket: "the current one" is not a version, and two machines that were installed a week apart are frequently not running the same build.

20 Server Options

Every option perfuse serve takes, generated from the program itself, so this list cannot name an option that does not exist or leave one out. Most are also a setting in the web interface, named in the last column: a value saved there wins over the flag at the next start, and the flag remains the way to set it from a service definition. The listen address, the database and the channel directory stay flags only, because they say where the server finds what it would be editing.

Secrets are never flags. An option that needs a password or a token names an environment variable that holds it.

20.1 All options

OptionTakesDefaultWhat it doesIn the console
-addrstring127.0.0.1:8080, or 127.0.0.1:8443 with -tls-certaddress to serve the interface on—
-alert-severitystringwarningminimum severity to send to the webhook: warning or criticalSettings › Alerts › Delivery: Only send alerts at least this serious
-alert-webhookstring—POST alerts to this URL as JSON. Works with Slack, Teams, Mattermost, Alertmanager or anything that accepts a webhook. The payload carries channel names, counts and thresholds; never message contentSettings › Alerts › Delivery: Send alerts to this address
-alertsstring—YAML file of alert rules. Default: alerts.yaml beside the database, written when the rules are first saved in the console; until then a conservative default set is used—
-allow-metadata-egresson/off—permit destinations pointed at cloud instance metadata addresses, which hold this machine's credentialsSettings › Security › What may leave: Allow destinations at cloud instance metadata addresses
-cds-clientsstring—the EHRs trusted to call the CDS services with signed JWTs (issuer and jwks_url)Settings › Payers › Prior authorization: EHRs trusted to call the CDS services
-channelsstring./channelsdirectory holding channel files—
-clusterstring—share the work with other servers on the same PostgreSQL -db: active-passive (one runs everything, another takes over when it stops) or active-active (channels spread over all)—
-cluster-leaseduration15show long a crashed server holds its work before another takes it over; renewed five times as often—
-crd-advisor-castring—a PEM file of certificate authorities to trust for -crd-advisor-url beside the system's (Vaglio's is its DATA/tls/ca.crt)Settings › Payers › Decision service: Certificate authorities to trust for the advice service
-crd-advisor-token-envstring—name of an environment variable holding a bearer token for -crd-advisor-url (the token itself is never a flag)Settings › Payers › Decision service: Environment variable holding the advice service's token
-crd-advisor-urlstring—with -crd-rules, ask the decision service at this URL (such as Vaglio's /api/pas/advise) what a prior authorization request for each order needing one would be told as the record stands, shown as one more CRD card; the prefetched record is POSTed to it. No answer, or any failure, leaves the cards as they are without itSettings › Payers › Decision service: Advice before submitting (CRD)
-crd-rulesstring—a Da Vinci CRD rules file: serve coverage requirements over CDS Hooks at /cds-servicesSettings › Payers › Prior authorization: Coverage rules file (Da Vinci CRD)
-dashboardsstring—a file mapping directory groups and roles to the dashboard each opens onSettings › Monitoring › Dashboards: Which dashboard each group opens on (file)
-datastring—directory for this server's own files (settings, alerts, sign-in configuration, the log): default beside -db when it is a file, the current directory when it is a PostgreSQL URL—
-davinci-versionstring2.2the Da Vinci CRD, DTR and PAS release answered when neither a request nor its client's registration says: 2.2 (CRD 2.2.1, DTR 2.2.0, PAS 2.2.1) or 2.1 (2.1.0)—
-dbstring./perfuse.dbdatabase for users, messages and stored resources: a file, or a postgres:// URL for servers that share one store (a cluster)—
-demo-accountstring—for a public demonstration server: username:password of a shared viewer account shown on the sign-in page—
-dicomweb-archivestring—keep imaging objects in this directory and serve them over DICOMweb (QIDO-RS, WADO-RS, WADO-URI, STOW-RS) at /dicomwebSettings › Data › Imaging archive: Keep images in this folder and serve them over DICOMweb
-dicomweb-corsstring—comma-separated browser origins, such as an OHIF viewer's, allowed to call /dicomwebSettings › Data › Imaging archive: Browser origins allowed to call it
-drain-forduration—after SIGTERM, report not-ready for this long before shutting down (for rolling deploys)Settings › Engine › Shutdown: Wait for in-flight messages on shutdown
-ecr-agencystring—play a public health agency for testing eCR: answer case reports at $process-message with a Reportability Response, using the agency YAML file given (name, phone, address, endpoint; optional rctc, reply)Settings › Public health › Case reporting: Act as a test public health agency
-ecr-responseson/off—accept eCR Reportability Responses at the FHIR endpoint's $process-message, storing each as DocumentReference/<id> derived from the eICR it answersSettings › Public health › Case reporting: Receive Reportability Responses
-engineon/offtruerun the channel engine in this process, so the interface can start, stop and monitor channels—
-epa-payer-idstring—this payer's identifier on the prescribing network, the From of every RTPB and ePA response (default: the formulary's payer id)Settings › Payers › Drug formulary: Payer ID on the prescribing network
-epa-reply-channelstring—route ePA decisions made after the request was answered (a reviewer's, an appeal's) to this channel, which delivers them to the prescriber's networkSettings › Payers › Drug formulary: Channel that sends later ePA decisions
-fhiron/offtrueserve a FHIR endpoint at /fhirSettings › FHIR › The FHIR server: Serve a FHIR endpoint at /fhir
-fhir-api-tokenson/off—with SMART configured, accept Perfuse API tokens on the FHIR endpoint as well (unscoped; the console's FHIR settings can switch it)Settings › FHIR › Access: Accept Perfuse API tokens beside SMART tokens
-fhir-bulk-exporton/off—enable $export, which produces a file holding every record the FHIR endpoint servesSettings › FHIR › Bulk data: Bulk Data export ($export)
-fhir-declaresstring—guides this FHIR server declares in its capability statement, comma-separated: a CapabilityStatement address, or Type=profile addressSettings › FHIR › Capability statement: Guides this server declares
-fhir-max-resourcesint—for a public server: refuse new FHIR resources once this many are held (0: no limit)Settings › FHIR › Access: Most resources held
-fhir-member-match-without-consenton/off—let $member-match answer without an active Consent; the Payer-to-Payer API is opt-in, so only for testingSettings › Payers › CMS-0057-F: Answer $member-match without the member's consent
-fhir-openon/off—serve the FHIR endpoint with no authentication at all, exposing every stored recordSettings › FHIR › Access: Let anyone read the FHIR endpoint without signing in
-fhir-patient-directory-searchon/off—let a token limited to one patient search the directory (practitioners, organisations, locations) within its scopesSettings › FHIR › Access: Let a patient's app search the directory
-fhir-payer-apison/off—enable the CMS-0057 payer operations: $member-match (Payer-to-Payer) and Group $davinci-data-export (Provider Access); the Group exports also need -fhir-bulk-exportSettings › Payers › CMS-0057-F: The CMS-0057-F payer APIs
-fhir-read-onlyon/off—refuse writes to the FHIR endpointSettings › FHIR › Access: Refuse writes to the FHIR endpoint
-fhir-subscriptionson/off—enable topic-based subscriptions, which send encounter and appointment notifications to URLs FHIR clients chooseSettings › FHIR › Notifications: FHIR subscriptions (encounter and appointment notifications)
-fhir-subscriptions-allow-httpon/off—let subscriptions deliver to plain-http endpoints; for a test receiver, never for patient data on a networkSettings › FHIR › Notifications: Allow subscriptions to notify plain-HTTP addresses
-fhir-versionstring4.0.1FHIR release to serve: R4, R4B or R5Settings › FHIR › The FHIR server: FHIR version clients see
-fhir-writes-per-minuteint—for a public server: writes one client address may make to the FHIR endpoint a minute (0: no limit)Settings › FHIR › Access: Writes a minute from one client
-fleet-labelstring—how this instance names itself in a fleet view (default: hostname)Settings › Fleet › This server: Name for this server
-formularystring—publish this drug formulary over the Da Vinci US Drug Formulary API at /formulary/fhir and answer NCPDP Real-Time Prescription Benefit and SCRIPT electronic prior authorization from itSettings › Payers › Drug formulary: Drug formulary file
-icd11-client-idstring—a WHO ICD API client: ICD-11 codes not imported are looked up thereSettings › Data › Code systems: WHO ICD API client ID
-icd11-client-secretstring—the WHO ICD API client's secretSettings › Data › Code systems: WHO ICD API client secret
-icd11-releasestring2025-01the ICD-11 MMS release the WHO ICD API is asked aboutSettings › Data › Code systems: ICD-11 release to ask for
-icd11-urlstring—the ICD API's address when it is not the WHO's: the WHO's ICD-API container on your network needs no clientSettings › Data › Code systems: ICD API address
-index-identityon/offtrueindex patient identifiers from message contents so a message can be found by MRN, name or accession in any format; has no effect when -store-payloads is offSettings › Data › What is kept: Index patient identifiers
-insecureon/off—serve over plain HTTP on a non-loopback address (not recommended)—
-inventorystring—write a Markdown inventory of every interface to this file, and keep it currentSettings › Monitoring › Inventory: Write an interface inventory to
-inventory-everyduration24h0m0show often to rewrite the interface inventorySettings › Monitoring › Inventory: Rewrite the inventory every
-json-logson/off—emit structured JSON logsSettings › Monitoring › Logging: Write logs as JSON
-ldapstring—YAML file configuring directory (LDAP) sign-in (default: ldap.yaml beside the database, once saved in the console)—
-log-filestring—append the log, and the first administrator's one-time password, to this file (mode 0600) instead of the console. A Windows service has no console, so there it defaults to perfuse.log beside the database—
-mcpon/off—serve the Model Context Protocol at /mcp, so AI agents with an API token can read status, messages, the queue, alerts and the FHIR store with that token's own accessSettings › Security › AI agents (MCP): Let AI agents connect over MCP
-measure-reporterstringPerfusethe reporting organisation's name on MeasureReports from $evaluate-measure when the request names no reporter—
-metrics-openon/off—serve /metrics to anybody who can reach the port. Only safe where that port is genuinely private: metric labels name your channels, and on a multi-tenant server they name every tenant'sSettings › Security › Metrics: Serve /metrics without a token
-metrics-tokenstring—credential the Prometheus scrape endpoint requires. Give the scraper the same value in bearer_token_file. Without this, only a signed-in operator can read /metricsSettings › Security › Metrics: Token a metrics scraper presents
-mpion/off—keep a patient index (master patient index) in mpi.db beside the database: patient records from every system linked into one enterprise identifier per person, fed by mpi destinations and the PIX v3 identity feed, answering Patient/$match, PIXm, PIX v3 (/pixv3) and PDQ v3 (/pdqv3)Settings › Data › Patient index: Keep a patient index
-multi-tenanton/off—keep each tenant's channels in their own subdirectory of -channels, so one server can run integration for several organisations without them being able to see each other—
-node-addressstring—this server's console URL as the other servers and a load balancer reach it—
-node-namestring—this server's name in the cluster (default: an id kept in -data)—
-oidcstring—YAML file configuring OpenID Connect sign-in (default: oidc.yaml beside the database, once saved in the console)—
-pason/off—serve Da Vinci PAS 2.2.1 and 2.1.0 (Claim/$submit, $inquire, $decide) on the FHIR endpoint; decisions come from -crd-rules, and what no rule decides is pended for a reviewer. With -fhir-subscriptions, the PAS topic delivers each pended request's resultSettings › Payers › Prior authorization: Da Vinci PAS prior authorization server
-pas-278-castring—a PEM file of certificate authorities to trust for the UM system beside the system'sSettings › Payers › Utilization management link: Certificate authorities to trust for the UM system
-pas-278-certstring—a PEM client certificate for X.509 mutual TLS with the UM system (CORE's authentication)Settings › Payers › Utilization management link: Client certificate file
-pas-278-core-receiverstring—the CORE envelope's ReceiverID, as the UM system's connectivity guide gives it (default: -pas-278-receiver)Settings › Payers › Utilization management link: CORE envelope ReceiverID
-pas-278-core-rule-versionstring—what to write in the CORE envelope's CORERuleVersion when the UM system's guide asks for something other than the version itself (CMS esMD's sample says v2.2.0)Settings › Payers › Utilization management link: CORERuleVersion written in the envelope
-pas-278-core-senderstring—the CORE envelope's SenderID, as the UM system's connectivity guide gives it (default: -pas-278-sender)Settings › Payers › Utilization management link: CORE envelope SenderID
-pas-278-core-versionstring2.2.0the CAQH CORE Connectivity Rule the UM system runs, for core-soap and core-mime: 2.2.0 (payload in the envelope) or C4.0.0 (payload as an MTOM attachment; SOAP only)Settings › Payers › Utilization management link: CAQH CORE Connectivity Rule
-pas-278-keystring—the private key (PEM) for -pas-278-certSettings › Payers › Utilization management link: Client certificate key file
-pas-278-oauth-client-idstring—the OAuth client ID for -pas-278-oauth-token-urlSettings › Payers › Utilization management link: OAuth client ID
-pas-278-oauth-scopestring—the scope asked for with the OAuth token, when the UM system wants oneSettings › Payers › Utilization management link: OAuth scope
-pas-278-oauth-secret-envstring—name of an environment variable holding the OAuth client secret (the secret itself is never a flag)Settings › Payers › Utilization management link: Environment variable holding the OAuth client secret
-pas-278-oauth-token-urlstring—an OAuth 2.0 token endpoint: a token got with the client-credentials grant is sent as a bearer token (CORE vC4.0.0's authorization)Settings › Payers › Utilization management link: OAuth token endpoint
-pas-278-password-envstring—name of an environment variable holding the password for -pas-278-user (the password itself is never a flag)Settings › Payers › Utilization management link: Environment variable holding the password
-pas-278-payer-idstring—the UMO's payer ID (NM1*X3) when the request's insurer carries no identifierSettings › Payers › Utilization management link: Payer ID for the 278s
-pas-278-productionon/off—mark the 278s sent as production (ISA15 P) rather than testSettings › Payers › Utilization management link: Mark the 278s as production
-pas-278-receiverstring—ISA08/GS03 receiver ID on the 278s sent to the UM systemSettings › Payers › Utilization management link: Receiver ID on the 278s
-pas-278-senderstringPERFUSEISA06/GS02 sender ID on the 278s sent to the UM systemSettings › Payers › Utilization management link: Sender ID on the 278s
-pas-278-sftpstring—host:port of the UM system's SFTP server, for -pas-278-transport sftpSettings › Payers › Utilization management link: SFTP server
-pas-278-sftp-everyduration1m0show often the SFTP inbound folder is readSettings › Payers › Utilization management link: Read the SFTP inbound folder every
-pas-278-sftp-inboundstring—the SFTP folder the UM system's 278 responses, 999s and TA1s are collected fromSettings › Payers › Utilization management link: SFTP inbound folder
-pas-278-sftp-keystring—an OpenSSH private key file for the SFTP user, instead of a passwordSettings › Payers › Utilization management link: SFTP key file
-pas-278-sftp-known-hostsstring—an OpenSSH known_hosts file holding the SFTP server's key; required, so requests never go to whoever answersSettings › Payers › Utilization management link: SFTP known_hosts file
-pas-278-sftp-outboundstring—the SFTP folder the 278 requests (and the 999s for the answers) are put inSettings › Payers › Utilization management link: SFTP outbound folder
-pas-278-sftp-password-envstring—name of an environment variable holding the SFTP password (the password itself is never a flag)Settings › Payers › Utilization management link: Environment variable holding the SFTP password
-pas-278-sftp-userstring—the SFTP user nameSettings › Payers › Utilization management link: SFTP user
-pas-278-token-envstring—name of an environment variable holding a bearer token for the UM system (the token itself is never a flag)Settings › Payers › Utilization management link: Environment variable holding the token
-pas-278-transportstringx12how the 278s reach the UM system: x12 (POSTed as application/edi-x12), core-soap (a CAQH CORE SOAP envelope), core-mime (CAQH CORE HTTP MIME multipart), or sftp (batch files over -pas-278-sftp, answers collected from its inbound folder)Settings › Payers › Utilization management link: How the 278s travel
-pas-278-urlstring—forward every PAS request to the payer's utilization management system as an X12 278 request sent to this URL (by -pas-278-transport), and answer with its 278 response; later 278 responses go to Claim/$decide-278Settings › Payers › Utilization management link: Send PAS requests on to the UM system at
-pas-278-userstring—user name for the UM system: CORE's UserName (WS-Security UsernameToken in SOAP), or HTTP Basic for x12Settings › Payers › Utilization management link: User name
-pas-decider-castring—a PEM file of certificate authorities to trust for -pas-decider-url beside the system's, such as the payer's own authority or the decision service's (Vaglio's is its DATA/tls/ca.crt)Settings › Payers › Decision service: Certificate authorities to trust for the decision service
-pas-decider-token-envstring—name of an environment variable holding a bearer token for -pas-decider-url (the token itself is never a flag)Settings › Payers › Decision service: Environment variable holding the decision service's token
-pas-decider-urlstring—ask the decision service at this URL (such as Vaglio) to decide each PAS item on the request's clinical evidence, when -crd-rules leave it to a reviewer; the PAS request Bundle is POSTed to it. Cannot be combined with -pas-278-urlSettings › Payers › Decision service: Decision service URL
-passkey-allowed-modelsstring—comma-separated authenticator AAGUIDs that may enrol; requires -passkey-attestation-roots—
-passkey-attestation-rootsstring—a PEM file of authenticator vendor certificates; passkey attestation is verified against these—
-passkey-domainstring—the bare domain passkeys are registered for, such as perfuse.example.org. No scheme, no port, no path. Setting it turns passkeys on; leaving it empty leaves them offSettings › Sign-in › Passkeys: Domain passkeys are bound to
-passkey-namestringPerfusethe name an authenticator shows a person when they register a passkeySettings › Sign-in › Passkeys: Name shown on the device
-passkey-originsstring—comma-separated origins the console is reached at, with scheme and port: https://perfuse.example.org. Defaults to https:// plus the domainSettings › Sign-in › Passkeys: Other origins passkeys are accepted from
-peersstring—YAML file listing other Perfuse instances to show in a fleet viewSettings › Fleet › Fleet: Other Perfuse servers to show (file)
-public-urlstring—the https address patients' phones and other organisations reach this server at, for the SMART Health Links it hosts (default: the listen address)Settings › FHIR › The FHIR server: Address others reach this server at
-rctcstring—eCR trigger codes for the case report preview: a FHIR ValueSet or Bundle of them, such as the eRSDSettings › Public health › Case reporting: Trigger codes file (RCTC or eRSD)
-residencystring—cloud regions this server's data may go to or come from: areas (eea, uk, ch) and region codes (eu-central-1), comma-separated; channels with connectors elsewhere are refusedSettings › Data › Data protection: Where data may go (cloud regions)
-retention-daysint30how long to keep recorded messages; 0 keeps them for ever, which will eventually fill the diskSettings › Data › How long it is kept: Delete records after
-samlstring—YAML file configuring SAML 2.0 sign-in (default: saml.yaml beside the database, once saved in the console)—
-scimon/off—accept SCIM 2.0 provisioning from an identity provider at /scim/v2. Okta, Entra ID and Google Workspace use it to create accounts and, more importantly, to disable them when somebody leavesSettings › Sign-in › Automatic accounts: Let the identity provider manage accounts
-scim-default-rolestringviewerrole for an account the identity provider does not describe. Defaults downward on purpose: an account whose role could not be determined should be able to read and nothing else—
-settingsstring—settings file the web interface may edit (default: perfuse-settings.yaml beside the database)—
-shl-allow-httpon/off—let a received SMART Health Link point at plain HTTP; for testing onlySettings › Public health › Health links: Allow SMART Health Links over plain HTTP
-signing-certstring—certificate for signing documents (defaults to -tls-cert)Settings › Security › Document signing: Certificate for signing documents
-signing-keystring—key for signing documents (defaults to -tls-key)Settings › Security › Document signing: Key for signing documents
-smart-audiencestring—the audience a SMART token must be issued for, normally the FHIR base URLSettings › SMART › Authorization: Audience tokens must name
-smart-authorizestring—the authorization endpoint to advertise to SMART appsSettings › SMART › Authorization: Authorization endpoint
-smart-backend-serviceson/off—advertise that the SMART authorization server issues backend-services (client_credentials, signed JWT) tokensSettings › SMART › Built-in server: Backend Services (system-to-system tokens)
-smart-clientsstring—run Perfuse's own SMART authorization server at /auth for the apps registered in this YAML fileSettings › SMART › Built-in server: Registered apps file (turns on the built-in server)
-smart-issuerstring—accept SMART on FHIR access tokens from this authorization serverSettings › SMART › Authorization: Accept SMART access tokens from this authorization server
-smart-jwksstring—where to fetch the SMART issuer's signing keys, when not from its published metadata (split DNS, a private address)Settings › SMART › Authorization: Key set file
-smart-jwks-uristring—the authorization server's public key set URL, advertised to SMART apps (needed to claim OpenID Connect sign-in)Settings › SMART › Authorization: Key set address
-smart-keystringsmart-signing.keythe built-in authorization server's RSA signing key (PEM, mode 0600), created if missingSettings › SMART › Built-in server: Signing key file
-smart-oidcstring—with -smart-users, let people sign in at this OpenID Connect provider (YAML: issuer, client_id, client_secret_file or client_secret_env, label); each is linked by oidc_subjectSettings › SMART › Built-in server: Sign people in through the OpenID Connect provider
-smart-tokenstring—the token endpoint to advertise to SMART appsSettings › SMART › Authorization: Token endpoint
-smart-usersstring—with -smart-clients, the people who may sign in to authorize apps (YAML: username, password_hash, fhir_user)Settings › SMART › Built-in server: People who sign in to authorize apps (file)
-snmp-allowstring—comma-separated networks or addresses the SNMP agent answers, such as 10.0.5.0/24; required with -snmp-listenSettings › Monitoring › SNMP: Networks that may ask
-snmp-base-oidstring1.3.6.1.4.1.8072.9999.9999.16, Net-SNMP's experimental spacewhere Perfuse's values sit, under your enterprise numberSettings › Monitoring › SNMP: Base OID
-snmp-communitystring—the SNMPv2c community (a password sent in clear: at least 6 characters, not public or private)Settings › Monitoring › SNMP: SNMP community
-snmp-contactstring—sysContact the SNMP agent reportsSettings › Monitoring › SNMP: Contact (sysContact)
-snmp-listenstring—answer SNMPv2c GET/GETNEXT/GETBULK on this UDP address, such as :161 (off when empty). Read-only: channel states, counts and queue depthsSettings › Monitoring › SNMP: Answer SNMP on this address
-snmp-locationstring—sysLocation the SNMP agent reportsSettings › Monitoring › SNMP: Location (sysLocation)
-snmp-trapsstring—comma-separated managers (host or host:port, default port 162) that receive an SNMPv2 trap when an alert fires or resolvesSettings › Monitoring › SNMP: Send traps to
-store-messageson/offtruerecord every message handled, which is what makes the message browser workSettings › Data › What is kept: Keep a record of each message
-store-payloadson/offtruestore message contents as well as their outcomes; turn this off where PHI must not be held at restSettings › Data › What is kept: Keep message contents
-tls-certstring—TLS certificate fileSettings › Security › Encryption: TLS certificate file
-tls-keystring—TLS private key fileSettings › Security › Encryption: TLS private key file
-trace-endpointstring—OTLP/HTTP base URL to export message traces to, for example http://localhost:4318Settings › Monitoring › Tracing: Send traces to
-trace-instancestring—name for this instance in traces; defaults to the hostname, which is what distinguishes replicasSettings › Monitoring › Tracing: This instance's name in traces
-trace-samplefloat1fraction of traces to record, 0 to 1—
-tsa-urlstring—RFC 3161 time-stamping authority for DSDR signatures on claims attachmentsSettings › Security › Document signing: Time-stamping authority
-vpnstring—partner VPN tunnels to report on the Connections dashboard (YAML: AWS Site-to-Site VPN, Azure VPN Gateway or strongSwan, and both sides' settings); see examples/vpn.yamlSettings › Monitoring › Connections: VPN tunnels to watch (file)
-watch-accesson/offtruewatch traffic and reads of patient data for what a breach looks like (bursts, night traffic, new destinations, bulk reads) and raise alerts—
-xdsstring—keep clinical documents here as an IHE XDS.b registry and repository with this repository uniqueId (an OID), answering ITI-41, ITI-42, ITI-18 and ITI-43 at /xds/repository and /xds/registrySettings › Data › Document sharing (IHE XDS): Keep documents here as an XDS registry and repository
-xds-home-communitystring—this community's home community id (urn:oid:...), so /xca/gateway answers XCA ITI-38 and ITI-39 for itSettings › Data › Document sharing (IHE XDS): Home community id, for XCA

21 Channel Reference

Every key that can appear at the top level of a channel file. These are extracted from the source, so this chapter cannot describe an option that does not exist or omit one that does.

21.1 Channel

Channel is one configured message flow: somewhere messages arrive, an optional filter, and one or more places they go.

Defined as Channel in internal/config.

KeyTypeRequiredDefault
nametextyes—
descriptiontextno—
grouptextno—
attachmentsAttachments (a block, omit to leave off)no—
delimitedDelimited (a block, omit to leave off)no—
tableslist of textno—
contractContractRef (a block, omit to leave off)no—
enabledtrue or false (a block, omit to leave off)no—
dataTypeDataTypenohl7
hl7v3HL7v3Options (a block, omit to leave off)no—
x12X12Options (a block, omit to leave off)no—
ncpdpNCPDPOptions (a block, omit to leave off)no—
edifactEDIFACTOptions (a block, omit to leave off)no—
scriptScriptOptions (a block, omit to leave off)no—
dicomDICOMOptions (a block, omit to leave off)no—
sourceSourceyes—
filtertextno—
transformationslist of transform.Stepno—
scriptsScripts (a block, omit to leave off)no—
shadowShadow (a block, omit to leave off)no—
destinationslist of Destinationyes—

21.1.1 name

Name identifies the channel in logs and metrics.

Required and unique.

21.1.2 group

Group is what this channel is part of, for organising a list that has grown too long to read.

Cosmetic at five channels and structural at two hundred: a site's channels divide by the system they talk to, or by the team that owns them, and a flat list of two hundred names is unusable however good the rest of the interface is.

A plain string rather than a group object with its own file, because a group has no behaviour. The moment it has a file it acquires settings, and settings on a group mean a channel's behaviour depends on something not written in the channel - which is the property that makes Mirth configuration hard to reason about. Groups here are a label and nothing more.

21.1.3 attachments

Attachments moves large payloads out of the message and puts them back before delivery.

Opt-in per channel: a feed of plain ADT messages has nothing to extract and should not pay for the machinery.

21.1.4 delimited

Delimited configures a delimited channel.

Only meaningful when the data type is delimited.

21.1.5 tables

Tables names files of shared mapping tables a transformation may refer to by name.

Shared because the domain tax is largely re-deriving a mapping somebody already derived: a site's sex codes and patient classes get translated identically in every channel that touches them, and a table inside a channel is a table only that channel can use.

21.1.6 enabled

Enabled defaults to true.

A disabled channel is loaded and validated but not started, so a file that is temporarily off is still checked by CI.

21.1.7 dataType

DataType is the format of the messages this channel receives.

Defaults to hl7, so every channel written before this existed keeps working.

21.1.8 x12

X12 holds the settings that only mean anything on an X12 channel.

Setting it on any other kind is refused rather than ignored.

21.1.9 script

Script configures an NCPDP SCRIPT channel.

Its steps live here rather than in the top-level transformations for the same reason every other non-v2 format's do: the step types differ per format, so one key would mean different things depending on dataType.

21.1.10 dicom

Imaging configures a DICOM channel's named transformation steps.

Named Imaging rather than DICOM because Source and Destination already have a DICOM field and three fields with the same name at different levels is how somebody sets the wrong one. The yaml key is dicom, which is what a person writing the file expects, and the Go name is the one that stops a mistake at the call site.

21.1.11 filter

Filter is an expression that must hold for a message to be forwarded.

A message that does not match is still acknowledged: the sender did nothing wrong, we simply are not interested.

21.1.12 transformations

Transformations are declarative changes applied to accepted messages, in order, before any script runs.

This is the layer meant to carry the ordinary work: each step is validated when the file loads, shows up in a diff, and can be reviewed by somebody who does not read JavaScript.

21.1.13 scripts

Scripts holds JavaScript for the cases the declarative steps cannot express, and is what a Mirth channel's own scripts drop into unchanged.

A channel with a script is marked as such in the interface, because its behaviour cannot be established by reading the configuration.

21.1.14 shadow

Shadow runs a candidate version of this channel beside it and reports where they differ.

Nothing it does can reach a receiver.

22 Source Reference

Every way a channel can receive messages, and every key each one accepts. A channel has exactly one source.

22.1 AMQP Source

AMQPSource receives from a queue or subscription.

A message is accepted only after the channel has handled it. One that could not be handled is released as a failed delivery, so the broker redelivers it and - on Service Bus, past the queue's max delivery count - dead-letters it.

Defined as AMQPSource in internal/config.

KeyTypeRequiredDefault
prefetchwhole numberno10
reconnectduration, such as 30s or 5mno5s

22.1.1 prefetch

Prefetch is how many messages the broker may send ahead.

Defaults to 10. They are still handled one at a time, in order.

22.1.2 reconnect

Reconnect is the wait after a lost connection.

Defaults to 5s.

22.2 AS2 Source

AS2Source receives AS2 (RFC 4130): EDI posted over HTTP, signed and encrypted with S/MIME, answered with a receipt (an MDN) carrying a digest of what arrived.

The payload is what the channel handles; X12 and EDIFACT are the usual ones.

Defined as AS2Source in internal/config.

KeyTypeRequiredDefault
listentextyes—
pathtextno—
tlstlsconf.Settings (a block, omit to leave off)no—
idtextyes—
cert_filetextyes—
key_filetextyes—
partnerslist of AS2Partneryes—
max_message_sizewhole numberno—
duplicate_windowduration, such as 30s or 5mno—

22.2.1 path

Path is the URL path partners post to.

Default /as2.

22.2.2 tls

TLS serves HTTPS.

Most partners require it in their agreement, on top of the S/MIME.

22.2.3 id

ID is our AS2 identifier, which partners send in AS2-To.

A message to any other is refused.

22.2.4 partners

Partners are who may send here.

A message from anyone else is refused.

22.2.5 duplicate_window

DuplicateWindow is how long a partner's Message-ID is remembered, so a message sent again (the partner never got our receipt, and retried) is answered with a receipt and not passed on twice (RFC 4130 section 7.5).

Default 30 days.

22.3 Azure Blob Source

AzureBlobSource picks blobs up from a container prefix.

Defined as AzureBlobSource in internal/config.

KeyTypeRequiredDefault
prefixtextyes—
suffixtextno—
after_readtextno—
move_totextnoprocessed/
error_prefixtextnoerror/
poll_intervalduration, such as 30s or 5mno30s
max_object_sizewhole numberno16 MiB
framedtrue or falseno—

22.3.1 move_to

MoveTo is where a handled blob moves.

Defaults to processed/.

22.3.2 error_prefix

ErrorPrefix is where a blob that cannot be handled moves, so it does not block the rest.

Defaults to error/.

22.3.3 poll_interval

PollInterval between listings.

Defaults to 30s.

22.3.4 max_object_size

MaxObjectSize refuses a larger blob, which is left where it is.

Defaults to 16 MiB.

22.4 Broker Source

BrokerSource reads messages from a message broker.

Mirth's JMS Reader, reached over STOMP because JMS is a Java API rather than a protocol. See internal/stomp for why STOMP and not OpenWire.

Defined as BrokerSource in internal/config.

KeyTypeRequiredDefault
addrtextyes—
destinationtextyes—
logintextno—
passcodetextno—
hosttextnothe address's host
selectortextno—
subscription_idtextnothe channel name
heartbeatduration, such as 30s or 5mnothirty seconds
reconnectduration, such as 30s or 5mnofive seconds
timeoutduration, such as 30s or 5mnothirty seconds
tlstlsconf.Settings (a block, omit to leave off)no—

22.4.1 addr

Addr is the broker's host and port.

Required. 61613 is the usual STOMP port.

22.4.2 destination

Destination is the queue or topic to read from.

Required.

Brokers spell these differently - /queue/name on ActiveMQ, a bare name on RabbitMQ - so it is passed through unchanged rather than being assembled here. Guessing would mean working against one broker and silently reading nothing on another.

22.4.3 login

Login and Passcode authenticate.

Most brokers refuse an anonymous connection.

22.4.4 passcode

Login and Passcode authenticate.

Most brokers refuse an anonymous connection.

22.4.5 host

Host is the virtual host.

Defaults to the address's host.

22.4.6 selector

Selector filters messages at the broker.

A JMS selector, passed through rather than interpreted. Filtering at the broker rather than here means the messages this channel does not want never cross the network - which on a shared queue is the difference between reading a hundred messages a day and a hundred thousand.

22.4.7 subscription_id

SubscriptionID names the subscription.

Defaults to the channel name.

Named rather than generated, because a durable subscription is identified by it: a generated one would create a new subscription on every restart and leave the old ones accumulating messages nobody reads.

22.4.8 heartbeat

Heartbeat keeps the connection alive.

Defaults to thirty seconds.

On by default here, unlike the client's own default, because a source holds one connection open for weeks. Without heartbeats a connection through a firewall that has silently dropped the route looks perfectly healthy from this end and messages simply stop arriving, with nothing logged.

22.4.9 reconnect

Reconnect is how long to wait before reconnecting after a failure.

Defaults to five seconds.

22.4.10 timeout

Timeout bounds connecting.

Defaults to thirty seconds.

22.5 DICOM Query Source

DICOMQuerySource polls an archive with C-FIND and emits a message per new study.

Mirth has no equivalent connector. Its DICOM support is a listener and a sender - it can receive images pushed at it and push them on, and that is all. Asking an archive what it holds has been requested on their forums since 2015.

What makes it worth having is not the query, it is the automation: a prefetch that runs at three in the morning, a reconciliation that notices the archive is missing a study the RIS says exists, a worklist built from what was actually scanned rather than what was ordered. Those are jobs sites currently do with a scheduled script outside the engine, which means they are invisible to it - no retries, no alerting, no message history.

Defined as DICOMQuerySource in internal/config.

KeyTypeRequiredDefault
addresstextyes—
called_aetextyes—
calling_aetextno—
leveltextnoSTUDY
patient_roottrue or falseno—
matchmapping of text to textno—
returnlist of textno—
intervalduration, such as 30s or 5myes—
windowduration, such as 30s or 5mno—
overlapduration, such as 30s or 5mnoone hour
limitwhole numberno500
timeoutduration, such as 30s or 5mnotwo minutes
tlstlsconf.Settings (a block, omit to leave off)no—
emit_on_first_polltrue or falsenofalse

22.5.1 address

Address is the archive's host and port.

Required.

Named to match the DICOM destination rather than shortened. The two were "address" and "addr" for an afternoon, which is the sort of inconsistency somebody hand-writing a channel file gets wrong once and then distrusts the whole format over.

22.5.2 called_ae

CalledAE is the archive's AE title.

Required in practice: most archives refuse an association addressed to anything else, and that refusal reads as an outage rather than as a configuration mistake.

22.5.3 calling_ae

CallingAE is the AE title Perfuse presents.

Archives commonly use it for access control.

22.5.4 level

Level is the query level: PATIENT, STUDY, SERIES or IMAGE.

Defaults to STUDY.

STUDY is the default because it is what almost every useful query wants and because the alternatives surprise people: IMAGE level against a large CT returns one response per slice, which is hundreds per study.

22.5.5 match

Match are the query keys.

A value filters; an empty value asks for the field to be returned.

Keys are named rather than numbered - "PatientID" not "0010,0020" - because a channel file is read by people, and a tag number in a configuration file is a lookup every reader has to perform.

22.5.6 return

Return are fields to bring back without filtering on them.

Separate from Match with an empty value, even though they encode identically, because the intent differs and a reader can see which keys are narrowing the search and which are being collected.

22.5.7 interval

Interval is how often to poll.

Required.

22.5.8 window

Window is how far back each poll looks, as a study date range.

Zero disables date filtering entirely.

A window is what keeps this bounded. Without one, every poll asks the archive for everything it has ever held and then discards what it has seen before - which works on a test archive and is antisocial against a real one holding millions of studies.

22.5.9 overlap

Overlap re-queries this far into the already-polled period.

Defaults to one hour.

Necessary rather than cautious. A study can be registered with yesterday's date, our clock and the archive's need not agree, and a poll boundary that lines up exactly with an arrival loses it. The overlap re-asks, and the already-seen check stops the duplicate - so the cost of overlapping is a slightly larger query and the cost of not overlapping is a silently missed study.

22.5.10 limit

Limit caps the matches accepted from one poll.

Defaults to 500.

A cap rather than no cap because a mistyped match key turns this into "give me everything", and the first symptom of that against a real archive is the archive's administrator asking who is hammering it.

22.5.11 timeout

Timeout bounds one poll.

Defaults to two minutes.

22.5.12 emit_on_first_poll

EmitOnFirstPoll sends messages for everything the first poll finds.

Defaults to false.

False matters. Pointing this at an archive that already holds a million studies and having it emit a message for each is not a useful first run, and it is the kind of mistake that is noticed downstream rather than here. The first poll records what exists and emits nothing; the second poll onwards reports what is new.

22.6 DICOM Source

DICOMSource receives imaging objects as a C-STORE service class provider.

What a hospital points a modality or a PACS at. Mirth calls this the DICOM Listener and it does C-STORE only, so this is parity rather than a subset.

Defined as DICOMSource in internal/config.

KeyTypeRequiredDefault
listentextyes—
ae_titletextno—
allowed_calling_aelist of textno—
sop_classeslist of textno—
transfer_syntaxeslist of textno—
max_object_byteswhole numberno—
idle_timeoutduration, such as 30s or 5mno—
tlstlsconf.Settings (a block, omit to leave off)no—

22.6.1 listen

Listen is the address to serve on.

Required. 104 is the registered DICOM port; 11112 is the common alternative where binding a privileged port is not possible.

22.6.2 ae_title

AETitle is what this endpoint calls itself.

Strongly recommended rather than required, and warned about when absent. A site's only access control on a DICOM endpoint is frequently the AE title, so an endpoint that accepts any title accepts anything that can reach it.

22.6.3 allowed_calling_ae

AllowedCallingAE lists the AE titles permitted to connect.

Empty accepts any caller.

This is the access control DICOM actually has. A PACS is routinely configured to accept one named calling title and reject everything else, and this end had no equivalent: the called title was checked - which is the name the caller dials, so anybody can send it correctly - while who was calling was recorded in the log and otherwise ignored.

Empty still accepts anything, because a modality nobody listed is the normal state of a first installation and refusing by default would break every deployment on day one. It warns instead, and the warning names the titles that have connected so somebody can fill this in from what actually arrived.

22.6.4 sop_classes

SOPClasses restricts what kinds of object to accept.

Empty accepts any.

Empty is a reasonable default here because this engine relays objects rather than interpreting them, so refusing an unfamiliar class would refuse valid images for no benefit.

22.6.5 transfer_syntaxes

TransferSyntaxes restricts the encodings to accept, in preference order.

Empty accepts the uncompressed three.

22.6.6 max_object_bytes

MaxObjectBytes bounds one received object.

Zero applies a default.

22.6.7 idle_timeout

IdleTimeout closes an association that has gone silent.

Zero means never, which is usually right - a modality holds an association open between studies.

22.6.8 tls

TLS encrypts inbound associations and can require a client certificate.

Worth stating plainly: Mirth needs its paid SSL Manager extension for this, and the community forks cannot ship it at all. DICOM carries patient names in its metadata, so this is one of the places where being a rewrite rather than a fork is a real advantage rather than a stylistic one.

22.7 DICO Mweb Source

DICOMwebSource accepts STOW-RS requests.

Defined as DICOMwebSource in internal/config.

KeyTypeRequiredDefault
listentextyes—
pathtextno/dicom-web
tokentextno—
usernametextno—
passwordtextno—
max_object_byteswhole numberno—
tlstlsconf.Settings (a block, omit to leave off)no—

22.7.1 listen

Listen is the address to serve on.

Required.

22.7.2 path

Path is the service root.

Objects are accepted at Path/studies and Path/studies/{uid}. Defaults to /dicom-web, which is what Orthanc and dcm4chee use, so a sender configured for either needs only the host changed.

22.7.3 token

Token, when set, is required as a bearer token.

Username and Password require HTTP basic authentication.

22.7.4 username

Token, when set, is required as a bearer token.

Username and Password require HTTP basic authentication.

22.7.5 password

Token, when set, is required as a bearer token.

Username and Password require HTTP basic authentication.

22.8 Database Source

DatabaseSource reads rows from a database and turns each one into a message.

This is how a great many hospital interfaces actually start. A department system has no HL7 capability, so somebody writes rows into a staging table and the interface engine polls it. It is unglamorous and it is everywhere.

It is also the single commonest way a Mirth channel stalls, and the reason is almost always the same shape: a row that cannot be processed is read, fails, and is read again, forever, because nothing marked it. The channel looks alive. The queue looks empty. Nothing moves, and every message behind the bad row waits for a row that will never succeed.

Most of the design here is about that.

Defined as DatabaseSource in internal/config.

KeyTypeRequiredDefault
drivertextyes—
dsntextyes—
querytextyes—
after_querytextno—
key_columntextno—
templatetextno—
columntextno—
poll_intervalduration, such as 30s or 5mno—
batch_sizewhole numberno100
max_attemptswhole numberno3
query_timeoutduration, such as 30s or 5mno30s

22.8.1 driver

Driver names the database.

See SupportedDrivers.

22.8.2 dsn

DSN is the connection string.

Prefer an environment variable reference over a literal, because this normally contains a password and channels are meant to live in git.

22.8.3 query

Query selects the rows waiting to be sent.

Required.

It must be a SELECT. A poll that quietly performed writes would run on a schedule with no record of what it changed.

22.8.4 after_query

AfterQuery marks a row as processed, and runs with the row's key bound to it.

Strongly recommended.

Without it, every poll re-reads every row the query still matches, so the query itself has to exclude processed rows some other way. If neither is true the channel resends the same rows forever, which is a duplicate storm rather than a stall and is arguably worse.

22.8.5 key_column

KeyColumn identifies a row for AfterQuery, quarantine and de-duplication.

Required when AfterQuery is set.

22.8.6 template

Template builds a message from a row.

Either this or Column is required.

Column references use ${column_name}. Missing columns are an error at load rather than an empty string in a clinical message.

22.8.7 batch_size

BatchSize caps the rows taken per poll.

Defaults to 100.

Bounded because an unbounded first poll against a table somebody has been filling for a year would read all of it into memory and then send all of it, which is how a migration floods a live receiver.

22.8.8 max_attempts

MaxAttempts is how many times a single row may fail before it is quarantined and the poll moves on.

Defaults to 3.

This is the setting that prevents the stall. Retrying forever is not resilience; it is one bad row stopping a hospital feed.

22.8.9 query_timeout

QueryTimeout bounds a single poll.

Defaults to 30s.

22.9 Direct Source

DirectSource receives Direct mail the HISP delivers: an SMTP listener for this address's mail.

Each message is decrypted, its signature checked against the anchors and its signer against From, and its content passed on: an XDM package's documents, else each attachment, else the text.

Defined as DirectSource in internal/config.

KeyTypeRequiredDefault
listentextno—
tls_certtextno—
tls_keytextno—
max_byteswhole numberno—
smtp_hosttextno—
usernametextno—
passwordtextno—
imapDirectIMAP (a block, omit to leave off)no—
xdrDirectXDR (a block, omit to leave off)no—

22.9.1 smtp_host

SMTPHost, Username and Password are the HISP's submission server, used to send a "processed" delivery notification (MDN) to the sender once a message has been taken in.

Without it, no notification is sent.

22.9.2 username

SMTPHost, Username and Password are the HISP's submission server, used to send a "processed" delivery notification (MDN) to the sender once a message has been taken in.

Without it, no notification is sent.

22.9.3 password

SMTPHost, Username and Password are the HISP's submission server, used to send a "processed" delivery notification (MDN) to the sender once a message has been taken in.

Without it, no notification is sent.

22.10 Email Source

EmailSource reads mail from a mailbox and turns attachments, bodies or whole messages into messages.

Defined as EmailSource in internal/config.

KeyTypeRequiredDefault
protocoltextno—
hosttextyes—
portwhole numberno—
securitytextno—
tlstlsconf.Settings (a block, omit to leave off)no—
usernametextyes—
passwordtextno—
oauthEmailOAuth (a block, omit to leave off)no—
mailboxtextno—
taketextno—
attachment_nameslist of textno—
allowed_senderslist of textno—
after_readtextno—
move_totextno—
error_foldertextno—
poll_intervalduration, such as 30s or 5mno—
max_message_sizewhole numberno—

22.10.1 password

Password may be ${ENV}; for Gmail an app password.

Ignored with oauth.

22.10.2 attachment_names

AttachmentNames limits which attachments are taken, by file name pattern (*.hl7).

Empty takes every attachment.

22.10.3 allowed_senders

AllowedSenders limits whose mail is read: addresses and domains.

The From header can be forged, so this keeps out accidents and casual mail, not an attacker.

22.10.4 move_to

MoveTo is the folder handled mail moves to; Processed by default.

Created when missing.

22.11 FTP Source

FTPSource collects files from an FTP or FTPS server.

Why FTP is still here

It is thirty years past its recommended replacement and a great many hospital systems still export to it: appliances whose firmware will never be updated, billing systems whose vendor charges for a change. Refusing to speak it does not make those systems go away, it means the integration engine cannot be used.

FTPS is the default. It is the same protocol with TLS, most surviving servers support it, and it is a much easier conversation with a security team than plain FTP.

Defined as FTPSource in internal/config.

KeyTypeRequiredDefault
hosttextyes—
usertextno—
passwordtextno—
securityftpconn.Securitynoexplicit
insecure_skip_verifytrue or falseno—
roottextno—
timeoutduration, such as 30s or 5mnothirty seconds

22.11.1 host

Host is the server, with an optional port.

Port 21 is assumed.

22.11.2 user

User and Password authenticate.

Both may be left out for an anonymous server.

Anonymous FTP is still how some public reference feeds are published, so an empty user is a legitimate configuration rather than an oversight and is not refused. On a server that does expect credentials, omitting them produces a login failure at connect time, which is reported against the channel rather than retried silently.

22.11.3 password

User and Password authenticate.

Both may be left out for an anonymous server.

Anonymous FTP is still how some public reference feeds are published, so an empty user is a legitimate configuration rather than an oversight and is not refused. On a server that does expect credentials, omitting them produces a login failure at connect time, which is reported against the channel rather than retried silently.

22.11.4 security

Security is explicit, implicit or none.

Defaults to explicit, which is FTPS negotiated with AUTH TLS on the ordinary port. "none" is plain FTP and has to be asked for by name.

22.11.5 root

Root is the directory every path is relative to, and what containment is enforced against.

The server has no notion of it: an FTP server will happily accept ../.. as a path. So this is what stops a move_to from writing wherever the account can reach.

22.11.6 timeout

Timeout bounds one operation, not the whole poll.

Defaults to thirty seconds.

Per operation because a poll that lists a directory and fetches twenty files does twenty-one operations, and a single budget for all of them would abort a perfectly healthy transfer of a large file.

22.12 File Source

FileSource reads files from a directory this server can see.

The connector a site tries first

An analyser writes results to a folder. A billing system drops a batch overnight. Radiology exports reports to a directory somebody mounted years ago. None of it involves a protocol, and an integration engine that cannot read a folder fails its evaluation on the first afternoon.

Defined as FileSource in internal/config.

KeyTypeRequiredDefault
roottextyes—
follow_symlinkstrue or falseno—

22.12.1 root

Root bounds every path this source touches.

Required.

Separate from Dir so that move_to and error_dir can be relative and still be contained. Given only Dir, an error_dir of "../failed" would be outside anything the source had been granted, and refusing it would be arbitrary because there would be nothing to refuse it against.

22.13 GCS Source

GCSSource picks objects up from a bucket prefix, as the s3 and azure_blob sources do.

Defined as GCSSource in internal/config.

KeyTypeRequiredDefault
prefixtextyes—
suffixtextno—
after_readtextno—
move_totextnoprocessed/
error_prefixtextnoerror/
poll_intervalduration, such as 30s or 5mno30s
max_object_sizewhole numberno16 MiB
framedtrue or falseno—

22.13.1 move_to

MoveTo is where a handled object goes when after_read is move.

Defaults to processed/.

22.13.2 error_prefix

ErrorPrefix is where an object that could not be handled goes.

Defaults to error/.

22.13.3 poll_interval

PollInterval is how often the prefix is listed.

Defaults to 30s.

22.13.4 max_object_size

MaxObjectSize leaves larger objects where they are.

Defaults to 16 MiB.

22.14 HTTP Source

HTTPSource accepts messages over HTTP.

A listener rather than a poller: something posts a message and gets an acknowledgement back, which is how the modern half of the hospital integrates when MLLP is not available to it.

Defined as HTTPSource in internal/config.

KeyTypeRequiredDefault
listentextyes—
pathtextno"/"
tlstlsconf.Settings (a block, omit to leave off)no—
tokentextno—
max_message_sizewhole numberno—
read_timeoutduration, such as 30s or 5mno—
ackAckno—

22.14.1 listen

Listen is the address to serve on.

Required.

22.14.2 path

Path is the URL path to accept messages on.

Defaults to "/".

22.14.3 token

Token, when set, is required in an Authorization: Bearer header.

There is no default and no anonymous mode toggle: an HTTP endpoint on a hospital network with no authentication at all is a decision that has to be visible in the file, so leaving this empty is reported as a warning at load.

22.14.4 max_message_size

MaxMessageSize bounds an inbound body.

Zero applies a default.

22.15 IBMMQ Source

IBMMQSource reads a queue.

Defined as IBMMQSource in internal/config.

KeyTypeRequiredDefault
poll_intervalduration, such as 30s or 5mno2s

22.15.1 poll_interval

PollInterval is the wait when the queue is empty, or after a message that could not be handled.

Defaults to 2s.

22.16 Java Script Source

JavaScriptSource configures a source that runs a script on a timer and feeds the returned messages into the channel.

This is the Mirth "JavaScript Reader" equivalent.

The script runs in a goja VM with the same sandbox as transformer scripts: msg is not available (there is no inbound message yet), but globalMap, configurationMap, logger, and DateUtil are present. The script must return a string (one message) or an array of strings (many). Returning nothing or an empty array means the poll produced no messages, which is normal.

Defined as JavaScriptSource in internal/config.

KeyTypeRequiredDefault
scripttextyes—
poll_intervalduration, such as 30s or 5mno—
timeoutduration, such as 30s or 5mno—

22.16.1 poll_interval

PollInterval is how often the script runs.

Zero means 5 seconds.

22.16.2 timeout

Timeout bounds a single script execution.

Zero means 30 seconds.

22.17 Kafka Source

KafkaSource reads messages from a Kafka topic.

Defined as KafkaSource in internal/config.

KeyTypeRequiredDefault
brokerslist of textyes—
topicslist of textyes—
grouptextyes—
from_beginningtrue or falseno—
commit_after_deliverytrue or false (a block, omit to leave off)no—
max_message_sizewhole numbernothe channel's limit
session_timeoutduration, such as 30s or 5mnoforty-five seconds
timeoutduration, such as 30s or 5mnothirty seconds
saslKafkaSASL (a block, omit to leave off)no—
tlstlsconf.Settings (a block, omit to leave off)no—

22.17.1 brokers

Brokers is the bootstrap server list.

Required.

A list rather than one address, because a single bootstrap broker is a single point of failure for starting up: the cluster survives losing it and this channel would not.

22.17.2 topics

Topics are the topics to read.

At least one is required.

22.17.3 group

Group is the consumer group.

Required.

Named rather than generated, and required rather than defaulted, because it is what remembers how far this channel has read. A generated group would start from the beginning - or from the end, losing everything - on every restart, and it would leave the old group behind holding committed offsets nobody reads. Requiring it makes the person name the thing that has to stay the same across restarts.

22.17.4 from_beginning

FromBeginning reads the topic from its start when the group has no committed offset.

Off by default, so a new channel pointed at a topic with two years of history does not replay two years of patient events into a live system on the day it is switched on. The first person to hit that would be doing it in production, because that is where the topic with the history is.

22.17.5 commit_after_delivery

CommitAfterDelivery commits the offset only once the message has been handled.

On by default, and the reason is the whole difference between losing a message and seeing it twice. Committing on read means a crash between the commit and the delivery loses the message silently, and nothing anywhere records that it existed. Committing after means a crash in the same window redelivers it, which is visible and which HL7 receivers are built to tolerate - a duplicate A08 is a nuisance and a missing lab result is a patient safety event.

22.17.6 max_message_size

MaxMessageSize bounds one record.

Defaults to the channel's limit.

22.17.7 session_timeout

SessionTimeout is how long the group coordinator waits before assuming this consumer is gone and reassigning its partitions.

Defaults to forty-five seconds.

22.17.8 timeout

Timeout bounds connecting and metadata requests.

Defaults to thirty seconds.

22.18 MQTT Source

MQTTSource subscribes to a topic filter.

Defined as MQTTSource in internal/config.

KeyTypeRequiredDefault
topictextyes—
reconnectduration, such as 30s or 5mno5s

22.18.1 reconnect

Reconnect is the wait after a lost connection, or after a message that could not be handled.

Defaults to 5s.

22.19 Pub Sub Source

PubSubSource pulls from a Pub/Sub subscription.

Defined as PubSubSource in internal/config.

KeyTypeRequiredDefault
subscriptiontextyes—
max_messageswhole numberno10
reconnectduration, such as 30s or 5mno5s

22.19.1 max_messages

MaxMessages is how many are pulled at once.

They are still handled one at a time, in order. Defaults to 10.

22.19.2 reconnect

Reconnect is the wait after an error.

Defaults to 5s.

22.20 S3 Source

S3Source picks objects up from a bucket.

Defined as S3Source in internal/config.

KeyTypeRequiredDefault
buckettextyes—
path_styletrue or falseno—
prefixtextno—
suffixtextno—
after_readtextno—
move_totextnoprocessed/
error_prefixtextnoerror/
poll_intervalduration, such as 30s or 5mno30s
max_object_sizewhole numberno16 MiB
framedtrue or falseno—

22.20.1 prefix

Prefix limits which objects are read, e.g. inbound/.

Empty reads the whole bucket, which is refused with after_read: move unless move_to is outside it - otherwise moved objects would be read again.

22.20.2 after_read

AfterRead is move (the default) or delete.

There is no leave: a bucket has no file a poller can remember having seen across a restart, so leaving objects means reading them all again every time Perfuse starts.

22.20.3 move_to

MoveTo is the prefix a handled object moves to.

Defaults to processed/.

22.20.4 error_prefix

ErrorPrefix is where an object that could not be handled moves, so one bad file does not block the rest.

Defaults to error/.

22.20.5 poll_interval

PollInterval between listings.

Defaults to 30s.

22.20.6 max_object_size

MaxObjectSize refuses anything larger.

Defaults to 16 MiB.

22.20.7 framed

Framed reads several MLLP-framed messages from one object.

Otherwise an object is one message.

22.21 SFTP Source

SFTPSource collects messages from a directory on an SFTP server.

Extremely common and rarely spoken about. A laboratory or a radiology system writes a file every few minutes, an SFTP server holds it, and something has to come and get it. Half the feeds described as "we send you HL7" are this.

The part worth care is not the transfer. It is that a file being written to and a file finished being written to look identical over SFTP, so a poll that is even slightly too eager collects half a message. That produces a truncated but often parseable HL7 message, which is the worst outcome available: it is accepted, acknowledged, stored and delivered, and the missing half is never mentioned again.

Defined as SFTPSource in internal/config.

KeyTypeRequiredDefault
hosttextyes22
usertextyes—
passwordtextno—
key_filetextno—
key_passphrasetextno—
known_hosts_filetextno—
insecure_skip_host_key_checktrue or falseno—
dirtextyes—
patterntextnoeverything
poll_intervalduration, such as 30s or 5mno—
after_readtextno—
move_totextno—
error_dirtextno—
stable_forduration, such as 30s or 5mno5s
framedtrue or falseno—
max_file_sizewhole numberno64MB
timeoutduration, such as 30s or 5mno60s

22.21.1 host

Host is the server, with an optional port.

Defaults to 22.

22.21.2 password

Password authenticates with a password.

Prefer KeyFile.

22.21.3 key_file

KeyFile is a private key.

The better choice, and the one hospital security teams ask for.

22.21.4 known_hosts_file

KnownHostsFile verifies the server's identity.

Required unless InsecureSkipHostKeyCheck is set. Without it there is nothing to distinguish the real server from anything that answers on that address, and the credentials are handed over before anybody notices.

22.21.5 insecure_skip_host_key_check

InsecureSkipHostKeyCheck accepts any host key.

Exists because refusing outright sends people to a shell script with StrictHostKeyChecking=no, which is worse in every way including auditability. It warns loudly and it is never the default.

22.21.6 pattern

Pattern selects files by glob.

Defaults to everything.

22.21.7 move_to

MoveTo is the remote directory files are moved into after reading.

Required when AfterRead is "move".

22.21.8 stable_for

StableFor is how long a file's size and modification time must be unchanged before it is read.

Defaults to 5s.

This is the setting that stops half a message being collected. Zero means reading whatever is there, which works right up until a file is large enough or a network slow enough that writing takes longer than a poll.

22.21.9 framed

Framed says the file contains MLLP-framed messages.

When false, the file is split on MSH boundaries.

22.21.10 max_file_size

MaxFileSize refuses a file larger than this many bytes.

Defaults to 64MB.

22.21.11 timeout

Timeout bounds a single connection.

Defaults to 60s.

22.22 SMB Source

SMBSource collects files from a Windows file share.

Defined as SMBSource in internal/config.

KeyTypeRequiredDefault
hosttextyes—
sharetextyes—
usertextno—
passwordtextno—
domaintextno—
roottextno—
timeoutduration, such as 30s or 5mnothirty seconds

22.22.1 host

Host is the server, with an optional port.

Port 445 is assumed.

22.22.2 share

Share is the share name only: the "data" in \\server\data.

A path here is refused rather than trimmed.

22.22.3 user

User and Password are the Windows account.

Required in practice.

A share reachable with no credentials at all is rare enough on a domain that an empty user here is far more likely to be a mistake than a guest share - but it is accepted, because refusing it would block the one arrangement where it is correct. Where the account belongs to a domain, set Domain as well: authenticating a domain account without it fails in a way that reads like a wrong password.

22.22.4 password

User and Password are the Windows account.

Required in practice.

A share reachable with no credentials at all is rare enough on a domain that an empty user here is far more likely to be a mistake than a guest share - but it is accepted, because refusing it would block the one arrangement where it is correct. Where the account belongs to a domain, set Domain as well: authenticating a domain account without it fails in a way that reads like a wrong password.

22.22.5 domain

Domain is the Windows domain or workgroup.

Empty is usually right for a local account.

22.22.6 timeout

Timeout bounds one operation.

Defaults to thirty seconds.

Worth raising on a share reached across a site link. An SMB operation against a distant server is slower than the same operation locally by enough that a default tuned for a LAN produces timeouts that look like the share being unavailable.

22.23 SOAP Source

SOAPSource accepts messages wrapped in a SOAP envelope.

The counterpart to the SOAP destination, and the rarer direction: most hospital integration has Perfuse calling a service rather than being one. It exists because Mirth's Web Service Listener does, and a site whose sending system only speaks SOAP cannot migrate without it.

Deliberately not a general web services framework. It accepts an envelope, takes the message out of it, and answers with an envelope. There is no WS-Security, no MTOM, no WS-Addressing, and no generated bindings - the same omissions as the destination, for the same reason: a half-implementation of WS-Security is worse than none.

Defined as SOAPSource in internal/config.

KeyTypeRequiredDefault
listentextyes—
profiletextno—
pathtextno"/"
versiontextno1
elementtextno—
base64true or falseno—
response_elementtextno"AckResponse"
response_namespacetextno—
wsdltextno—
tlstlsconf.Settings (a block, omit to leave off)no—
tokentextno—
usernametextno—
passwordtextno—
max_message_sizewhole numberno—
read_timeoutduration, such as 30s or 5mno—
ackAckno—
fault_on_naktrue or falseno—

22.23.1 listen

Listen is the address to serve on.

Required.

22.23.2 profile

Profile names a standard service this endpoint implements.

Empty accepts any envelope as configured below.

"cdc-iis" answers as an immunization registry does, on the CDC interface (urn:cdc:iisb:2011): connectivityTest is echoed, submitSingleMessage's hl7Message is the message and the channel's acknowledgement goes back in return, the WSDL is served at ?wsdl, and username and password, when set, are checked against the ones in the body (where the WSDL puts them) rather than against HTTP authentication. For a registry's test double, or for a site that receives submissions the way registries do.

22.23.3 path

Path is the URL path to accept envelopes on.

Defaults to "/".

22.23.4 version

Version is the SOAP version to answer in, "1.1" or "1.2".

Defaults to 1.1.

Requests are accepted in either version regardless, because a sender that gets this wrong is common and rejecting it teaches nobody anything. The setting controls the reply, where getting it wrong means the sender cannot read the answer.

22.23.5 element

Element names the element inside the SOAP Body holding the message.

Empty means the whole body text.

Named rather than guessed because a body may carry several elements - a message alongside a sender identifier and a timestamp - and taking the whole body would hand the engine an XML fragment where it expected HL7.

22.23.6 base64

Base64 decodes the element's content before treating it as a message.

Common in real services, because HL7 carries carriage returns and callers who did not think about that end up with XML that mangles them. If the content decodes as base64 but was not meant to be, the result is not a message and the engine says so - which is why this is explicit rather than sniffed.

22.23.7 response_element

ResponseElement names the element wrapping the acknowledgement in the reply.

Defaults to "AckResponse".

22.23.8 wsdl

WSDL is a file served at the listen path with ?wsdl.

Empty means none is served.

A file rather than something generated. A generated WSDL would describe what this endpoint accepts, which is almost never what the sending system was built against - and a WSDL that is nearly right costs more time than no WSDL at all, because it looks authoritative.

22.23.9 username

Username and Password, when set, require HTTP basic authentication.

Offered alongside a token because SOAP callers overwhelmingly expect basic authentication, and a site whose sending system cannot be changed needs the option it already has.

22.23.10 password

Username and Password, when set, require HTTP basic authentication.

Offered alongside a token because SOAP callers overwhelmingly expect basic authentication, and a site whose sending system cannot be changed needs the option it already has.

22.23.11 max_message_size

MaxMessageSize bounds an inbound body.

Zero applies a default.

22.23.12 fault_on_nak

FaultOnNak answers a negative acknowledgement with a SOAP fault instead of a normal response carrying the NAK.

Off by default, which is the right default and worth explaining. HL7 already has a way to say "I received this and rejected it", and that is a NAK - a successful exchange reporting a rejected message. Turning the same thing into a transport fault tells the sender its request was malformed, which it was not, and many clients respond by retrying forever.

It exists because some callers only look at the HTTP status and would otherwise treat a rejection as success.

22.24 SQS Source

SQSSource reads from an SQS queue.

A message is deleted only after the channel has handled it - delivered, or filtered, or failed in a way the channel records. Until then it stays on the queue, invisible for visibility_timeout, and comes back if Perfuse stops. So a message is never lost; the price is that one can arrive twice, which SQS itself already allows.

Defined as SQSSource in internal/config.

KeyTypeRequiredDefault
queue_urltextyes—
wait_secondswhole numberno20: an empty queue costs one request every twenty seconds
max_messageswhole numberno10
visibility_timeoutduration, such as 30s or 5mnothe queue's own setting

22.24.1 visibility_timeout

VisibilityTimeout is how long a received message stays hidden while it is handled.

Defaults to the queue's own setting.

22.25 Serial Source

SerialSource reads messages from a serial port.

Why a serial connector in 2026

Because the equipment is still there. A blood gas analyser bought in 2009 with a working sensor and a 25-pin socket, a bedside monitor, a scale in a dialysis unit, an older anaesthesia machine. None of them will ever get an Ethernet port, several of them cost six figures, and the alternative to reading them is somebody typing results into a form.

Mirth has a serial connector. Perfuse had nothing, and a site with one of these had no route in at all.

Framing is shared with the TCP connector

The framings are the same wherever the bytes come from: an analyser that speaks STX/ETX over a socket speaks STX/ETX over a cable. So this reuses the TCP framing block rather than defining its own, which also means a device moved from a serial cable to a serial-to-Ethernet adapter keeps its framing configuration.

What is different about a serial line

There is no connection. A socket tells you when the peer went away; a cable does not. An unplugged cable, a device switched off, and a device with nothing to say are indistinguishable - all three are silence. That is why quiet_after exists: without something to say "this line has been silent for longer than it should be", a dead feed looks exactly like a quiet night.

Defined as SerialSource in internal/config.

KeyTypeRequiredDefault
porttextyes—
baudwhole numberyes—
data_bitswhole numberno8
paritytextnonone
stop_bitstextno1
flow_controltextnonone
max_message_sizewhole numberno—
quiet_afterduration, such as 30s or 5mno—
replytextno—
reply_texttextno—
reopen_afterduration, such as 30s or 5mno—

22.25.1 baud

Baud is the speed.

Required, because guessing produces bytes rather than an error.

A wrong baud rate does not fail. It delivers plausible-looking rubbish: framing bytes appear at random, occasional runs decode as printable characters, and a lenient parser accepts some of it. There is deliberately no default.

22.25.2 parity

Parity is none, odd, even, mark or space.

Defaults to none.

22.25.3 stop_bits

StopBits is 1, 1.5 or 2, written as "1", "1.5" or "2".

Defaults to 1.

22.25.4 flow_control

FlowControl is none, hardware or software.

Defaults to none.

Worth setting correctly rather than leaving. With hardware flow control expected and not configured, a device stops sending partway through a long message and the result is a truncated message rather than a failure.

22.25.5 quiet_after

QuietAfter logs a warning when nothing has been received for this long.

The only way to notice a dead serial feed. An unplugged cable and a quiet night are the same silence, so somebody has to say how long is too long for this particular device.

22.25.6 reopen_after

ReopenAfter is how long to wait before reopening a port that failed.

A USB serial adapter unplugged and plugged back in comes back as the same device path but a different kernel handle, so the old one returns errors forever. Reopening is the only recovery, and doing it in a tight loop fills the log.

22.26 Source

Source is where a channel receives messages.

Defined as Source in internal/config.

KeyTypeRequiredDefault
typeSourceTypeyes—
listentextyes—
max_message_sizewhole numberno—
idle_timeoutduration, such as 30s or 5mno—
max_connectionswhole numberno—
tlstlsconf.Settings (a block, omit to leave off)no—
httpHTTPSource (a block, omit to leave off)no—
soapSOAPSource (a block, omit to leave off)no—
dicomwebDICOMwebSource (a block, omit to leave off)no—
directDirectSource (a block, omit to leave off)no—
dicomDICOMSource (a block, omit to leave off)no—
fileFileSource (a block, omit to leave off)no—
tcpTCPSource (a block, omit to leave off)no—
ftpFTPSource (a block, omit to leave off)no—
smbSMBSource (a block, omit to leave off)no—
webdavWebDAVSource (a block, omit to leave off)no—
serialSerialSource (a block, omit to leave off)no—
dicom_queryDICOMQuerySource (a block, omit to leave off)no—
brokerBrokerSource (a block, omit to leave off)no—
kafkaKafkaSource (a block, omit to leave off)no—
sqsSQSSource (a block, omit to leave off)no—
s3S3Source (a block, omit to leave off)no—
amqpAMQPSource (a block, omit to leave off)no—
azure_blobAzureBlobSource (a block, omit to leave off)no—
emailEmailSource (a block, omit to leave off)no—
as2AS2Source (a block, omit to leave off)no—
pgpPGPReceive (a block, omit to leave off)no—
mqttMQTTSource (a block, omit to leave off)no—
ibm_mqIBMMQSource (a block, omit to leave off)no—
pubsubPubSubSource (a block, omit to leave off)no—
gcsGCSSource (a block, omit to leave off)no—
databaseDatabaseSource (a block, omit to leave off)no—
sftpSFTPSource (a block, omit to leave off)no—
javascriptJavaScriptSource (a block, omit to leave off)no—
ackAckno—

22.26.1 type

Type is the transport.

Only mllp is implemented.

22.26.2 max_message_size

MaxMessageSize bounds one inbound message.

Zero uses the transport default.

22.26.3 idle_timeout

IdleTimeout closes a connection that has been silent this long.

Zero means never, which is usually right: a hospital feed holds a connection open for months and goes quiet overnight.

22.26.4 max_connections

MaxConnections bounds concurrent connections.

Zero means unlimited.

22.27 TCP Source

TCPSource listens on a socket for messages that are not necessarily HL7.

Why this is separate from an mllp source

MLLP is one framing and Perfuse has always spoken it. A great deal of equipment does not: a laboratory analyser, a scale, a bedside monitor, an older billing system. Each tends to have its own convention, and Mirth's TCP connector covers all of them, so a site with a socket feed that was not HL7 could not use Perfuse at all.

Defined as TCPSource in internal/config.

KeyTypeRequiredDefault
listentextyes—
max_message_sizewhole numberno—
idle_timeoutduration, such as 30s or 5mno—
max_connectionswhole numberno—
replytextno—
reply_texttextno—
tlstlsconf.Settings (a block, omit to leave off)no—

22.27.1 idle_timeout

IdleTimeout closes a connection that has been silent this long.

Zero means never, which is usually right: a device feed holds a connection open for months and goes quiet overnight.

22.27.2 max_connections

MaxConnections bounds concurrent connections.

Zero means unlimited.

22.27.3 reply

Reply is what to send back after each message: nothing, ack, or a fixed string.

Most raw feeds expect nothing. Some expect a single ACK byte. A device that expects a reply and does not get one usually retries the same message forever, so this is worth being explicit about.

22.28 Web DAV Source

WebDAVSource collects files from a WebDAV collection.

Where this turns up: document management systems, SharePoint, Nextcloud, and vendor portals that expose a drop folder over HTTPS because it is the only outbound port their customers' firewalls allow.

Defined as WebDAVSource in internal/config.

KeyTypeRequiredDefault
urltextyes—
usertextno—
passwordtextno—
insecure_skip_verifytrue or falseno—
timeoutduration, such as 30s or 5mnosixty seconds

22.28.1 user

User and Password authenticate with HTTP basic authentication.

Basic authentication sends the password in a header that is only protected by TLS, so these belong with an https URL. Over http they are readable by anything on the path, and no warning from the server will tell you so.

22.28.2 password

User and Password authenticate with HTTP basic authentication.

Basic authentication sends the password in a header that is only protected by TLS, so these belong with an https URL. Over http they are readable by anything on the path, and no warning from the server will tell you so.

22.28.3 timeout

Timeout bounds one request.

Defaults to sixty seconds.

Longer than the FTP and SMB defaults deliberately. A WebDAV collection listing is a single PROPFIND that the server may spend a long time assembling for a large directory, and there is no partial result to fall back on.

23 Destination Reference

Every way a channel can send messages, and every key each one accepts. A channel may have any number of destinations, and they are independent: one failing does not stop the others.

23.1 AMQP Destination

AMQPDestination sends to a queue or topic, and reports success only once the broker has accepted the message.

Defined as AMQPDestination in internal/config.

KeyTypeRequiredDefault
content_typetextnothe channel's data type

23.1.1 content_type

ContentType is sent with each message.

Defaults to the channel's data type.

23.2 AS2 Destination

AS2Destination sends each message as AS2: signed with our key, encrypted to the partner's certificate, posted to the partner's URL, and, by default, delivered only once the partner's signed receipt names the same digest.

Defined as AS2Destination in internal/config.

KeyTypeRequiredDefault
urltextyes—
fromtextyes—
totextyes—
cert_filetextno—
key_filetextno—
partner_cert_filetextno—
unsignedtrue or falseno—
unencryptedtrue or falseno—
compresstrue or falseno—
receipttextno—
unsigned_receipttrue or falseno—
receipt_urltextno—
content_typetextno—
file_nametextno—
subjecttextno—
usernametextno—
passwordtextno—
tlstlsconf.Settings (a block, omit to leave off)no—

23.2.1 compress

Compress compresses the payload (zlib, RFC 5402) before signing and encryption.

Every AS2 receiver that announces version 1.1 or later reads it; ask the partner before turning it on.

23.2.2 unsigned_receipt

UnsignedReceipt asks for a receipt that is not signed.

Off: a signed one is asked for, and an unsigned one refused.

23.2.3 content_type

ContentType is the payload's MIME type.

Default by the channel's data type: application/edi-x12, application/edifact, application/hl7-v2, otherwise application/octet-stream.

23.3 Azure Blob Destination

AzureBlobDestination writes each message as a block blob.

Defined as AzureBlobDestination in internal/config.

KeyTypeRequiredDefault
blobtextno${date}/${channel}/${control_id}
tiertextno—
content_typetextno—

23.3.1 blob

Blob names each blob, with the S3 destination's placeholders.

Defaults to ${date}/${channel}/${control_id}.hl7.

23.3.2 tier

Tier is Hot, Cool, Cold or Archive.

Archive is offline: reading a blob back means rehydrating it, which takes hours.

23.4 Broker Destination

BrokerDestination publishes messages to a message broker.

Mirth's JMS Writer.

Defined as BrokerDestination in internal/config.

KeyTypeRequiredDefault
addrtextyes—
destinationtextyes—
logintextno—
passcodetextno—
hosttextnothe address's host
content_typetextnotext/plain for HL7
headersmapping of text to textno—
persistenttrue or false (a block, omit to leave off)notrue
timeoutduration, such as 30s or 5mno—
tlstlsconf.Settings (a block, omit to leave off)no—

23.4.1 addr

Addr is the broker's host and port.

Required.

23.4.2 destination

Destination is the queue or topic to publish to.

Required.

23.4.3 login

Login and Passcode authenticate.

Most brokers refuse an anonymous connection.

23.4.4 passcode

Login and Passcode authenticate.

Most brokers refuse an anonymous connection.

23.4.5 host

Host is the virtual host.

Defaults to the address's host.

Worth setting explicitly on a broker that hosts several virtual hosts, where the default lands the publish on whichever one answers to the server's own name - a queue that exists, accepts the message, and is read by nobody.

23.4.6 content_type

ContentType is sent with each message.

Defaults to text/plain for HL7.

23.4.7 headers

Headers are sent with every message.

Configuration only, never message content. A header taken from a field would put a patient identifier into broker metadata that is logged, indexed and visible to every other application on the queue.

23.4.8 persistent

Persistent asks the broker to survive a restart.

Defaults to true.

True because a non-persistent message is lost when the broker restarts, and that is not a reasonable default for clinical data. False exists for a genuinely transient feed - a monitoring heartbeat - and has to be chosen.

23.5 CDA Destination

CDADestination configures a cda destination: it takes the clinical document carried inside an HL7 v2 message, converts it, and writes or posts the result.

This exists because a document almost never arrives on its own. It arrives base64-encoded inside an MDM^T02, and every engine that treats that message as an opaque blob leaves the document unread. Owning both ends of that is the point.

Defined as CDADestination in internal/config.

KeyTypeRequiredDefault
urltextno—
dirtextno—
writetextno—
versiontextnoR5
identifier_systemsmapping of text to textno—
require_agreementtrue or falseno—
on_no_documenttextno—
headersmapping of text to textno—
bearer_tokentextno—

23.5.1 url

URL is a FHIR server to post the converted bundle to.

Leave it empty to write to Dir instead. One of the two is required.

23.5.2 dir

Dir writes the output to files instead of posting it.

Useful for a landing zone another process picks up, and for seeing what conversion produces before pointing it at a live server.

23.5.3 write

Write selects what is written or posted:

fhir the converted transaction bundle (default) document the original document bytes, unchanged both both, with the document alongside the bundle transfer a PACIO Transitions of Care transfer summary: one FHIR R4 document Bundle for the place the patient goes next (a nursing home, a rehabilitation unit, home health), posted to url/Bundle or written to dir

"document" is worth having on its own: a site that wants its CDAs on disk for a records team does not need the FHIR conversion at all.

23.5.4 identifier_systems

IdentifierSystems maps an OID root from the document to a URI, so an MRN becomes namespaced.

A CDA identifies people by OID and FHIR by URI, and nothing can derive one from the other.

23.5.5 require_agreement

RequireAgreement refuses a document whose narrative and coded entries contradict each other.

Off by default, and deliberately so. The check is valuable but it is a judgement about a document somebody else authored, and a channel that silently drops real clinical documents because a sender's C-CDA generator is sloppy is worse than one that passes them through with a warning. A site that has looked at its own findings and decided it wants the gate can turn it on.

23.5.6 on_no_document

OnNoDocument decides what happens when the message carries no clinical document:

skip deliver nothing and report success (default) fail treat it as a delivery failure

The default is skip, because a channel carrying a mixed ADT and MDM feed would otherwise fail every admission. A channel dedicated to documents should set fail, since a document message with no document in it is a real problem at the sender.

23.5.7 bearer_token

BearerToken is sent as an Authorization header.

Prefer an environment variable reference over a literal in a file that goes into git.

23.6 Channel Destination

ChannelDestination routes to another channel in the same server.

Defined as ChannelDestination in internal/config.

KeyTypeRequiredDefault
nametextyes—

23.7 DICOM Destination

DICOMDestination sends imaging objects as a C-STORE service class user.

Mirth calls this the DICOM Sender.

Defined as DICOMDestination in internal/config.

KeyTypeRequiredDefault
addresstextyes—
called_aetextno—
calling_aetextno—
transfer_syntaxeslist of textno—
timeoutduration, such as 30s or 5mno—
tlstlsconf.Settings (a block, omit to leave off)no—

23.7.1 address

Address is the remote host and port.

Required.

23.7.2 called_ae

CalledAE is what the remote calls itself, and CallingAE what this end calls itself.

Both matter. Sites configure a PACS to accept one specific calling title and reject everything else, so a mismatch is the commonest reason a first connection fails - and the failure looks like a network problem.

23.7.3 calling_ae

CalledAE is what the remote calls itself, and CallingAE what this end calls itself.

Both matter. Sites configure a PACS to accept one specific calling title and reject everything else, so a mismatch is the commonest reason a first connection fails - and the failure looks like a network problem.

23.7.4 transfer_syntaxes

TransferSyntaxes restricts what to offer, in preference order.

Empty offers the object's own encoding and then implicit VR little endian as a fallback.

23.7.5 timeout

Timeout bounds one store operation.

Zero applies a default.

23.8 DICO Mweb Destination

DICOMwebDestination stores each object with STOW-RS.

Defined as DICOMwebDestination in internal/config.

KeyTypeRequiredDefault
urltextyes—
tokentextno—
usernametextno—
passwordtextno—
headersmapping of text to textno—
tlstlsconf.Settings (a block, omit to leave off)no—
timeoutduration, such as 30s or 5mno—

23.9 Database Destination

DatabaseDestination writes each message to a database.

Defined as DatabaseDestination in internal/config.

KeyTypeRequiredDefault
drivertextyes—
dsntextyes—
statementtextyes—
paramslist of textno—
timeoutduration, such as 30s or 5mno30s
max_open_connswhole numberno4

23.9.1 driver

Driver names the database.

See SupportedDrivers.

23.9.2 statement

Statement is executed once per message, with Params bound to it in order.

Placeholders are the driver's own: $1 for postgres, ? for mysql and sqlite, @p1 for sqlserver. Perfuse does not rewrite them, because a translation layer that got a placeholder wrong would bind a patient's name to the wrong column and the result would look like valid data.

23.9.3 params

Params are HL7 paths or ${...} expressions, bound to the statement in order.

Values are always bound as parameters and never interpolated into the SQL. That is not only about injection: O'Brien is a common name, and a concatenated query breaks on the apostrophe.

23.9.4 timeout

Timeout bounds a single statement.

Defaults to 30s.

23.9.5 max_open_conns

MaxOpenConns caps the pool.

Defaults to 4.

23.10 Destination

Destination is one place a channel sends messages.

Defined as Destination in internal/config.

KeyTypeRequiredDefault
nametextyes—
typeDestinationTypeyes—
enabledtrue or false (a block, omit to leave off)no—
filtertextno—
addresstextno—
dirtextno—
file_nametextno—
temp_suffixtextno"
appendtrue or falseno—
retain_hourswhole numberno—
tlstlsconf.Settings (a block, omit to leave off)no—
fhirFHIRDestination (a block, omit to leave off)no—
cdaCDADestination (a block, omit to leave off)no—
httpHTTPDestination (a block, omit to leave off)no—
databaseDatabaseDestination (a block, omit to leave off)no—
sftpSFTPDestination (a block, omit to leave off)no—
tcpTCPDest (a block, omit to leave off)no—
smtpSMTPDestination (a block, omit to leave off)no—
channelChannelDestination (a block, omit to leave off)no—
s3S3Destination (a block, omit to leave off)no—
ftpFTPDestination (a block, omit to leave off)no—
documentDocumentDestination (a block, omit to leave off)no—
soapSOAPDestination (a block, omit to leave off)no—
dicomwebDICOMwebDestination (a block, omit to leave off)no—
xdsXDSDestination (a block, omit to leave off)no—
directDirectDestination (a block, omit to leave off)no—
as2AS2Destination (a block, omit to leave off)no—
mpiMPIDestination (a block, omit to leave off)no—
tefcaTEFCADestination (a block, omit to leave off)no—
pasPASDestination (a block, omit to leave off)no—
x12_astextno—
dicomDICOMDestination (a block, omit to leave off)no—
javascriptJavaScriptDestination (a block, omit to leave off)no—
brokerBrokerDestination (a block, omit to leave off)no—
kafkaKafkaDestination (a block, omit to leave off)no—
sqsSQSDestination (a block, omit to leave off)no—
snsSNSDestination (a block, omit to leave off)no—
amqpAMQPDestination (a block, omit to leave off)no—
azure_blobAzureBlobDestination (a block, omit to leave off)no—
mqttMQTTDestination (a block, omit to leave off)no—
ibm_mqIBMMQDestination (a block, omit to leave off)no—
pubsubPubSubDestination (a block, omit to leave off)no—
gcsGCSDestination (a block, omit to leave off)no—
elrELRDestination (a block, omit to leave off)no—
pgpPGPSend (a block, omit to leave off)no—
response_transformertextno—
timeoutduration, such as 30s or 5mno—
retryRetryno—
queueQueueConfig (a block, omit to leave off)no—

23.10.1 name

Name identifies the destination in logs and metrics.

Required within a channel.

23.10.2 file_name

FileName, for a file destination, templates one file per message (${timestamp}, ${date}, ${control_id}, ${message_type}, ${destination}).

Empty keeps the default: one file per day and message type, appended to.

23.10.3 temp_suffix

TempSuffix is the name a per-message file carries while it is written, removed by a rename once it is complete, so whatever collects the files never reads half of one.

Defaults to ".part".

23.10.4 retain_hours

RetainHours deletes files in Dir older than this.

Zero keeps them forever.

23.10.5 x12_as

X12As is xml for a destination on an X12 channel that receives the interchange as the XML Mirth Connect and Open Integration Engine write for X12, for a receiver built against what Mirth sent it.

Empty or x12 sends X12.

23.10.6 elr

ELR makes this a public health lab reporting destination: each lab result (ORU^R01) is reshaped into an ELR 2.5.1 message holding only the orders with a reportable result, and that is what is delivered; a message with nothing reportable is not sent at all.

Applies to destinations that deliver an HL7 v2 message as it is (mllp, http, file, sftp and the rest), on an hl7 channel.

23.10.7 response_transformer

ResponseTransformer inspects what the receiver said back and may mark the delivery failed.

It exists because "did this arrive" is often not a question the transport can answer: an MLLP receiver returns an application acknowledgement whose meaning is in its text, and an HTTP receiver returns 200 with an error document. In both cases the transport succeeded and the message did not arrive.

Only meaningful on a destination that receives a reply. On one that cannot, it is refused at load rather than left never running.

23.10.8 queue

Queue makes delivery durable: a message that cannot be delivered now is kept on disk and retried until it can be.

Off by default.

23.11 Direct Destination

DirectDestination sends each message as Direct: signed as this address, encrypted to each recipient's certificate, and submitted to the HISP by SMTP.

Defined as DirectDestination in internal/config.

KeyTypeRequiredDefault
tolist of textyes—
subjecttextno—
texttextno—
attach_nametextno—
xdmtrue or falseno—
xdm_source_idtextno—
recipient_certs_dirtextno—
dnstextno—
ldaptextno—
smtp_hosttextyes—
usernametextno—
passwordtextno—
request_mdntrue or falseno—
timeoutduration, such as 30s or 5mno—

23.11.1 to

To are the recipients' Direct addresses.

Field references are substituted, so a recipient can come from the message.

23.11.2 recipient_certs_dir

RecipientCertsDir holds recipient certificates (PEM, one file per address or domain, address.pem or domain.pem) found in advance; empty reads the server's Direct address book (the Direct screen).

Recipients not there are looked up in DNS (CERT records). Direct does not send to a recipient whose certificate cannot be found.

23.11.3 ldap

LDAP is the directory a recipient's certificate is looked up in when DNS has none (host:port, port 389): the second way the Direct certificate discovery guide allows.

Empty finds the recipient domain's directory by its DNS SRV record (_ldap._tcp), as the guide says; "off" never asks a directory.

23.12 Document Destination

DocumentDestination renders each message into a document.

Defined as DocumentDestination in internal/config.

KeyTypeRequiredDefault
dirtextyes—
formatDocumentFormatnopdf
templatetextyes—
titletextno—
file_nametextno—
font_sizenumberno10
landscapetrue or falseno—
temp_suffixtextno"
timeoutduration, such as 30s or 5mno30s

23.12.1 format

Format is pdf or text.

Defaults to pdf.

23.12.2 template

Template is the document body, with ${PID-5.1} style references to message paths.

Required. There is no sensible default: a document with no template would either be the raw message, which nobody wants printed, or empty.

23.12.3 font_size

FontSize in points.

Defaults to 10, which fits 80 characters across A4.

23.12.4 temp_suffix

TempSuffix is appended while writing and removed by a rename.

Defaults to ".part".

The same courtesy as every other file destination, and it matters more here: a print watcher picking up a half-written PDF produces a page of nothing, and nobody investigates a blank page.

23.12.5 timeout

Timeout bounds one write.

Defaults to 30s.

23.13 ECR Destination

ECRDestination configures electronic case reporting.

Defined as ECRDestination in internal/config.

KeyTypeRequiredDefault
rctctextno—
sourcetextyes—
facilityECRFacilityyes—

23.13.1 rctc

RCTC is a FHIR ValueSet, or a Bundle of them such as the eRSD, holding the trigger codes.

Without it Perfuse's short built-in sample is used, which is for testing: agencies expect the RCTC.

23.14 ELR Destination

ELRDestination configures electronic laboratory reporting.

Defined as ELRDestination in internal/config.

KeyTypeRequiredDefault
configtextyes—
rctctextno—

23.14.1 config

Config is the ELR file perfuse elr -config reads: the sending and receiving applications and facilities by OID, the processing mode, and what fills the gaps a lab feed leaves (ordering facility, performing lab, assigning authorities).

A relative path is read from the channel file's directory.

23.14.2 rctc

RCTC is a FHIR ValueSet, or a Bundle of them such as the eRSD, holding the trigger codes.

Without it Perfuse's short built-in sample is used, which is for testing: agencies expect the RCTC. Relative to the channel file, as Config.

23.15 FHIR Destination

FHIRDestination configures conversion to FHIR and delivery to a FHIR server.

Defined as FHIRDestination in internal/config.

KeyTypeRequiredDefault
urltextyes—
versiontextnoR5
timezonetextno—
default_identifier_systemtextno—
identifier_systemsmapping of text to textno—
claim_us_coretrue or falseno—
validate_before_sendtrue or false (a block, omit to leave off)notrue: sending a resource a server will reject wastes a retry budget and hides the real problem behind a transport error
reject_on_warningtrue or falseno—
headersmapping of text to textno—
bearer_tokentextno—
authFHIRAuth (a block, omit to leave off)no—
ecrECRDestination (a block, omit to leave off)no—
bundle_scripttextno—

23.15.1 url

URL is the base URL of the FHIR server, for example "https://fhir.example.org/fhir".

The bundle is posted to it directly.

23.15.2 version

Version is the FHIR release to produce: R4, R4B or R5. Defaults to R5, the latest published release.

A deployment talking to an EHR almost always wants R4 and should say so, because silently downgrading would hide a real interoperability decision.

23.15.3 timezone

Timezone is applied to HL7 v2 timestamps that carry no offset. v2 permits a bare local time and FHIR does not, so something has to supply one.

When unset, the offset the sender put on MSH-7 is used, since v2 presumes a bare time is the sender's local time; a message whose MSH-7 has no offset either falls back to UTC.

23.15.4 default_identifier_system

DefaultIdentifierSystem namespaces identifiers whose assigning authority has no configured URI.

Without a system, an MRN is ambiguous between facilities.

23.15.5 claim_us_core

ClaimUSCore adds the US Core 9.0.0 profile to each resource produced that has been checked against it and conforms.

A resource that falls short is sent without the claim, with a note saying why.

23.15.6 validate_before_send

ValidateBeforeSend refuses to post a bundle that fails validation.

Defaults to true: sending a resource a server will reject wastes a retry budget and hides the real problem behind a transport error.

23.15.7 reject_on_warning

RejectOnWarning also refuses on warnings, such as a missing US Core identifier.

Off by default, because a warning is a judgement about profile conformance rather than about validity.

23.15.8 bearer_token

BearerToken is sent as an Authorization header.

Prefer an environment variable reference, ${NAME}, over a literal in a file that goes into git.

23.15.9 ecr

ECR makes this a public health case reporting destination.

Each message is checked for trigger codes; one that has any is sent as an eCR 2.1 eICR, wrapped in an eCR message, to url's $process-message, and one with none is not sent at all.

23.15.10 bundle_script

BundleScript is JavaScript run on each converted Bundle before it is validated and sent: bundle is the Bundle as an object, edited in place.

It is how a site adds what its own systems know and the v2 message does not say - its organisation's IKNR or ODS code, a national profile's contact level - without the converter guessing it. A script that throws fails the message, as any other transformer does. Not run for case reports (ecr).

23.16 FTP Destination

FTPDestination uploads a file per message.

Defined as FTPDestination in internal/config.

KeyTypeRequiredDefault
hosttextyes21
usertextno—
passwordtextno—
securityFTPSecuritynoexplicit rather than none
insecure_skip_verifytrue or falseno—
allow_clear_passwordtrue or falseno—
dirtextyes—
file_nametextno—
temp_suffixtextno"
framedtrue or falseno—
timeoutduration, such as 30s or 5mno60s

23.16.1 host

Host is the server, with an optional port.

Defaults to 21, or 990 for implicit TLS.

23.16.2 user

User and Password authenticate.

An empty user means anonymous.

23.16.3 password

User and Password authenticate.

An empty user means anonymous.

23.16.4 security

Security selects TLS: explicit, implicit or none.

Defaults to explicit rather than none, because defaulting to plain FTP would make the insecure choice the quiet one, and a site that genuinely has no TLS should have to write that down.

23.16.5 insecure_skip_verify

InsecureSkipVerify accepts any certificate.

Needed more often than anybody would like: these servers frequently have a self-signed certificate generated years ago that nobody can reissue. Named separately so choosing it is deliberate and visible in the file rather than hidden inside a "security: relaxed" mode.

23.16.6 allow_clear_password

AllowClearPassword permits a password over plain FTP.

Refused by default, because that sends a credential across the network in clear text on every single delivery. Some analysers genuinely have no TLS, so it has to be possible - but it has to be written down, so that it is a decision somebody made rather than a default they inherited. The name is deliberately uncomfortable to type.

23.16.7 temp_suffix

TempSuffix is appended while the file is being written and removed by a rename once it is complete.

Defaults to ".part".

Whoever collects these files is usually a scheduled job that takes whatever it finds, and without the rename it eventually takes half a message.

23.16.8 timeout

Timeout bounds one transfer.

Defaults to 60s.

23.17 GCS Destination

GCSDestination writes each message as an object.

Defined as GCSDestination in internal/config.

KeyTypeRequiredDefault
objecttextno${date}/${channel}/${control_id}
storage_classtextno—
content_typetextno—

23.17.1 object

Object is the object name, with the same placeholders as an s3 key.

Defaults to ${date}/${channel}/${control_id}.hl7.

23.18 HTTP Destination

HTTPDestination posts messages to an HTTP endpoint.

The commonest thing a Mirth channel does that Perfuse could not, and the commonest blocker the translator reports. Most uses are unglamorous: post the message to an internal API, post it to a web service that wraps a legacy system, post it to something somebody wrote in an afternoon.

Defined as HTTPDestination in internal/config.

KeyTypeRequiredDefault
urltextyes—
methodtextno—
content_typetextno—
headersmapping of text to textno—
bearer_tokentextno—
usernametextno—
passwordtextno—
tlstlsconf.Settings (a block, omit to leave off)no—
success_statuslist of whole numbernoany 2xx
fail_on_bodylist of textno—
follow_redirectstrue or falseno—

23.18.1 url

URL is where to post.

Required.

23.18.2 method

Method defaults to POST.

PUT is allowed for an endpoint that wants it.

GET and DELETE are refused: this sends a message body, and a transport that silently dropped the message because of a method choice would be a very quiet way to lose data.

23.18.3 content_type

ContentType defaults to application/hl7-v2+er7, which is the registered type for a pipe-delimited HL7 v2 message.

Many endpoints want text/plain instead.

23.18.4 bearer_token

BearerToken is sent as an Authorization header.

Prefer an environment variable reference over a literal in a file that goes into git.

23.18.5 success_status

SuccessStatus lists the status codes that count as delivered.

Defaults to any 2xx.

Configurable because endpoints disagree about what success looks like: some answer 200 with an error in the body, and some answer 202 for a queue they have accepted the message into.

23.18.6 fail_on_body

FailOnBody treats a response containing any of these strings as a failure, whatever the status code.

This exists because an endpoint answering 200 with "ERROR: patient not found" in the body is common, and treating it as delivered means the message is gone and nobody knows.

23.18.7 follow_redirects

FollowRedirects allows the request to be redirected.

Off by default: a redirect on a write would repost clinical data somewhere the configuration never named.

23.19 IBMMQ Destination

IBMMQDestination puts each message on a queue.

Defined as IBMMQDestination in internal/config.

KeyTypeRequiredDefault
non_persistenttrue or falseno—

23.19.1 non_persistent

NonPersistent sends messages that do not survive a queue manager restart.

Off by default.

23.20 Java Script Destination

JavaScriptDestination runs a script instead of sending anywhere.

Mirth's JavaScript Writer, and it is used far more than its position in a feature list suggests. It is the escape hatch: when a site needs to talk to something no connector covers, or apply logic no transformation expresses, they write a JavaScript Writer. A migration that cannot run those cannot move the channels that have them, which in a mature Mirth installation is a large fraction.

Deliberately not a general scripting host. The script gets the message and the same helpers a transformer gets; what it does not get is a way to reach the filesystem or the network, because that is what the other destinations are for and a script that opens its own socket is invisible to every retry, queue and metric in the engine.

Defined as JavaScriptDestination in internal/config.

KeyTypeRequiredDefault
scripttextyes—
timeoutduration, such as 30s or 5mno—
success_on_undefinedtrue or false (a block, omit to leave off)notrue

23.20.1 script

Script is the code to run.

Required.

23.20.2 timeout

Timeout bounds one execution.

Zero applies a default.

Bounded because a destination script is on the delivery path: an infinite loop in one does not merely fail a message, it holds the queue behind it.

23.20.3 success_on_undefined

SuccessOnUndefined treats a script that returns nothing as success.

Defaults to true.

True is the right default and worth explaining. Mirth's JavaScript Writers overwhelmingly do their work and return nothing, so requiring an explicit return would break every one of them on import. Setting it false is for a site that wants a script to be explicit about whether it worked.

23.21 Kafka Destination

KafkaDestination publishes messages to a Kafka topic.

Defined as KafkaDestination in internal/config.

KeyTypeRequiredDefault
brokerslist of textyes—
topictextyes—
keytextno—
ackstextnoall
compressiontextnosnappy
headersmapping of text to textno—
timeoutduration, such as 30s or 5mnothirty seconds
saslKafkaSASL (a block, omit to leave off)no—
tlstlsconf.Settings (a block, omit to leave off)no—

23.21.1 brokers

Brokers is the bootstrap server list.

Required.

23.21.2 topic

Topic is where to publish.

Required.

23.21.3 key

Key is a template for the record key, which decides the partition.

This is the field that makes Kafka safe for a clinical feed, and it is worth being precise about why. Kafka guarantees order within a partition and nowhere else. Records with the same key always land in the same partition, so keying on the patient identifier keeps that patient's events in order while letting different patients go in parallel.

Without a key there is no such guarantee. Measured against a real broker, thirty unkeyed records went to one partition rather than spreading: the client keeps a batch together and picks a new partition between batches. So the ordering holds by accident and breaks at a boundary nobody can see, which is worse than breaking consistently - an A03 discharge read before its A01 admission, intermittently, under load.

An HL7 path, evaluated against the outgoing message: PID-3.1 keys by patient.

23.21.4 acks

Acks is how many brokers must confirm a write: "all", "leader" or "none".

Defaults to all, which is the only setting that survives a broker failing between the write and the replication. "none" is fire-and-forget and will lose messages; it exists because somebody moving non-clinical telemetry may legitimately want it, and refusing it outright would mean they wrote their own producer instead.

23.21.5 compression

Compression is "none", "gzip", "snappy", "lz4" or "zstd".

Defaults to snappy.

On by default because HL7 is highly compressible text and the wire is usually the constraint. Snappy rather than zstd as the default: it is the cheapest in CPU, and a connector on the delivery path should not be the thing that saturates a core.

23.21.6 headers

Headers are record headers to set.

Values are templates evaluated per message.

23.21.7 timeout

Timeout bounds one publish.

Defaults to thirty seconds.

23.22 MPI Destination

MPIDestination registers each message's patient in this server's patient index: an HL7 v2 PID (an A40 merge merges), or a FHIR Patient on a raw channel.

The index decides which records are the same person; see the Patient index screen.

Defined as MPIDestination in internal/config.

KeyTypeRequiredDefault
authoritiesmapping of text to textno—
id_typetextno—
ssn_systemtextno—
no_mergetrue or falseno—

23.22.1 authorities

Authorities maps an assigning authority's namespace (PID-3.4.1) to its identifier system, for senders that give no universal identifier.

Unmapped ones become urn:perfuse:aa:<namespace>.

23.22.2 id_type

IDType is the identifier type (PID-3.5) of the record's own number.

Default MR.

23.22.3 ssn_system

SSNSystem names PID-19 as an identifier.

Default http://hl7.org/fhir/sid/us-ssn.

23.23 MQTT Destination

MQTTDestination publishes to a topic.

Defined as MQTTDestination in internal/config.

KeyTypeRequiredDefault
topictextyes—
retaintrue or falseno—

23.23.1 topic

Topic is published to.

It may not hold wildcards.

23.24 PAS Destination

PASDestination submits each message, a Da Vinci PAS request Bundle, to a payer's Claim/$submit, for a provider whose systems build the request (an EHR, DTR) and need it sent, the decision recorded and the answer passed on.

Defined as PASDestination in internal/config.

KeyTypeRequiredDefault
payertextyes—
bearer_tokentextno—
authFHIRAuth (a block, omit to leave off)no—
results_totextno—

23.24.1 bearer_token

BearerToken is a static token, ${ENV} allowed.

Auth is OAuth client credentials instead, or Azure, as a FHIR destination's.

23.24.2 auth

BearerToken is a static token, ${ENV} allowed.

Auth is OAuth client credentials instead, or Azure, as a FHIR destination's.

23.25 Pub Sub Destination

PubSubDestination publishes to a Pub/Sub topic.

Defined as PubSubDestination in internal/config.

KeyTypeRequiredDefault
topictextyes—
order_bytextno—

23.26 S3 Destination

S3Destination writes each message as an object.

Defined as S3Destination in internal/config.

KeyTypeRequiredDefault
buckettextyes—
regiontextyes—
keytextnoa date-partitioned path plus the control id
access_key_idtextno—
secret_access_keytextno—
session_tokentextno—
endpointtextno—
path_styletrue or falseno—
server_side_encryptiontextno—
content_typetextnoapplication/hl7-v2 for HL7 and text/plain otherwise
framedtrue or falseno—
timeoutduration, such as 30s or 5mno60s
storage_classtextno—
formattextno—

23.26.1 key

Key templates the object key, using the same ${date}, ${timestamp}, ${control_id}, ${message_type} and ${channel} placeholders as the file and SFTP destinations.

Defaults to a date-partitioned path plus the control id.

Date partitioning by default because the alternative - every object in one flat prefix - makes a bucket that is slow to list and impossible to lifecycle by age, and nobody discovers that until there are four million objects in it.

23.26.2 access_key_id

AccessKeyID and SecretAccessKey authenticate.

Either may name an environment variable instead of holding the value, using the form ${NAME}.

23.26.3 secret_access_key

AccessKeyID and SecretAccessKey authenticate.

Either may name an environment variable instead of holding the value, using the form ${NAME}.

23.26.4 path_style

PathStyle addresses the bucket as a path rather than a subdomain.

Most S3-compatible stores need this.

23.26.5 content_type

ContentType labels the object.

Defaults to application/hl7-v2 for HL7 and text/plain otherwise.

23.26.6 timeout

Timeout bounds one upload.

Defaults to 60s.

23.26.7 storage_class

StorageClass is the S3 storage class each object is written with: STANDARD_IA, INTELLIGENT_TIERING, GLACIER_IR, GLACIER, DEEP_ARCHIVE and so on.

This is how an archive goes straight to Glacier - S3 has no separate Glacier API to call.

23.26.8 format

Format is hl7 (the default: each object is the message) or ndjson: each object is one JSON line with the message and the fields worth querying, under date-partitioned keys, so Amazon Athena can query the archive.

See perfuse athena.

23.27 SFTP Destination

SFTPDestination writes each message to a file on an SFTP server.

Defined as SFTPDestination in internal/config.

KeyTypeRequiredDefault
hosttextyes22
usertextyes—
passwordtextno—
key_filetextno—
key_passphrasetextno—
known_hosts_filetextno—
insecure_skip_host_key_checktrue or falseno—
dirtextyes—
file_nametextnoa timestamp plus the control ID
temp_suffixtextno"
framedtrue or falseno—
appendtrue or falseno—
timeoutduration, such as 30s or 5mno60s

23.27.1 host

Host is the server, with an optional port.

Defaults to 22.

23.27.2 password

Password authenticates with a password.

Prefer KeyFile.

23.27.3 file_name

FileName templates the name.

Defaults to a timestamp plus the control ID.

23.27.4 temp_suffix

TempSuffix is appended while a file is being written, and removed by a rename once it is complete.

Defaults to ".part".

The same courtesy this connector needs on the way in. Whoever collects these files has the identical problem, and a rename within a directory is atomic on every server worth using.

23.27.5 timeout

Timeout bounds a single connection.

Defaults to 60s.

23.28 SMTP Destination

SMTPDestination configures delivery by email.

Defined as SMTPDestination in internal/config.

KeyTypeRequiredDefault
hosttextyes—
fromtextyes—
tolist of textyes—
cclist of textno—
bcclist of textno—
subjecttextno—
bodytextno—
attachtrue or falseno—
attach_nametextnothe control ID with a
usernametextno—
passwordtextno—
starttlstrue or false (a block, omit to leave off)no—
insecure_skip_verifytrue or falseno—
timeoutduration, such as 30s or 5mno—

23.28.1 host

Host is the mail server, with an optional port.

Port 587 is assumed, which is submission with STARTTLS - the port a modern server expects a client on. Port 25 is server-to-server and is usually either blocked or unauthenticated.

23.28.2 from

From is the envelope sender.

Required, because a message with no sender is discarded silently by a great deal of mail infrastructure, which makes it the hardest possible failure to diagnose.

23.28.3 to

To, CC and BCC are recipients.

At least one To is required.

23.28.4 cc

To, CC and BCC are recipients.

At least one To is required.

23.28.5 bcc

To, CC and BCC are recipients.

At least one To is required.

23.28.6 body

Body is the message text.

Field references are substituted, as in Subject. When it is empty the message itself is the body.

23.28.7 attach_name

AttachName names the attachment.

Defaults to the control ID with a .hl7 suffix.

23.28.8 username

Username and Password authenticate to the server.

Both or neither.

23.28.9 password

Username and Password authenticate to the server.

Both or neither.

23.28.10 starttls

StartTLS upgrades the connection before authenticating.

It defaults to on, and turning it off with a username set is refused rather than allowed: sending a password over an unencrypted connection is not a trade-off worth offering.

23.28.11 insecure_skip_verify

InsecureSkipVerify accepts any certificate.

Named unambiguously because that is what it does.

23.29 SNS Destination

SNSDestination publishes each message to an SNS topic.

Defined as SNSDestination in internal/config.

KeyTypeRequiredDefault
topic_arntextyes—
subjecttextno—
group_bytextno—

23.29.1 subject

Subject is sent with email subscriptions.

Optional; ${message_type} and ${control_id} are filled in.

23.30 SOAP Destination

SOAPDestination posts a SOAP request per message.

Defined as SOAPDestination in internal/config.

KeyTypeRequiredDefault
urltextyes—
profiletextno—
facility_idtextno—
warnings_are_errorstrue or falseno—
actiontextno—
versiontextno1
bodytextyes—
headertextno—
headersmapping of text to textno—
usernametextno—
passwordtextno—
tlstlsconf.Settings (a block, omit to leave off)no—
fault_is_successlist of textno—
timeoutduration, such as 30s or 5mno30s

23.30.1 profile

Profile names a standard service whose envelope Perfuse builds itself.

Empty is a plain SOAP call with the body written in this file.

"cdc-iis" is the CDC's immunization registry interface (urn:cdc:iisb:2011), which every US state registry uses: the message goes in submitSingleMessage, username and password go in the body as the WSDL says (not as HTTP authentication), SOAP 1.2 by default, and the registry's HL7 acknowledgement decides whether the message was delivered. A registry answers a rejected shot with HTTP 200 and an AR inside, so without this a rejection reads as a success.

23.30.2 facility_id

FacilityID is the cdc-iis facilityID, the registry's identifier for the submitting organisation.

Optional in the WSDL; some registries require it.

23.30.3 warnings_are_errors

WarningsAreErrors, for cdc-iis, treats an AE acknowledgement carrying only warnings as a failure.

Off by default: registries send AE for "accepted with warnings", and the ERR severity says whether anything was lost.

23.30.4 action

Action is the SOAPAction.

Required by most 1.1 services.

23.30.5 body

Body is the XML that goes inside the envelope, with ${PID-5.1} style references to message paths.

Required, unless a profile builds it. There is no useful default: the element names come from the service's own schema and no engine can guess them.

23.30.6 header

Header is XML for the envelope's Header element.

Omitted entirely when empty, because some services reject an empty one.

23.30.7 fault_is_success

FaultIsSuccess lists fault codes that count as delivered.

Needed more often than it should be. A service that answers a resend with "already submitted" is telling you the message arrived, and without this a site has to write a response transformer to say so - or worse, watches a queue retry forever against a receiver that already has the message.

23.30.8 timeout

Timeout bounds one call.

Defaults to 30s.

23.31 SQS Destination

SQSDestination sends each message to an SQS queue.

Defined as SQSDestination in internal/config.

KeyTypeRequiredDefault
queue_urltextyes—
group_bytextno—

23.31.1 queue_url

QueueURL names the queue.

One ending .fifo is a FIFO queue, and is grouped and deduplicated.

23.32 TCP Dest

TCPDest sends messages to a socket with configurable framing.

Defined as TCPDest in internal/config.

KeyTypeRequiredDefault
addresstextyes—
max_message_sizewhole numberno—
timeoutduration, such as 30s or 5mno—
expect_replytrue or falseno—
keep_alivetrue or falseno—
tlstlsconf.Settings (a block, omit to leave off)no—

23.32.1 expect_reply

ExpectReply waits for bytes back before reporting success.

Off by default, and worth thinking about. With it off, "delivered" means the bytes reached the operating system's send buffer, which a peer that crashed a moment later never read. With it on, a peer that never replies makes every message time out.

23.32.2 keep_alive

KeepAlive holds one connection open across messages rather than dialling per message.

Most device endpoints expect this; some refuse a second message on the same connection. Off by default because a connection per message is the behaviour that works everywhere, at the cost of a handshake each time.

23.33 TEFCA Destination

TEFCADestination asks the TEFCA Facilitated FHIR partner for the records of each message's patient.

Who this server is on the network, its certificate and the partner are server settings (Settings, TEFCA), because they belong to the organisation and its agreement with a QHIN. What a channel decides is when to ask and why: the purpose of use is part of the channel, not of whoever is signed in, so every query a feed makes states the same reason and the audit trail can be read against it.

Defined as TEFCADestination in internal/config.

KeyTypeRequiredDefault
purposetextyes—
results_totextno—

23.33.1 purpose

Purpose is the purpose of use: treatment, payment, operations, public_health or individual_access.

It must be one the participant declares.

23.33.2 results_to

ResultsTo names a channel in this server that receives each Bundle the partner returns, as a message of its own.

Empty keeps the Bundle as this destination's reply only, for a response transformer to read.

23.34 XDS Destination

XDSDestination publishes each clinical document to an IHE XDS.b Document Repository with ITI-41 Provide and Register.

The metadata is read from the CDA document (patient, title, type, time, author, confidentiality, language) and completed from these settings, which say what the document cannot: who is sending it, what kind of facility and practice it came from, and which of the patient's identifiers is the affinity domain's.

Defined as XDSDestination in internal/config.

KeyTypeRequiredDefault
urltextyes—
source_idtextyes—
patient_domaintextno—
facility_typeXDSCodeyes—
practice_settingXDSCodeyes—
classXDSCode (a block, omit to leave off)no—
content_typeXDSCode (a block, omit to leave off)no—
tokentextno—
usernametextno—
passwordtextno—
headersmapping of text to textno—
tlstlsconf.Settings (a block, omit to leave off)no—
timeoutduration, such as 30s or 5mno—
direct_fromtextno—
direct_tolist of textno—

23.34.1 patient_domain

PatientDomain is the affinity domain's assigning authority OID: the patient identifier with this root is the one registered.

Without it a document whose patient has one identifier sends that one, and one with several is refused.

23.34.2 token

Token or Username/Password authenticate; Headers are added to each request.

Most affinity domains use mutual TLS (tls) instead.

23.34.3 username

Token or Username/Password authenticate; Headers are added to each request.

Most affinity domains use mutual TLS (tls) instead.

23.34.4 password

Token or Username/Password authenticate; Headers are added to each request.

Most affinity domains use mutual TLS (tls) instead.

23.34.5 headers

Token or Username/Password authenticate; Headers are added to each request.

Most affinity domains use mutual TLS (tls) instead.

23.34.6 tls

Token or Username/Password authenticate; Headers are added to each request.

Most affinity domains use mutual TLS (tls) instead.

23.34.7 direct_from

DirectFrom and DirectTo make the submission XDR as Direct's transport: a Direct addressing block naming this sender's Direct address and the recipients', and the recipients in the submission's intendedRecipient.

A HISP that takes XDR delivers it as Direct mail, or to the recipient's own XDR endpoint.

23.34.8 direct_to

DirectFrom and DirectTo make the submission XDR as Direct's transport: a Direct addressing block naming this sender's Direct address and the recipients', and the recipients in the submission's intendedRecipient.

A HISP that takes XDR delivers it as Direct mail, or to the recipient's own XDR endpoint.

24 Format Reference

Options governing how messages are parsed and written, for the formats that need more than a name.

24.1 Attachments

Attachments configures moving large payloads out of a message.

A message carrying a scanned report or a PDF is ordinary in document workflows, and the payload is routinely hundreds of times larger than the message around it. Left inline it means the store grows at the rate of the documents rather than the traffic, a message queued for five destinations is copied five times, and the interface streams megabytes to show somebody a patient name.

Mirth calls these attachment handlers. This is the equivalent, and it is opt-in per channel because a feed of plain ADT messages has nothing to extract and should not pay for the machinery.

Defined as Attachments in internal/config.

KeyTypeRequiredDefault
extractlist of attach.Ruleyes—
reassembletrue or false (a block, omit to leave off)notrue

24.1.1 reassemble

Reassemble puts payloads back before delivery.

Defaults to true.

The default is on because a receiver expecting a document must get a document; a message containing a literal token would be filed as a report and nobody would find out until somebody opened the record.

Turning it off is for the case where the point of the channel is to strip documents - forwarding metadata to a system that has no use for the image, where sending it would be a privacy question rather than a bandwidth one.

24.2 Delimited

Delimited configures a delimited channel.

Mirth's Delimited data type: CSV, tab-separated, and the long tail of formats a laboratory analyser or a bureau service emits.

Defined as Delimited in internal/config.

KeyTypeRequiredDefault
delimitertextno—
quotetextnoa double quote
commenttextno—
has_headertrue or falseno—
columnslist of textno—
trim_spacetrue or falseno—
relaxedtrue or falseno—
splittrue or false (a block, omit to leave off)notrue
keep_blank_linestrue or falseno—
filtertextno—
transformationslist of steps.Stepno—

24.2.1 delimiter

Delimiter separates columns, as a character or a name.

A name is accepted because a tab cannot be written into a YAML file unambiguously, and a configuration file containing an invisible character is unreadable to whoever opens it next.

24.2.2 quote

Quote wraps values containing the delimiter.

Defaults to a double quote; "none" disables it.

24.2.3 split

Split makes each row its own message.

Defaults to true.

This is the decision that shapes a delimited channel and it deserves saying out loud. A file of five thousand results is either one message or five thousand. One message means one acknowledgement, one entry in the browser, and one failure that takes the whole file with it. Five thousand means each row is filtered, transformed, delivered and retried on its own, and a single bad row does not stop the rest.

Splitting is the default because per-row handling is what almost every site wants and because the alternative is discovered late: a channel that looked fine in testing with a three-row file fails an entire night's transfer over one malformed line.

24.2.4 keep_blank_lines

KeepBlankLines stops blank lines being skipped.

Skipping is the default, because every file ends in a newline and a record of empty fields is not a record. This exists for the rare format where a blank line is meaningful.

24.2.5 filter

Filter decides which messages continue, written against column names.

Here rather than on the channel because the channel-level filter compiles HL7 paths. Two settings named filter would be confusing, but one setting that means different things depending on dataType is worse: a delimited channel that silently compiled its filter as HL7 would match nothing and drop everything, which is the failure the top-level refusal exists to prevent.

24.2.6 transformations

Transformations are the declarative steps, addressing columns rather than HL7 fields.

Separate from the channel's transformations for the same reason as Filter, and it is the arrangement X12 and NCPDP already use.

24.3 HL7v3 Options

HL7v3Options are the settings that only apply to an HL7 v3 channel.

Defined as HL7v3Options in internal/config.

KeyTypeRequiredDefault
filtertextno—
acknowledgetrue or false (a block, omit to leave off)no—
sender_devicetextno—
sender_oidtextno—
transformationslist of hl7v3.Stepno—

24.3.1 filter

Filter excludes messages, using v3 paths.

Its own field rather than the channel's ordinary filter, because the two are different languages against different message models. Sharing the field would mean a v2 filter silently never matching on a v3 channel, or a v3 filter failing to parse as v2 - and the second is only better because it fails loudly.

A channel that sets both is refused at load, since one of them would have to be ignored and there is no honest way to choose.

24.3.2 acknowledge

Acknowledge sends an MCCI_IN000002UV01 acknowledgement back.

On by default, unlike X12. A v3 interaction over a synchronous transport expects an acknowledgement - the sending application is generally waiting on one - and a sender that receives nothing will usually retry, which is how a patient gets registered three times.

A pointer so that "not set" and "set to false" are different: the default is on, and somebody turning it off should have said so.

24.3.3 sender_device

SenderDevice is the device identifier we put in an acknowledgement.

Required when acknowledging. A v3 acknowledgement names the device it comes from, and a receiver that does not recognise ours may discard it - which looks exactly like not sending one.

24.3.4 transformations

Transformations change the content of a v3 document.

Its own field rather than the channel's ordinary transformations, for the same reason the filter is: they are different vocabularies against different message models. The v2 steps address segments and fields, which a v3 document does not have, so sharing the field would mean a step that silently did nothing.

The step names match the v2 ones wherever the meaning matches, so somebody who has written one channel can write the other. What is different is what v3 needs and v2 cannot express - chiefly that removing a value has three distinct meanings, so clear, nullflavor and remove are three steps rather than one.

24.4 TCP Framing

TCPFraming is how message boundaries are found on a raw socket.

Shared by the TCP source and the TCP destination, and by the serial source, because the framings are the same wherever the bytes come from. A laboratory analyser that speaks STX/ETX over a socket speaks STX/ETX over a serial cable too.

Defined as TCPFraming in internal/config.

KeyTypeRequiredDefault
framingtextyes—
delimitertextno—
start_blocktextno—
keep_delimitertrue or falseno—
record_lengthwhole numberno—
trim_paddingtrue or false (a block, omit to leave off)no—
length_byteswhole numberno—
big_endiantrue or falseno—
length_includes_headertrue or falseno—

24.4.1 framing

Framing is mllp, delimited, fixed, length or whole.

Required, with no default. Reading a stream with the wrong framing produces messages that look plausible rather than an error, so guessing is worse than asking.

24.4.2 start_block

StartBlock optionally begins a message.

Bytes before it are discarded as noise from a partial connection.

24.4.3 keep_delimiter

KeepDelimiter includes the delimiter in the message.

Off by default: it is framing, not content.

24.4.4 trim_padding

TrimPadding removes trailing spaces and NULs from a fixed record.

On by default.

24.5 X12 Options

X12Options are the settings that only apply to an X12 channel.

Defined as X12Options in internal/config.

KeyTypeRequiredDefault
envelopeEnvelopePolicynorequire
splittrue or falseno—
acknowledgetextno—
ack_sender_idtextno—
ack_sender_qualifiertextno—
arrives_astextno—
transformationslist of x12.Stepno—

24.5.1 envelope

Envelope says what to do when the self-declared counts do not match what is present.

Defaults to require.

24.5.2 split

Split sends one message per transaction set instead of one per file.

Off by default. A single 837 can carry hundreds of claims, so turning this on multiplies everything downstream - message counts, acknowledgements, rows, alert thresholds - and that should be a decision somebody made rather than a default they inherited.

24.5.3 acknowledge

Acknowledge says which acknowledgement to send back: 999, 997, ta1, or none.

Which one is a property of the trading partner relationship rather than of the message, so it has to be configured. A 999 supersedes a 997 for HIPAA transactions, but plenty of partners - older payer connections especially - are set up to expect a 997 and will treat a 999 as an unrecognised file. Sending the wrong one is worse than sending none, because it answers a question nobody asked and leaves the real one open.

Empty means none, which is the safe default: a partner who is not expecting an acknowledgement and receives one may treat it as an unsolicited interchange.

24.5.4 ack_sender_id

AckSenderID and AckSenderQualifier are our own interchange identifier, becoming ISA06 and ISA05 of any acknowledgement.

Required whenever Acknowledge is set, and refused at load otherwise. These must be the values the trading partner has configured for us and there is no way to guess them: an interchange whose ISA06 the partner does not recognise is discarded before anybody reads it, so a wrong value produces silence that looks exactly like not sending anything - and somebody spends a week looking in the wrong place.

24.5.5 ack_sender_qualifier

AckSenderID and AckSenderQualifier are our own interchange identifier, becoming ISA06 and ISA05 of any acknowledgement.

Required whenever Acknowledge is set, and refused at load otherwise. These must be the values the trading partner has configured for us and there is no way to guess them: an interchange whose ISA06 the partner does not recognise is discarded before anybody reads it, so a wrong value produces silence that looks exactly like not sending anything - and somebody spends a week looking in the wrong place.

24.5.6 arrives_as

ArrivesAs is xml when the source delivers the interchange as the XML Mirth Connect and Open Integration Engine write for X12 (an X12Transaction root, elements named CLM.05.1), as an engine being migrated from, or a system built against one, sends it.

It is turned back into X12 before anything else happens. Empty or x12 is X12.

24.5.7 transformations

Transformations are declarative changes applied to accepted interchanges.

Separate from the channel-level transformations for the same reason the v3 ones are: those address HL7 fields through a parser that reads segment-field-component-subcomponent, and X12 has no subcomponent, treats repeated segments as ordinary rather than exceptional, and is written CLM01 by the people who configure it. A shared parser would have to accept both notations and would then be ambiguous in both.

The step names are the same ones the HL7 and v3 steps use, so what an author has already learned carries over.

25 Behaviour Reference

Acknowledgement, queueing, retry, polling, scripting and shadow settings. These apply regardless of which transport a channel uses.

25.1 AMQP Connection

AMQPConnection is where and as whom to connect.

Defined as AMQPConnection in internal/config.

KeyTypeRequiredDefault
addrtextyes—
addresstextyes—
usernametextno—
passwordtextno—
hostnametextnothe host of addr
tlstlsconf.Settings (a block, omit to leave off)no—
timeoutduration, such as 30s or 5mno30s

25.1.1 username

Username and Password authenticate with SASL PLAIN; both empty means ANONYMOUS.

For Service Bus, the policy name and key.

25.1.2 password

Username and Password authenticate with SASL PLAIN; both empty means ANONYMOUS.

For Service Bus, the policy name and key.

25.1.3 hostname

Hostname is sent to the broker, which Service Bus routes on.

Defaults to the host of addr.

25.1.4 tls

TLS encrypts the connection.

Required for Service Bus and Event Hubs.

25.1.5 timeout

Timeout bounds connecting and each wait for the broker.

Defaults to 30s.

25.2 AS2 Partner

AS2Partner is a trading partner an AS2 source accepts messages from.

Defined as AS2Partner in internal/config.

KeyTypeRequiredDefault
idtextyes—
cert_filetextyes—
allow_unsignedtrue or falseno—
allow_unencryptedtrue or falseno—

25.2.1 cert_file

CertFile is the partner's certificate (PEM), for its signatures.

Often self-signed: AS2 partners exchange certificates directly rather than through an authority.

25.2.2 allow_unsigned

AllowUnsigned and AllowUnencrypted accept a message from this partner that is not signed, or not encrypted.

Off: such a message is refused with a receipt saying why.

25.2.3 allow_unencrypted

AllowUnsigned and AllowUnencrypted accept a message from this partner that is not signed, or not encrypted.

Off: such a message is refused with a receipt saying why.

25.3 AWS Access

AWSAccess is how a connector reaches AWS: the region it signs for, the keys, and an optional endpoint override.

Credentials may be literal or ${ENV_VAR} references, which is how they should be supplied. There is no instance-metadata credential chain, deliberately: see internal/awsv4.

Defined as AWSAccess in internal/config.

KeyTypeRequiredDefault
regiontextyes—
access_key_idtextno—
secret_access_keytextno—
session_tokentextno—
endpointtextno—

25.3.1 secret_access_key

SecretAccessKey is the secret, literal or ${AWS_SECRET_ACCESS_KEY}.

Prefer the reference: this file goes into version control.

25.3.2 session_token

SessionToken is for temporary credentials only.

They expire, and the connector stops working when they do.

25.4 Ack

Ack configures acknowledgement generation.

Defined as Ack in internal/config.

KeyTypeRequiredDefault
whenAckWhenno—
applicationtextno—
facilitytextno—
include_trigger_eventtrue or falseno—

25.4.1 include_trigger_event

IncludeTriggerEvent sends ACK^A01 rather than ACK.

Some receivers require it and others reject it, so it is explicit.

25.5 Azure Blob Access

AzureBlobAccess is the account, container and credential.

Defined as AzureBlobAccess in internal/config.

KeyTypeRequiredDefault
accounttextyes—
containertextyes—
keytextno—
sastextno—
endpointtextno—

25.5.1 key

Key is the account key, or ${AZURE_STORAGE_KEY}.

Set it or sas.

25.5.2 sas

SAS is a shared access signature query string, or a ${...} reference.

Preferred: it can be limited to this container.

25.6 Contract Ref

ContractRef points a channel at its contract.

Defined as ContractRef in internal/config.

KeyTypeRequiredDefault
filetextyes—
check_everytextno15 minutes
overwhole numberno500

25.6.1 check_every

CheckEvery is how often to re-check, as a duration.

Defaults to 15 minutes.

Effectively floored at the alert evaluation interval, which is 30 seconds by default, because the check runs from that loop rather than a timer of its own - so a contract asking for 3s gets 30s. That is deliberate: one timer is easier to reason about than two, and it keeps a contract result in step with the reading it is attached to. Worth knowing before somebody sets a short interval and concludes it is broken.

Not per message. Building a profile means reading recent traffic, and doing that on every message would make a channel's throughput depend on how much history it has - which is the kind of performance characteristic that only shows up in production, six months in.

25.6.2 over

Over is how many recent messages to profile.

Defaults to 500.

A count rather than a time window, because a quiet feed and a busy one need the same amount of evidence to justify a rate, and a time window gives them wildly different amounts.

25.7 DICOM Options

DICOMOptions configures an imaging channel.

Why the steps are named rather than paths

Every other format gets a path writer. DICOM deliberately does not, and the reason is the pixel data: an object is binary, a general tag writer can set any tag to any bytes, and a mistake there does not produce a rejected message - it produces an image that opens and is wrong. A radiologist reading a study has no way to tell.

So the four things sites actually need are named actions with bounded effects: de-identify, rewrite an AE title, strip private tags, set the institution. Each knows which tags it touches, and none can reach the pixel data.

Why this block exists now

internal/dicom has carried steps.go and transform.go for a while - the four actions, dicom.Apply, and tests. Nothing referenced them. Five hundred lines with no caller, which the queue's own section 7 names: an implementation with no caller is indistinguishable from a feature that does not exist. This block and the engine call are the wiring, not the feature.

Defined as DICOMOptions in internal/config.

KeyTypeRequiredDefault
transformationslist of dicom.Stepno—

25.8 Direct Address

DirectAddress is a Direct address this server holds: its certificate and key, and the anchors it trusts.

The same block is used to send (sign as this address) and to receive (decrypt for it).

Defined as DirectAddress in internal/config.

KeyTypeRequiredDefault
addresstextyes—
cert_filetextyes—
key_filetextyes—
anchors_filetextyes—

25.8.1 cert_file

CertFile and KeyFile are the address's certificate (PEM, with its chain after it) and private key (PEM).

The HISP or its registration authority issues them; they are the address's identity.

25.8.2 key_file

CertFile and KeyFile are the address's certificate (PEM, with its chain after it) and private key (PEM).

The HISP or its registration authority issues them; they are the address's identity.

25.8.3 anchors_file

AnchorsFile is the trust anchors (PEM): the certificate authorities whose senders and recipients this address trusts, such as the DirectTrust bundle.

A message signed by anything else is refused.

25.9 Direct IMAP

DirectIMAP is the HISP mailbox a Direct source reads.

Defined as DirectIMAP in internal/config.

KeyTypeRequiredDefault
hosttextyes—
portwhole numberno—
securitytextno—
usernametextyes—
passwordtextno—
mailboxtextno—
after_readtextno—
move_totextno—
error_foldertextno—
poll_intervalduration, such as 30s or 5mno—
tlstlsconf.Settings (a block, omit to leave off)no—

25.9.1 move_to

MoveTo is the folder taken messages move to; Processed by default.

Created when missing.

25.9.2 tls

TLS trusts a CA file (or the HISP's own certificate) and names the server expected in it, for a HISP whose IMAP certificate is not from a public authority.

The certificate is always verified.

25.10 Direct XDR

DirectXDR is the endpoint a Direct source takes XDR at.

Defined as DirectXDR in internal/config.

KeyTypeRequiredDefault
listentextyes—
tls_certtextno—
tls_keytextno—
client_ca_filetextno—

25.11 ECR Facility

ECRFacility is the organisation sending case reports.

Defined as ECRFacility in internal/config.

KeyTypeRequiredDefault
nametextyes—
npitextno—
phonetextyes—
linetextno—
citytextyes—
statetextyes—
postal_codetextno—

25.12 EDIFACT Options

EDIFACTOptions are the settings that only apply to an EDIFACT channel.

Defined as EDIFACTOptions in internal/config.

KeyTypeRequiredDefault
envelopetextno—
splittrue or falseno—
acknowledgetextno—

25.12.1 acknowledge

Acknowledge answers a sender posting over HTTP with a CONTRL in the response: accepted (action 7) when the interchange was read and delivered, rejected (4) otherwise.

Default none.

25.13 Email O Auth

EmailOAuth signs in with OAuth 2.0 (SASL XOAUTH2), which Microsoft 365 and Gmail require in place of a password.

Defined as EmailOAuth in internal/config.

KeyTypeRequiredDefault
providertextyes—
tenanttextno—
token_urltextno—
client_idtextyes—
client_secrettextno—
refresh_tokentextno—
scopetextno—

25.13.1 client_secret

ClientSecret may be ${ENV}.

With no refresh_token it selects Microsoft's app-only client-credentials grant.

25.13.2 refresh_token

RefreshToken may be ${ENV}: a mailbox a person consented to.

Required for Gmail.

25.14 FHIR Auth

FHIRAuth is how a fhir destination authenticates to a hosted FHIR service.

Three presets, because the hosted services are where a bearer token pasted into a file stops working within the hour:

Tokens are fetched when needed and reused until a minute before they expire.

Defined as FHIRAuth in internal/config.

KeyTypeRequiredDefault
typetextyes—
regiontextno—
access_key_idtextno—
secret_access_keytextno—
session_tokentextno—
tenant_idtextno—
token_urltextno—
client_idtextno—
client_secrettextno—
scopetextno—

25.14.1 region

Region is the AWS region.

Read from a HealthLake URL when it is not given.

25.14.2 token_url

TokenURL is the OAuth token endpoint, for client_credentials.

For azure it is derived from the tenant.

25.14.3 client_id

ClientID and ClientSecret identify the application.

The secret may be a ${ENV} reference, which is how it should be given.

25.14.4 client_secret

ClientID and ClientSecret identify the application.

The secret may be a ${ENV} reference, which is how it should be given.

25.14.5 scope

Scope is requested with the token.

For azure it defaults to the FHIR service URL followed by /.default, which is its audience.

25.15 File Poll

FilePoll is the part of a file-collecting source that has nothing to do with the transport.

Shared by the local, FTP, SMB and WebDAV sources, and by SFTP. Every one of these settings exists because of a way files go wrong rather than a way transports differ, which is why they belong together: a site that has worked out the right stable_for for its analyser should not have to work it out again when the analyser moves to a share.

Defined as FilePoll in internal/config.

KeyTypeRequiredDefault
dirtextyes—
patterntextno—
poll_intervalduration, such as 30s or 5mno—
after_readtextno—
move_totextno—
error_dirtextno—
stable_forduration, such as 30s or 5mno—
max_file_sizewhole numberno—
framedtrue or falseno—
rawtrue or falseno—
batch_sizewhole numberno—
sort_bytextnoname

25.15.1 pattern

Pattern selects files by glob.

Empty means every file.

25.15.2 error_dir

ErrorDir is where a file that could not be processed goes.

Separate from MoveTo deliberately. A directory holding nothing but failures is one somebody can watch, and mixing failures into the archive means the only way to find them is to read every file.

25.15.3 raw

Raw treats the whole file as one message regardless of content.

The setting that makes this connector useful for anything other than HL7. Without it a CSV batch, an X12 claim file or a PDF is split on lines beginning MSH and produces nothing, and the error says the file is not HL7 - true but not the point, since the file was never meant to be.

25.15.4 batch_size

BatchSize bounds how many files one poll will read.

Zero means no limit.

Exists for the first poll after an outage. A directory holding forty thousand files that accumulated overnight will otherwise be read in one pass, which holds the poll loop for as long as it takes and makes the channel look hung. Reading a bounded number per poll keeps the channel responsive and the queue draining visibly.

25.15.5 sort_by

SortBy orders the files within a poll: name, modified or none.

Defaults to name. Ordering matters more than it looks: an ADT stream where A08 updates arrive before the A01 admission produces patients that do not exist yet. Directory listing order is arbitrary on most filesystems, so leaving it unsorted means the order changes between polls for no visible reason.

25.16 GCP Access

GCPAccess is how to authenticate to Google Cloud.

Defined as GCPAccess in internal/config.

KeyTypeRequiredDefault
projecttextno—
credentials_filetextno—
endpointtextno—

25.16.1 credentials_file

CredentialsFile is a service account's JSON key.

Empty uses the service account attached to the VM, GKE pod or Cloud Run service Perfuse runs on (the metadata server), unless Endpoint names an emulator.

25.17 GCS Access

GCSAccess is a Cloud Storage bucket.

Defined as GCSAccess in internal/config.

KeyTypeRequiredDefault
buckettextyes—

25.18 IBMMQ Connection

IBMMQConnection is a queue reached through the queue manager's messaging REST API.

Defined as IBMMQConnection in internal/config.

KeyTypeRequiredDefault
urltextyes—
queue_managertextyes—
queuetextyes—
usernametextno—
passwordtextno—
tlstlsconf.Settings (a block, omit to leave off)no—
api_versiontextno—
timeoutduration, such as 30s or 5mno30s

25.18.1 username

Username and Password are an mqweb user with the MQWebUser role; the password may be ${ENV}.

Leave both empty when a client certificate (tls.cert_file) authenticates.

25.18.2 password

Username and Password are an mqweb user with the MQWebUser role; the password may be ${ENV}.

Leave both empty when a client certificate (tls.cert_file) authenticates.

25.18.3 timeout

Timeout bounds each request.

Defaults to 30s.

25.19 Kafka SASL

KafkaSASL authenticates to a Kafka cluster.

Defined as KafkaSASL in internal/config.

KeyTypeRequiredDefault
mechanismtextyes—
usernametextyes—
passwordtextyes—

25.19.1 mechanism

Mechanism is "plain", "scram-sha-256" or "scram-sha-512".

PLAIN sends the password readable on the wire, so it belongs with TLS. SCRAM does not, which is why it is worth naming the difference here rather than treating the three as interchangeable.

25.19.2 password

Password is the SASL password.

Supports ${ENV} references like every other secret here, so a cluster credential need not be written into the channel file.

25.20 MQTT Connection

MQTTConnection is how to reach an MQTT 3.1.1 broker: Mosquitto, EMQX, HiveMQ, AWS IoT Core, Azure Event Grid.

Defined as MQTTConnection in internal/config.

KeyTypeRequiredDefault
addrtextyes—
client_idtextno—
usernametextno—
passwordtextno—
tlstlsconf.Settings (a block, omit to leave off)no—
qoswhole number (a block, omit to leave off)no—
timeoutduration, such as 30s or 5mno30s

25.20.1 client_id

ClientID names the session.

A source keeps its messages across reconnects only under a fixed client id; it defaults to perfuse-<channel>.

25.20.2 username

Username and Password authenticate; either may be empty.

The password may be ${ENV}.

25.20.3 password

Username and Password authenticate; either may be empty.

The password may be ${ENV}.

25.20.4 timeout

Timeout bounds connecting and each wait for the broker.

Defaults to 30s.

25.21 NCPDP Options

NCPDPOptions are the settings that only apply to a pharmacy claim channel.

Defined as NCPDPOptions in internal/config.

KeyTypeRequiredDefault
transformationslist of ncpdp.Stepno—

25.21.1 transformations

Transformations are declarative changes applied to each transmission before delivery.

The same vocabulary as every other format - set, copy, clear, map, replace, trim, case, each with an optional condition - because the step engine in internal/steps is generic. What differs is the notation: a path is written D1 or 07-D7, using the standard's own two-character field identifiers.

25.22 PGP Receive

PGPReceive opens what a source receives with OpenPGP: decrypted with our secret key, and its signature checked against the senders' public keys.

Defined as PGPReceive in internal/config.

KeyTypeRequiredDefault
decrypt_withtextno—
passphrasetextno—
verify_withlist of textno—
require_signaturetrue or falseno—
allow_unencryptedtrue or falseno—
pass_plaintrue or falseno—

25.22.1 decrypt_with

DecryptWith is our secret key file.

Required unless allow_unencrypted is on and messages are only signed.

25.22.2 passphrase

Passphrase unlocks DecryptWith when it is protected.

Prefer an environment variable reference, ${NAME}.

25.22.3 verify_with

VerifyWith are the senders' public key files.

A signature by any of them is checked and recorded.

25.22.4 pass_plain

PassPlain lets a message that is not PGP at all through unchanged, for a folder that receives both.

Off: such a message is refused, so a sender who forgot to encrypt is told.

25.23 PGP Send

PGPSend protects what a destination delivers with OpenPGP: encrypted to the receiver's public key, signed with ours, or both.

Payers, clearinghouses and state agencies often take files over SFTP only this way.

Defined as PGPSend in internal/config.

KeyTypeRequiredDefault
encrypt_tolist of textno—
sign_withtextno—
passphrasetextno—
armortrue or falseno—
no_compresstrue or falseno—
file_nametextno—

25.23.1 encrypt_to

EncryptTo are the receivers' public key files (armored or binary).

The message is readable by any of them. Empty: the message is signed only.

25.23.2 sign_with

SignWith is our secret key file, to sign each message.

Empty: encrypted only.

25.23.3 passphrase

Passphrase unlocks SignWith when it is protected.

Prefer an environment variable reference, ${NAME}.

25.24 Queue Config

QueueConfig turns on durable queueing for a destination.

Without it, a destination that is down for two minutes loses everything sent during those two minutes: retries happen in memory and when they run out the message is recorded as failed and gone. With it, the message is on disk and keeps its place until the receiver comes back.

It is off by default, which is a deliberate choice rather than caution. A queue changes what an acknowledgement means and it changes ordering behaviour during an outage, and both of those are things an operator should opt into knowingly rather than discover.

Defined as QueueConfig in internal/config.

KeyTypeRequiredDefault
enabledtrue or falseno—
max_attemptswhole numberno—
backoffduration, such as 30s or 5mno—
max_backoffduration, such as 30s or 5mno—
max_depthwhole numberno—
retain_hourswhole numberno—

25.24.1 max_attempts

MaxAttempts is how many times the queue tries before marking a message failed and moving on to the next one.

Zero means keep trying indefinitely, which is legitimate for a feed that must not lose anything but should be chosen rather than inherited.

25.24.2 max_backoff

MaxBackoff caps the growing delay.

The default is a minute rather than something larger because a receiver that comes back after a brief outage should be found quickly: an hour-long ceiling turns a two-minute blip into an hour of backlog.

25.24.3 max_depth

MaxDepth refuses new messages once this many are waiting, so a receiver that has been down for a week cannot fill the disk.

Zero means no limit.

When the limit is reached the message is failed rather than queued, and the sender is told so — which is the honest answer, because we are no longer promising to deliver it.

25.24.4 retain_hours

RetainHours is how long delivered and abandoned items are kept before being purged.

They are kept at all so somebody watching a backlog drain can see it happening, and so an abandoned message can be found afterwards.

25.25 Retry

Retry controls redelivery after a failure.

Defined as Retry in internal/config.

KeyTypeRequiredDefault
attemptswhole numberno—
backoffduration, such as 30s or 5mno—
max_backoffduration, such as 30s or 5mno—

25.25.1 attempts

Attempts is the total number of tries, including the first.

Zero means the default.

25.25.2 backoff

Backoff is the wait before the second attempt.

It doubles each time, up to MaxBackoff.

25.26 Script Options

ScriptOptions configures an NCPDP SCRIPT channel.

Why SCRIPT gets a block when its filter is top-level

The channel filter for every data type is the top-level filter key, because internal/expr is generic over the message type: one key, compiled against whichever tree or claim the channel carries. Steps cannot be arranged that way. A v2 step addresses a segment and a field, an X12 step addresses a fixed-width element, and the types are different, so one yaml key would have to hold whichever the data type implied - which is a field that means different things depending on another field, and the failure mode is a step that silently does nothing.

So this follows hl7v3, x12, ncpdp and delimited: the steps live in the format's own block, and the top-level transformations key is refused on a SCRIPT channel rather than accepted and ignored.

Defined as ScriptOptions in internal/config.

KeyTypeRequiredDefault
transformationslist of hl7v3.Stepno—

25.26.1 transformations

Transformations are the declarative steps, addressing the prescription as an XML tree.

hl7v3.Step rather than a type of its own, and that is not laziness. A prescription and a v3 document are both XML, the path grammar is the same one, and internal/eprescribe already borrows hl7v3.Path for its filter with a comment saying so - v3 and CDA already use it, and a third copy would be a third set of answers to what //a/b[2]@c means.

The steps that carry v3-only meaning are refused at load rather than being silently available. nullflavor is the one: it writes the v3 attribute stating why a value is absent, which a prescription has no equivalent of, and a step that wrote it would produce a document the receiver does not understand.

25.27 Scripts

Scripts holds the JavaScript attached to a channel.

Defined as Scripts in internal/config.

KeyTypeRequiredDefault
filtertextno—
transformertextno—
preprocessortextno—
deploytextno—
undeploytextno—
postprocessortextno—
languagetextno—
timeoutduration, such as 30s or 5mno—
includelist of textno—
file_rootslist of textno—
allowlist of textno—

25.27.1 filter

Filter runs after the expression filter, if any, and must return a boolean.

A Mirth filter script drops in here unchanged.

25.27.2 transformer

Transformer runs after the declarative steps and may modify the message.

A Mirth transformer script drops in here unchanged.

25.27.3 preprocessor

Preprocessor runs before the message is parsed, reads the text as "message" and returns the text to parse.

A Mirth preprocessor drops in here unchanged.

This is the only place a message that does not parse can be repaired. Sites use it for a stray character from a serial gateway, a segment terminator that arrived as a line feed, or a vendor padding a field with something illegal for its position. Without it, Perfuse rejects traffic that Mirth accepts today.

25.27.4 deploy

Deploy runs once when the channel starts, before it accepts anything.

This is where sites warm a lookup cache or check that something they depend on is reachable. It runs before the source binds, so a deploy script that fails stops the channel from starting rather than letting it accept messages it cannot handle.

25.27.5 undeploy

Undeploy runs once when the channel stops.

After the source has stopped accepting and after in-flight messages have finished, so it sees a channel that is genuinely idle. Its failure cannot stop a stop: a channel that could not be shut down because a cleanup script threw would be worse than one that logged and closed.

25.27.6 postprocessor

Postprocessor runs after the message has been handled and cannot change it.

The message and what happened to it are visible, so it can notify, count or record. It cannot alter what was sent: by the time it runs the sending has happened, and a script that appeared to change a delivered message would be lying about it.

25.27.7 language

Language is which scripting language these scripts are written in: javascript or lua.

Why one setting for the whole block rather than one per script

A channel's scripts share the channel map and the connector map, and they are read together by whoever maintains them. A filter in Lua beside a transformer in JavaScript would mean two languages in one review, sharing state through a map whose values would have to mean the same in both. Sites that want that can split the channel.

Why the default is javascript

Every script written before this setting existed is JavaScript and says nothing about its language. Making lua the default would silently reinterpret all of them; requiring the field would refuse them. Neither is acceptable for a setting added later.

25.27.8 timeout

Timeout bounds each script.

Zero means five seconds.

There is deliberately no way to disable it. Mirth allows a transformer to loop forever, which stops the channel, and an interface that stops accepting admissions because of a typo is not something to offer as an option.

25.27.9 include

Include names files of shared script source made available to every script on this channel, in whichever language scripts.language names.

This is Mirth's code templates. A site with forty channels usually has one library every channel calls, and without it a migration means copying that library into forty files - forty places to fix the next time it changes.

Paths are relative to the channel file, so a library beside the channels travels with them. It is a list because the alternative is one enormous file, and libraries tend to divide by subject.

The files have to be in the channel's language. This was JavaScript-only for a while, and not by refusal: a Lua channel read the files, refused a missing one, and then the runtime installed none of it, so every call into the library failed on the first message with a nil global - the exact error the refusal above exists to avoid. Both languages install it now, and a library that does not parse is refused at load naming the file.

25.27.10 file_roots

FileRoots are the directories FileUtil may read and write.

Required whenever "file" is allowed. Granting file access without naming directories used to mean the whole filesystem: a script could read Perfuse's own database, which holds password hashes, the LDAP service account password and the OIDC client secret. Creating a channel needs only the editor role, so the escalation completed the moment an administrator started it.

25.27.11 allow

Allow grants capabilities a script would otherwise be refused: "file", "database", "route".

Mirth grants all of these to every script; requiring them to be named means a transformer cannot quietly start reading the filesystem after a copy-paste.

25.28 Shadow

Shadow runs a candidate version of a channel beside the live one and reports where they differ.

This is the answer to the question that makes interface work frightening: how do you know a change is safe? Not by reading it. A transformation is a program operating on messages whose variety nobody has catalogued, and the case that breaks is always the one nobody thought of - the patient with two identifiers, the result with an empty units field, the A08 that arrives before its A01.

A channel test proves the cases somebody thought of. Shadow mode proves the cases that actually arrive, against real traffic, before the change carries any of it.

Two properties make it safe, and both are structural rather than configurable:

Defined as Shadow in internal/config.

KeyTypeRequiredDefault
channeltextyes—
samplenumberno1
comparelist of textno—
ignorelist of textno—
max_differenceswhole numberno100
timeoutduration, such as 30s or 5mno5s

25.28.1 channel

Channel is the path to the candidate channel file.

A separate file rather than an inline block, on purpose: the candidate is meant to become the live channel, so it should be the artefact that gets reviewed, committed and promoted, not a fragment that has to be extracted.

25.28.2 sample

Sample is the fraction of messages to shadow, from 0 to 1. Defaults to 1.

Worth lowering only on a busy feed, and worth understanding before doing so: sampling reduces cost and reduces confidence in exactly the same proportion, and the message that would have shown the difference is the unusual one.

25.28.3 compare

Compare limits the comparison to these field paths.

Empty compares everything.

25.28.4 ignore

Ignore excludes field paths from the comparison.

Needed in practice, because a channel that stamps a timestamp or a sequence number differs on every single message and would report a difference rate of 100% while telling you nothing.

25.28.5 max_differences

MaxDifferences bounds how many differing messages are kept for inspection.

Defaults to 100.

25.28.6 timeout

Timeout bounds one shadow run.

Defaults to 5s.

Separate from the channel's own timeout, and shorter. A candidate with a runaway script must not accumulate goroutines behind the live traffic.

25.29 XDS Code

XDSCode is a coded value in destination settings: code, coding scheme (an OID) and display.

Defined as XDSCode in internal/config.

KeyTypeRequiredDefault
codetextyes—
schemetextyes—
displaytextno—

26 How Each Component Works

Perfuse is built from small packages, each with one job. This chapter is each one's own account of what it does and why it is built the way it is, taken from the source. Where a decision has a reason that is not obvious, the reason is here rather than in a commit message nobody will find.

26.1 fhir

Package fhir implements FHIR resources, serialisation and validation.

Version handling is the first design decision, because "FHIR" on its own does not identify a wire format. R5 is the latest published release and is the default here. R4 is what the installed base and US regulation actually consume, so it is fully supported rather than treated as legacy. R6 is in ballot at the time of writing and is deliberately not implemented: shipping a guess at an unpublished specification would be worse than not supporting it.

The resource model is R5-shaped. Where R4 differs, the difference is applied when serialising rather than by keeping two parallel sets of structs, which is how a codebase ends up with two subtly divergent definitions of Patient.

Source: github.com/biodream-llc/perfuse/fhir

26.2 main

Command xmlordergen writes the element order FHIR XML needs, from a core package's StructureDefinitions.

go run ./fhir/xmlordergen hl7.fhir.r4.core-4.0.1.tgz | gzip -9 -n > fhir/xmlorder_r4.txt.gz

JSON objects may hold their members in any order; XML may not: every element must come in the order its definition lists. Each output line is a path and its children in that order, each child with how to find its own children: "@Path" (an element defined inline, a backbone), "#Path" (the same as another element), or its types.

Source: github.com/biodream-llc/perfuse/fhir/xmlordergen

26.3 hl7

Package hl7 reads HL7 version 2 messages.

It has no dependencies outside the standard library, and a test enforces that. It does no network I/O, writes no files and starts no goroutines, so it is safe to use from anywhere.

Reading a message

msg, err := hl7.Parse(raw) if err != nil { return err } mrn := msg.MustGet("PID-3.1") name := msg.MustGet("PID-5.1") msgType, event, _ := msg.Type() // "ADT", "A01"

Paths are the notation people already use when they talk about HL7: segment, field, then optional component and subcomponent, as in PID-5.1 or OBX-5.2.3. Repeats are addressed with square brackets, as in PID-3[2].1.

Get returns an error for a path it cannot parse; MustGet returns the empty string instead. Both are safe on absent segments and fields, because a real feed is full of both and a parser that panics on missing data is useless for the job. Absence is not an error — a message that simply does not carry PID-8 yields "" rather than a failure.

Absent and empty

HL7 distinguishes a field that was not sent from one sent deliberately empty, and clinically those can mean different things - "no allergies recorded" is not "no known allergies". Value.Exists reports the difference where callers need it, while String flattens both to "" for the common case.

Copying, and the one way to get this wrong

Parse copies its input, so the returned Message stays valid however the caller reuses its buffer. That costs about 3% and one allocation per message.

WithZeroCopy skips the copy and keeps a reference to the caller's slice instead. It is faster, and it makes the Message unsafe to use after that slice is written again. Reading into a reused buffer is the normal, efficient way to read from a network in Go, so the trap catches careful code, and the symptom is silently wrong field values rather than an error - one patient's data under another patient's name. Use it for input that is immutable or outlives the Message, and not otherwise.

Acknowledgements

AckFor builds an ACK or NAK for a message, including for one that failed to parse, which is the case that matters most: a sender needs a reply even when what it sent was not a message. See AckOptions.

What this package does not do

It does not write or modify messages. It has no notion of message structure beyond segments, so it will not tell you that a PV1 belongs to a particular visit group. It does not validate against a version's tables. Those are deliberate omissions rather than oversights: this is an indexer for real traffic, which is frequently not standard-conformant and still has to be routed.

Source: github.com/biodream-llc/perfuse/hl7

26.4 admit

Package admit bounds how much work is in flight at once.

The engine spawns a goroutine per destination per message. Goroutines are cheap; what they hold is not. Each outbound delivery holds a socket at the far end and a file descriptor here, and it holds them for the whole retry budget - with the defaults, five attempts at a thirty second timeout plus fifteen seconds of backoff, so nearly three minutes. A few hundred senders against one receiver that has gone quiet is therefore a few hundred descriptors held for minutes, and the process runs out.

Running out is the failure worth preventing, because it does not surface where it happened. It surfaces as "too many open files" somewhere unrelated: the database cannot open its journal, the interface stops accepting, a log cannot be written. The engine looks broken in three places at once and the cause is a partner system that stopped answering.

Two limits, and the second matters more than the first:

A total, so the process stays inside its descriptor budget.

A limit per destination, so one receiver cannot consume the total. Without it a single hung partner takes down every other channel, which is worse than having no limit at all: the blast radius grows from one interface to all of them.

Source: github.com/biodream-llc/perfuse/internal/admit

26.5 alerts

Package alerts watches the metrics and says when something is wrong.

The point is that nobody should have to be looking at the dashboard. "Failed transactions remain queued without timely investigation" is a documented operational failure in this class of software, and the reason is always the same: the information was on a screen nobody was in front of at four in the morning.

Three decisions shape it.

An alert must clear itself. A notification that fires and never resolves trains people to ignore notifications. Every rule here is evaluated against current state, so a receiver coming back closes the alert it opened.

Alerts are debounced by duration, not by count. "Error rate above 5% right now" fires on a single unlucky message during a quiet hour. "Above 5% for two minutes" does not. Every rule carries a For duration and nothing fires until the condition has held that long.

What is sent contains no patient data. An alert carries a channel name, a destination name, a number and a threshold. Those come from configuration and from counting. A webhook goes to a chat room, a chat room has a scrollback, and a scrollback is not somewhere a patient identifier should end up.

Source: github.com/biodream-llc/perfuse/internal/alerts

26.6 amqp

Package amqp is an AMQP 1.0 client: one connection, one session, sending and receiving links, settled after the receiver says so.

AMQP 1.0 is what Azure Service Bus and Event Hubs speak, what RabbitMQ 4 speaks natively, and what ActiveMQ and Artemis speak beside STOMP. Written here for the reason the STOMP client and the AWS signer are: a full client library would be most of the binary's dependency list for four performatives that matter.

What it does: SASL PLAIN or ANONYMOUS (Service Bus takes a shared access key name and key as PLAIN), TLS, open/begin/attach, credit-based flow, a transfer split into frames when it exceeds the peer's maximum frame size, and dispositions - accepted, released, rejected. The sender waits for the broker's disposition before reporting success, so "sent" means the broker took custody. Empty frames keep the connection alive inside the peer's idle timeout, which Service Bus enforces.

What it does not: transactions, multiple sessions, link recovery and message annotations beyond reading them.

Source: github.com/biodream-llc/perfuse/internal/amqp

26.7 analyze

Package analyze turns a parsed Mirth channel into something a human can act on: a plain description of what the channel does, and an honest list of what will not survive a migration.

The design rule here is that a finding must be specific enough to act on. "This channel uses JavaScript" is not a finding. "Destination 1 calls java.util.Date, which has no equivalent outside the JVM" is.

Source: github.com/biodream-llc/perfuse/internal/analyze

26.8 api

Package api serves the HTTP interface used by the web front end.

The important design constraint: channels are read from and written to files on disk. The API is an editor for those files, not a different storage system that happens to export YAML. A channel created in the browser and one written by hand are the same artifact, which is what makes "export to share" trivial and keeps git as the history of record.

Source: github.com/biodream-llc/perfuse/internal/api

26.9 as2

Package as2 is Applicability Statement 2 (RFC 4130): EDI over HTTP, signed and encrypted with S/MIME, with a signed receipt (an MDN) that names a digest of what arrived.

Payers and trading partners use it for X12 and EDIFACT.

The S/MIME is internal/cms, the same code Direct messaging uses. What is here is AS2's own: its headers, the message integrity check (MIC), and the receipt.

Source: github.com/biodream-llc/perfuse/internal/as2

26.10 attach

Package attach moves large payloads out of a message and puts them back.

An HL7 message carrying a scanned report or a PDF is ordinary in document workflows, and the payload is routinely hundreds of times larger than the message around it. Storing that inline means the message store grows at the rate of the documents rather than the traffic, a message queued for five destinations is copied five times, and the interface has to stream megabytes to show somebody a patient name.

So the payload is extracted, stored once by content, and replaced in the message with a token. Mirth calls these attachment handlers and it is a documented migration feature, which is why the token format here is designed to be recognisable rather than clever.

What this package does not decide

It does no storage and no I/O. Extraction produces the rewritten message and the payloads; a Store somewhere else decides where those go. That separation is what makes the extraction testable without a database, and it is also what lets the same logic run in the WASM playground where there is no database at all.

Source: github.com/biodream-llc/perfuse/internal/attach

26.11 authlimit

Package authlimit throttles failed authentication attempts by source address.

It exists because two listeners needed the same thing and one of them had nothing. The FHIR server got a limiter when it got authentication; the console login had none at all, and a measurement showed twenty password guesses a second sustained against the administrator account with no delay and no lockout. At that rate a weak password is gone overnight and the only trace is a log full of identical warnings.

Deliberately not a general-purpose rate limiter. It throttles failures, not requests: a busy legitimate client is never affected, and the only thing that accumulates is being wrong.

Source: github.com/biodream-llc/perfuse/internal/authlimit

26.12 awsmsg

Package awsmsg speaks to Amazon SQS and SNS with the standard library and the shared Signature Version 4 signer.

The same argument as s3put: the AWS SDK would double the size of the bill of materials for four API calls. SQS uses its JSON protocol (AWS JSON 1.0, X-Amz-Target AmazonSQS.<Action>), which AWS documents as the current one; SNS uses its query protocol, which is the only one it has. Both work against LocalStack and VPC endpoints through an endpoint override.

Source: github.com/biodream-llc/perfuse/internal/awsmsg

26.13 awsv4

Package awsv4 signs requests to AWS with Signature Version 4, using only the standard library.

It began inside s3put. SQS, SNS and reading from S3 need the same signature with a different service name and with a query string, and a second copy of the signing code would be a second place for the one bug that matters - a signature mismatch reads exactly like wrong credentials. So it lives here, checked against AWS's published test vectors, and every AWS connector uses it.

Credentials are static keys or the standard environment variables. There is deliberately no instance-metadata credential chain: it means IMDS calls, caching and refresh, which is where a small implementation stops being small, and Perfuse refuses metadata addresses as destinations by default anyway.

Source: github.com/biodream-llc/perfuse/internal/awsv4

26.14 azblob

Package azblob reads and writes Azure Blob Storage with the standard library: Shared Key or SAS authorisation over plain HTTP calls.

The same reasoning as s3put: the Azure SDK is large and the operations an integration engine needs are five - put, get, list, delete, and setting the access tier. Shared Key signing is specified exactly ("Authorize with Shared Key", Azure Storage REST), and Azurite, Microsoft's emulator, checks it the way the service does.

Source: github.com/biodream-llc/perfuse/internal/azblob

26.15 branding

Package branding stores a customer's logo and product name so an installation can present itself as their own product rather than as Perfuse.

Why the logo is not a setting

Everything else an operator can change is a scalar in the settings file. A logo is a few hundred kilobytes of binary, and putting it there would mean base64 in a YAML file that a person is meant to be able to read and hand-edit. It lives beside that file instead, and only its presence is visible through the settings surface.

The part that matters: an uploaded image is untrusted input

A logo is uploaded by an administrator and then served to every browser that loads the console, including on the sign-in page before anyone has authenticated. That makes it the most widely distributed piece of attacker-supplied content in the product, so it gets treated accordingly:

Source: github.com/biodream-llc/perfuse/internal/branding

26.16 cda

Package cda reads clinical documents.

The design starts from how these documents actually travel, which is not as files. A discharge summary leaves an EHR inside an HL7 v2 MDM^T02 message, with the whole document base64-encoded in OBX-5 and described by a TXA segment. That is thousands of messages a day in a mid-sized hospital, and it is why this package sits next to an HL7 v2 parser rather than in a document toolkit: the v2 half is already here, so the pipeline from "a message arrived" to "a FHIR DocumentReference exists" is short.

Three things are deliberately in scope and one is deliberately not.

In scope: reading a document and saying what it contains in plain terms; checking that the human-readable narrative and the coded entries agree; and converting to FHIR. The narrative check is the unusual one. Every C-CDA section carries the same information twice, once as text for a clinician and once as codes for a machine, and they are supposed to match. In practice they often do not, and nothing checks it - not the certification tooling, not the engines. When the text says a patient is allergic to penicillin and the coded entry says something else, one of the two readers acts on the wrong information. It is a patient safety defect that is mechanically detectable, so it is detected here.

Not in scope: generating conformant C-CDA. Writing one means satisfying several hundred template rules and a Schematron, and producing documents that are 95% right would fail certification in ways that are miserable to debug. Reading, checking and converting is most of the value for a fraction of the risk.

Source: github.com/biodream-llc/perfuse/internal/cda

26.17 channeltest

Package channeltest runs tests against a channel definition.

A channel is a program. It filters, rewrites and routes clinical messages, and it is edited under pressure by people who cannot try it against production. The absence of a way to test one is the single biggest reason interface work is frightening, and "I changed the transformer and nothing looked different in the message browser" is not a test.

The central decision is that a test runs the message through the real transformation path — the same filter, the same declarative steps, the same script engine, in the same order — with only the transport replaced. A test that exercised a reimplementation would pass while the channel failed, which is worse than having no test at all because it actively misleads.

Source: github.com/biodream-llc/perfuse/internal/channeltest

26.18 chanscope

Package chanscope narrows reads and writes on a context to some channels.

It rides on the context rather than being a parameter of each query because there are dozens of queries across the message store and the delivery queue, and the limit is a property of who is asking, not of the question. The API sets it once per request for a person limited to some channel groups; every count, list, chart, search and queue action then agrees with the others, so nobody is told a total of forty failures and shown four.

Source: github.com/biodream-llc/perfuse/internal/chanscope

26.19 cluster

Package cluster lets several Perfuse servers share one PostgreSQL store and decide, between them, which runs what.

The problem it solves

One server is one point of failure: when it stops, every interface stops. Two servers on one database solve that only if they never both run the same channel, because two MLLP listeners on one feed take turns with the sender, two queue workers deliver the same message twice, and two DICOM pollers each report the same study.

How

A lease is a row naming who holds something, with a counter its holder moves on at every renewal, every few seconds. No clock is compared with another: a holder that cannot renew (crashed, cut off from the database) stops what the lease covers once its own clock says the lease is nearly done, and another server takes it only after its own clock has watched the counter stand still for the whole lease. Each measures an interval on its own clock, so the servers' clocks, and the database's, need not agree; they need only run at about the same rate. A clock that jumps (a virtual machine resynchronising, an administrator setting the time) changes nothing: the first version used the database's clock for expiry, and a test found a database whose clock jumped thirteen seconds hand every lease to a new server while the old ones still ran them.

Between the holder stopping and the next one starting there is a short gap with nobody running, which costs a sender a few seconds of refused connections, and never a moment with two.

Two modes:

Each lease carries an epoch, counted up whenever its holder changes, which names a holding in the history.

Source: github.com/biodream-llc/perfuse/internal/cluster

26.20 cms

Package cms is the Cryptographic Message Syntax (RFC 5652) as S/MIME and Direct secure messaging use it: SignedData (sign a message, verify one and say who signed it) and EnvelopedData (encrypt a message to its recipients' certificates, decrypt one with our key).

Only what the Direct Applicability Statement and its Implementation Guide require: RSA with SHA-256 (SHA-1 accepted for reading only, since older HISPs still send it), RSA key transport with PKCS#1 v1.5 or OAEP, and AES-128 or AES-256 in CBC mode. The cryptography is the standard library's; this package is the ASN.1 around it.

Source: github.com/biodream-llc/perfuse/internal/cms

26.21 cms0057

Package cms0057 holds the payer-side pieces of the CMS Interoperability and Prior Authorization final rule (CMS-0057-F) that are about data rather than transport: adjudicated claims as CARIN Blue Button ExplanationOfBenefit resources, prior authorisations as Da Vinci PDex ExplanationOfBenefit resources, HRex member matching for the Payer-to-Payer API, the member list behind the Provider Access API, and the prior authorisation metrics a payer has to publish every year.

The FHIR endpoints that serve these live in fhirserver; this package decides what goes in them.

Drugs are out of scope throughout, as they are in the rule's prior authorisation provisions: no pharmacy ExplanationOfBenefit, no formulary, no NCPDP.

Source: github.com/biodream-llc/perfuse/internal/cms0057

26.22 codeset

Package codeset holds mapping tables that outlive the channel that first needed one.

The domain tax is largely re-deriving a mapping somebody already derived. A site's sex codes, patient classes, order statuses and facility identifiers get translated the same way in every channel that touches them, and today each of those translations is a separate table inside a separate channel. Forty channels means forty copies and forty places to fix the next time the receiving system changes.

So a table becomes a named object referenced by name. That much is obvious and it is the smaller half of the value.

The half nobody builds

The larger half is provenance. Most of what makes an interface hard to maintain is not the mapping - it is that nobody knows *why*. "We strip leading zeros because the lab's MRN column is numeric" is knowledge that exists in one person's head and leaves the building when they retire, and the mapping that remains becomes something nobody dares change and nobody dares delete.

So every table records who decided and when, every entry may record its own reason, and both survive into the file that goes into version control. A mapping with a reason can be argued with. A mapping without one becomes superstition.

Why the file is separate from the channel

Because it is shared. A table inside a channel is a table only that channel can use, which is the problem. And a shared table needs its own history: "who changed the sex mapping, and when" is a question about the table, not about whichever channel happened to be edited that day.

Source: github.com/biodream-llc/perfuse/internal/codeset

26.23 compliance

Package compliance implements the CMS ADT Event Notification workflow required by 42 CFR 482.24(d) since May 2021.

Hospitals must electronically notify a patient's established providers — primary care, specialists, post-acute care facilities — when the patient is admitted, discharged, or transferred. The regulation exists because patients fall through the cracks during transitions: a PCP who does not know about a discharge cannot schedule the follow-up that prevents a readmission.

This package provides the machinery to: - Track which providers are established for each patient. - Route ADT events to the correct providers at the correct priority. - Record every notification sent, with delivery status, for CMS surveyor audits. - Generate compliance reports showing notification rates and failures.

Thread safety is required throughout because ADT messages arrive concurrently from multiple channels and the notification pipeline runs in parallel.

Source: github.com/biodream-llc/perfuse/internal/compliance

26.24 config

Package config loads channel definitions from files.

Configuration lives in files rather than a database, which is the main thing Perfuse does differently from Mirth. Mirth keeps channels in its own database, which is why version control is a separate product, why promoting a channel from test to production is difficult, and why nobody can answer "what changed last Tuesday" without a support call. Files give git, diffs, review, CI and environment promotion with no extra machinery.

Validation is deliberately strict and happens at load: unknown fields are an error, filter expressions are compiled, addresses are checked. A channel that cannot be understood must refuse to start rather than run in a way nobody intended.

Source: github.com/biodream-llc/perfuse/internal/config

26.25 contract

Package contract turns what a feed looks like into what a feed must look like.

A profile answers "what am I receiving". That is useful once. The expensive problem is the day it changes: a vendor upgrade drops a field, a new code appears, a segment starts repeating. None of those is an error - the messages are still valid HL7 and every engine in the world accepts them - so nothing notices until a receiver falls over, usually weeks later, and the investigation starts from the wrong end.

A contract is the profile written down as an expectation and then checked. It is contract testing, which is a solved idea in software generally and almost entirely absent from healthcare integration.

Why the unit is a rate and not a message

The obvious design is to check each message and complain when one fails. That produces a system nobody can use. Real feeds contain a proportion of genuinely odd messages - a test message from the vendor, a patient with no recorded sex, a manual entry - and alerting on each one trains people to ignore the alerts, at which point the feature is worse than nothing because it has consumed the attention it needed.

So an expectation is about a proportion over a window: "PID-3 is populated in at least 99% of messages". One odd message never fires. Five percent of messages losing their medical record number does.

A contract describes what arrives, not what leaves

This is the distinction people get wrong first, including me while integration-testing it. Perfuse stores the message as it arrived - that is deliberate, because the stored message is evidence of what a sender sent - so a contract is checked against the incoming feed and not against the output of the channel's transformations.

That is the right default by a wide margin: the thing that changes without warning is the sending system, and the point of a contract is to notice. But it means an expectation written about a mapped value ("after mapping, only the lab's codes should remain") will fail immediately and confusingly, because the codes it names never appear in the traffic being profiled. Anything about what leaves belongs in a channel test, which runs the real transformations.

Why a violation names the tolerance it broke

"PID-3 failed" tells somebody nothing. "PID-3 was populated in 94.2% of the last 500 messages; the contract requires 99%" tells them how bad it is, how confident to be, and whether it is worth waking somebody. The numbers are the message.

Source: github.com/biodream-llc/perfuse/internal/contract

26.26 crd

Package crd is a Da Vinci Coverage Requirements Discovery service (CRD 2.2.1): the payer's side of the CDS Hooks conversation that tells a clinician, while the order is still a draft, whether it is covered, whether it needs prior authorisation, and what documentation the payer will want.

CMS-0057's Prior Authorization API is in practice three Da Vinci guides together: CRD to say what is required, DTR to gather it, PAS to submit it. Perfuse had PAS. This is the first of the other two; DTR's $questionnaire-package is on the FHIR endpoint.

The coverage decisions come from a rules file, because they are the payer's policy and change without software releases: a code (or a list of codes) and what applies to it - covered or not, prior authorisation needed or not, documentation, and the DTR questionnaire that collects it. An order matching no rule is answered "conditional" and "indeterminate", which is what CRD says to return when the service cannot decide - never a guess that it is covered.

Responses follow the guide: a systemAction updating each order with the coverage-information extension, which the EHR stores on the order and later sends on with the prior authorisation; and a card per order, so a clinician sees it.

Source: github.com/biodream-llc/perfuse/internal/crd

26.27 dashboards

Package dashboards defines the dashboards Perfuse ships for each kind of person who watches it, and how a person is given one: by their role, by a directory group the dashboards file maps, or by the view they saved.

Dashboards are made of tiles, and tiles show aggregates only - counts, rates, times, states. None shows a message, a patient or an identifier, so a dashboard can be given to a department manager or put on a wall screen without widening who sees patient data. A tile that needs data Perfuse does not yet record is not faked: the dashboard lists it under Planned.

Full flexibility is Grafana's job, not this package's. Every tile that has a metric behind it exports as a Grafana panel over Perfuse's /metrics, so a site that wants more than light configuration takes the dashboard there.

Source: github.com/biodream-llc/perfuse/internal/dashboards

26.28 davinci

Package davinci says which release of the Da Vinci prior-authorization guides (CRD, DTR, PAS) a client speaks.

Perfuse answers in two: 2.2 (CRD 2.2.1, DTR 2.2.0, PAS 2.2.1), the newest, and 2.1 (CRD, DTR and PAS 2.1.0), the release CMS-0057-F's recommended list points at once the 2.0.1 releases' US Core 3.1.1 basis expired. The two differ in shapes a client reads by name - DTR's $questionnaire-package parameters (PackageBundle in 2.1, packagebundle in 2.2), PAS's $inquire answer (one Bundle in 2.1, a Parameters of them in 2.2), extensions that exist in only one of them - so one answer cannot serve both, and the server has to know which it is talking to.

It is told, most specific first: by the request itself (CRD's davinci-crd.requestedVersion; the version on a profile the input claims, profile|2.1.0), then by the client's registration (davinci_version on a SMART or CDS client), then by the server's own setting (serve -davinci-version), which defaults to the newest.

Source: github.com/biodream-llc/perfuse/internal/davinci

26.29 dbtime

Package dbtime formats timestamps so that sorting them as text sorts them as time.

Every store in Perfuse keeps timestamps as TEXT in SQLite and relies on string comparison for ordering and for range queries: ORDER BY received_at DESC, WHERE expires_at < ?, WHERE at >= ?. That is a reasonable design - it keeps a database somebody can read with the sqlite3 shell - but it only works if the text form is fixed width.

time.RFC3339Nano is not. Its documentation says it removes trailing zeros from the seconds field, so the same clock produces strings of different lengths:

20:56:33.12345Z <- 123450 microseconds, trailing zero dropped 20:56:33.123456Z <- 123456 microseconds

Compared as text, the first is *greater*, because after the shared prefix "12345" it has 'Z' where the second has '6', and 'Z' sorts above every digit. So the earlier instant sorts later. Roughly one adjacent pair in ten is affected, which is why it presented as an intermittent test failure rather than as an obviously broken feature.

What it actually broke: the audit log and the message browser both order by a timestamp and can therefore list entries from the same second in the wrong order. An audit trail is a compliance artefact and a message browser is how somebody reconstructs what happened, so in both cases the order is the point.

Layout below uses .000000000 rather than .999999999. Both print nanoseconds; the difference is that zeros are significant in the first and dropped in the second.

Source: github.com/biodream-llc/perfuse/internal/dbtime

26.30 delimited

Package delimited parses records separated and split by configured characters.

This is Mirth's Delimited data type: CSV, tab-separated, pipe-separated, fixed-column files, and the long tail of formats a laboratory analyser or a bureau service emits because somebody wrote it in 1994 and it works.

Deliberately not a general CSV library. What matters here is what a healthcare feed does that a well-formed CSV does not: a header row that may or may not be present, a trailing empty line that must not become an empty record, columns referenced by name so a transformation does not break when a supplier inserts one, and the ability to treat a whole file as one message or each row as its own.

The last of those is the decision that shapes everything else. A file of five thousand results is either one message or five thousand, and the two are not interchangeable: one message means one acknowledgement and one failure for the whole file, and five thousand means each row can be filtered, transformed and retried on its own.

Source: github.com/biodream-llc/perfuse/internal/delimited

26.31 dicom

Package dicom reads DICOM data sets.

DICOM is where radiology lives, and a hospital integration engine that cannot speak it is shut out of imaging entirely. Mirth has a DICOM listener and sender, so this is a migration blocker for any site doing modality work.

Scope, stated plainly

This package parses data sets: the file meta information, the elements, the transfer syntax, and the values that matter for routing and identification. It does not decode pixel data, and it never will as far as this package is concerned - an integration engine moves images, it does not display them, and a decoder is a large body of format-specific work whose only purpose here would be to make the binary bigger.

Reading metadata without touching pixels is also what makes it fast on the traffic that matters: a chest CT is hundreds of megabytes of pixels behind two kilobytes of the information anybody routes on.

Why the value representations are handled explicitly

A DICOM element carries a two-letter value representation saying how to read its value, and the rules differ per VR in ways that cannot be guessed: some have a two-byte length and some four, some are padded with a space and some with a null, and numeric strings can hold several values separated by backslashes. Getting one wrong does not produce an error, it produces a plausible-looking wrong value - a patient name read from the middle of a date, or a study identifier off by one byte.

Source: github.com/biodream-llc/perfuse/internal/dicom

26.32 dicomweb

Package dicomweb speaks DICOMweb (DICOM PS3.18): QIDO-RS to search, WADO-RS and WADO-URI to retrieve, STOW-RS to store, over HTTP.

It is how viewers, AI services and cloud archives reach imaging now: OHIF, Google Cloud Healthcare, Azure Health Data Services, AWS HealthImaging, Orthanc's and dcm4chee's DICOMweb interfaces. An integration engine that only speaks the network protocol from 1993 (DIMSE: C-STORE, C-FIND) can feed a PACS but not a browser.

The package has four parts: a data set reader that walks the whole object, sequences included, and knows where the pixel data is without loading it; the DICOM JSON model (PS3.18 F.2); multipart/related, which is how DICOMweb moves several objects in one request; and an archive with an index, which is what QIDO and WADO answer from.

Source: github.com/biodream-llc/perfuse/internal/dicomweb

26.33 direct

Package direct is Direct secure messaging as an edge system: a message to a Direct address is signed with this address's certificate and encrypted to the recipient's, as S/MIME, and sent to a HISP by SMTP; a message received from the HISP (by SMTP or collected over IMAP) is decrypted and its signature checked against the trust anchors before anything reads it.

It follows the Direct Project Applicability Statement for Secure Health Transport 1.2 and the XDM specification (IHE ITI TF-1 Annex 15) for documents with metadata. It is not a HISP: the HISP's accreditation, its certificate policy and its directory stay with the HISP.

Source: github.com/biodream-llc/perfuse/internal/direct

26.34 drug

Package drug handles the codes and quantities that identify a medicine.

Split out from the pharmacy standards that carry them, because the same drug code appears in an NCPDP claim, a SCRIPT prescription, an HL7 RXE segment and a FHIR MedicationRequest, and a conversion that lives in one of those places is a conversion the other three do without.

Source: github.com/biodream-llc/perfuse/internal/drug

26.35 dsdr

Package dsdr signs and verifies CDA documents as the HL7 Implementation Guide for CDA Release 2: Digital Signatures and Delegation of Rights, Release 1 (October 2014) describes - the electronic signature standard HIPAA adopts for claims attachments in CMS-0053-F, 45 CFR 162.2002(e), with compliance from 26 May 2028.

What the guide asks for, and where it is done here:

Where the guide is silent, the choices made are named: it does not give a namespace for its own elements (digitalSignature, authorizedSigner, SignaturePurpose), so they are put in urn:hl7-org:sdtc; and the purpose is also stated as a XAdES CommitmentTypeIndication with the ASTM OID, which XAdES tools read and the guide's own element they do not.

Delegation of rights (a delegated signer with a SAML assertion) is not produced; a signature carrying one is reported as such.

Source: github.com/biodream-llc/perfuse/internal/dsdr

26.36 e4x

Package e4x makes Mirth Connect's JavaScript run unchanged.

Mirth scripts are written for Rhino, which implements E4X: XML is a native type with its own syntax. The engine here is goja, which does not, and the gap is not only a runtime one. Three pieces of E4X are *syntax*, so goja's parser rejects the file outright before any object model could help:

for each (var seg in msg..OBX) { } // for-each, and the descendant operator var z = <ZZZ><ZZZ.1>value</ZZZ.1></ZZZ>; // an XML literal var id = node.@root; // an attribute reference

This file rewrites those into ordinary ECMAScript that goja parses, leaving the semantics to the runtime object in xml.go. Everything else about E4X - property access by element name, indexing repetitions, assignment, toString - is already valid JavaScript syntax and needs no rewriting.

The rewriting is done with a real lexer rather than regular expressions, because the alternative corrupts working scripts. A pattern for the descendant operator finds one inside "http://example.org", a pattern for XML literals finds one in "a < b && c > d", and a script that silently changes meaning is far worse than one that refuses to load.

Source: github.com/biodream-llc/perfuse/internal/e4x

26.37 edifact

Package edifact reads and writes UN/EDIFACT interchanges (ISO 9735): the UNA service string advice, the UNB/UNZ interchange envelope, optional UNG/UNE functional groups, and UNH/UNT messages, with the release character and, for syntax version 4, the repetition separator.

It checks the envelope's counts and references, addresses data elements for filters, splits an interchange into one per message, and builds a CONTRL acknowledgement.

It reads any message type (ORDERS, INVOIC, IFTSTA, MEDRPT ...); it does not hold the directories that say which segments a message type may contain, so it checks structure, not content.

Source: github.com/biodream-llc/perfuse/internal/edifact

26.38 egress

Package egress decides whether this engine may connect outward to an address.

It exists because a destination can be pointed anywhere, and one combination turns that into a way to read the internal network: a SOAP destination with a response transformer returns the receiver's reply to a script, so an editor can author a channel that fetches an internal URL and writes the body into a log or a message.

Demonstrated rather than theorised: a SOAP destination aimed at a local HTTP server put that server's response body into a channel log line.

The proportionate answer is not to forbid outbound connections - delivering messages to configured endpoints is the entire purpose of the software, and an editor is meant to configure them. It is to refuse the specific addresses that are never a message destination and are always a credential store.

Source: github.com/biodream-llc/perfuse/internal/egress

26.39 engine

Package engine runs configured channels.

A channel is a source, a filter, and a set of destinations. The runtime's job is to be honest about what happened to each message: an acknowledgement should mean what the configuration says it means, a message that is filtered out should be distinguishable from one that was delivered, and a message that could not be delivered anywhere must not be reported as accepted.

Source: github.com/biodream-llc/perfuse/internal/engine

26.40 epa

Package epa is NCPDP SCRIPT electronic prior authorization, the payer's side: PAInitiationRequest, PARequest, PAAppealRequest and PACancelRequest in, and their responses out.

CMS-0062-P proposes that impacted payers support it from 1 October 2027, and Medicare Part D already requires it. A request is decided the way every prior authorization on this server is: it becomes a Da Vinci PAS request (a Claim, the MedicationRequest and the prescriber's answers as a QuestionnaireResponse) and goes to the same rules file, decision service and review queue. The formulary says which drugs need one and which questions to ask, from the same FHIR Questionnaire Da Vinci DTR fills in.

NCPDP's schemas are licensed. Element names and order here follow the public Surescripts ePA companion guide and its examples; two elements the public material never shows (the reason inside Closed and inside Denied) follow the SCRIPT pattern used elsewhere and are named in the manual so a payer can check them against its own copy of the schema.

Source: github.com/biodream-llc/perfuse/internal/epa

26.41 eprescribe

Package eprescribe reads and writes NCPDP SCRIPT, the standard prescriptions travel in.

Named for what it does rather than for the standard, because internal/script is already the JavaScript engine and two packages called script in one tree is a mistake waiting to be made at an import line.

SCRIPT is XML, unlike the Telecommunication Standard that carries the resulting claim. A prescriber sends a NEWRX to a pharmacy; the pharmacy sends back RXFILL to say what happened, REFREQ to ask for a refill, or RXCHG to ask for a change. Every one of those is a message here.

Where the danger is

Two fields in a prescription are dangerous when misread rather than when missing.

Substitutions is the first. A zero means substitution is permitted and a one means dispense as written. It reads backwards to anybody expecting a flag where one means yes, and getting it wrong dispenses a generic where the prescriber required the brand - or refuses a substitution that was allowed, which is the same error costing the patient money instead. So this package refuses to guess: an absent Substitutions element is an error rather than a default, because both defaults are wrong.

Refill count is the second. A schedule II prescription may not be refilled at all, and a system carrying a refill count across from a non-controlled template produces a valid-looking prescription that a pharmacy must refuse.

What has not been verified

The message structure follows the published SCRIPT element names. No message from this package has been sent through a real routing network such as Surescripts, and certification by one is a commercial process rather than a technical one. This parses and builds the format; it does not claim certification.

Source: github.com/biodream-llc/perfuse/internal/eprescribe

26.42 equiv

Package equiv proves that two engines agree, or says exactly where they do not.

This is the answer to the only question that actually blocks a migration. An interface team does not refuse a new engine because it lacks features; they refuse because they cannot be sure it will do the same thing to the traffic they already have, and being wrong means lab results stop reaching doctors. No amount of feature comparison answers that. Evidence does.

The intended use is a parallel run. Both engines receive the same production messages; the incumbent keeps delivering and Perfuse delivers to nobody, which is what shadow channels already are. After two weeks the outputs are compared here, and the conversation changes from "trust this" to "here are the three messages out of four hundred thousand where we differed, and why".

Grouping is the whole feature

A naive diff of 400,000 messages produces 400,000 findings and gets closed unread. Almost every real difference has one cause: a date format, a facility code, one transformation step nobody carried across. So differences are grouped by cause - the path in the message and the kind of difference - and reported once with a count. Three thousand differences from one cause is one finding, not three thousand. That single decision is what makes the report readable, and a readable report is the difference between a migration that proceeds and one that stalls.

Values are withheld by default

Real traffic is patient data, so a comparison report is a place PHI could leak into a ticket, an email or a slide. By default this reports paths, kinds and counts - never values. That is enough to find the cause: "MSH-7 differs in 3,000 of 3,000 messages" tells an analyst it is the date format without showing one date.

Values can be enabled deliberately, because eventually somebody does need to see one, and pretending otherwise would only push them to a worse tool. It is an explicit opt-in and the report says on its face that it contains message content.

Source: github.com/biodream-llc/perfuse/internal/equiv

26.43 expr

The HL7 v2 binding.

This is the logic that used to live inline in eval.go. It is unchanged in behaviour - deliberately, because the v2 filter is what every HL7 channel runs and the point of the generic grammar was to add formats, not to alter this one.

Source: github.com/biodream-llc/perfuse/internal/expr

26.44 fdlimit

Package fdlimit finds out how many file descriptors this process may open, and raises the limit if it can.

This exists because of the shape of the failure it prevents. Every inbound connection is a descriptor, and so is every outbound delivery in flight. A receiver that has stopped answering holds a descriptor for the whole retry budget - with the defaults, five attempts at a thirty second timeout plus fifteen seconds of backoff, so nearly three minutes. A few hundred connections against one hung receiver is therefore a few hundred descriptors held for minutes.

Running out does not produce a tidy error in the place that ran out. It produces "too many open files" somewhere else entirely: the database cannot open its journal, the web interface stops accepting, a log file cannot be written. The engine appears to have broken in three unrelated places at once, and the real cause is a partner system that went quiet. That is the support call this package exists to avoid.

Two things happen at startup. The soft limit is raised to the hard limit, which usually removes the problem outright - the soft default is often a few hundred while the hard limit is tens of thousands. Then whatever is available is reported, so the engine can bound its own concurrency inside it rather than discovering it by hitting it.

Source: github.com/biodream-llc/perfuse/internal/fdlimit

26.45 fhirpath

Package fhirpath evaluates FHIRPath (HL7 FHIRPath N1, 2.0.0) over FHIR resources held as decoded JSON.

It has no FHIR model: an element's type is known when FHIR JSON says it (a choice element's suffix, such as valueQuantity, a resource's resourceType) or inferred from the JSON value otherwise. That covers what SQL on FHIR views and CQL-free quality measures need; navigation that depends on the model (a primitive's own type when it is not a choice) is answered from the value's shape.

Source: github.com/biodream-llc/perfuse/internal/fhirpath

26.46 fhirserver

Package fhirserver implements a FHIR REST server.

This exists because the useful end of a v2-to-FHIR mapper is somewhere to put the result. Being able to convert a message is a demo; being able to convert it, store it, and then find the patient again by identifier is a product.

It is a real FHIR server in the parts that matter for that: a capability statement, create, read, update, delete, search on the parameters people actually use, and transaction bundles applied atomically. It is not a complete FHIR server — no history, no chained search, no _include — and it says so in its own capability statement rather than letting a client discover the gaps.

Source: github.com/biodream-llc/perfuse/internal/fhirserver

26.47 formulary

Package formulary is a payer's drug formulary: which drugs each plan covers, on what tier, and with which limits (prior authorization, step therapy, quantity limits), and what the member pays.

One file is the single source for everything that answers a question about a drug: the Da Vinci US Drug Formulary API (published from it as FHIR), NCPDP Real-Time Prescription Benefit answers, and NCPDP SCRIPT electronic prior authorization (whether a drug needs one, and which questions to ask). A formulary that lived in three places would answer the same question three ways.

Source: github.com/biodream-llc/perfuse/internal/formulary

26.48 framing

Package framing reads and writes messages on a byte stream that is not MLLP.

Why this exists separately from mllp

MLLP is one framing: 0x0B before, 0x1C 0x0D after. It is what HL7 v2 uses over TCP and Perfuse has always spoken it.

A great deal of equipment does not. A laboratory analyser, a scale, a bedside monitor, an older billing system - each tends to have its own convention, and the conventions are all simple variations on the same three ideas: a delimiter somewhere, a fixed record length, or a length written in front of the payload.

Mirth's TCP connector covers this and Perfuse had nothing, so a site with a socket feed that was not HL7 could not use it at all.

The mistake this package is written to avoid

Reading from a stream and guessing where a message ends is the whole problem. Guess short and a message is cut in half; guess long and two messages arrive as one. Both produce output that often looks plausible, which is worse than an error, so every mode here is explicit about what terminates a message and none of them fall back to another.

A message with no framing at all is a legitimate configuration - some devices open a connection, send one message and close - but it cannot be combined with anything else and cannot be used on a connection that stays open, so it is named and validated rather than treated as a default.

Source: github.com/biodream-llc/perfuse/internal/framing

26.49 ftpconn

Package ftpconn uploads files over FTP and FTPS using only the standard library.

FTP is old, chatty and not a good protocol. It is also still how a large number of hospital systems accept files, usually because the far end is a laboratory analyser or a bureau service whose software has not been touched since it worked. Refusing to support it does not make those systems modern; it makes Perfuse unusable at those sites.

Implemented here rather than taken as a dependency for the same reason as S3: the eight-dependency bill of materials is a real argument with whoever reviews software before a hospital installs it. FTP's control channel is line-oriented text over TCP, so this is a few hundred lines of net and crypto/tls.

What is supported, and what is refused

Passive mode only. Active mode requires the server to open a connection back to the client, which every hospital firewall built this century blocks, and offering it would mean people configuring it and then debugging a firewall.

Explicit FTPS (AUTH TLS) is supported and is the default when TLS is asked for. Implicit FTPS on port 990 is also supported because some analysers only speak that.

Plain FTP is supported and warns. It sends the password in clear text, and a site that has to use it should be told once rather than left to find out from a packet capture.

Source: github.com/biodream-llc/perfuse/internal/ftpconn

26.50 gcp

Package gcp talks to Google Cloud Pub/Sub and Cloud Storage over their REST APIs, with Google's OAuth 2.0 done here: a service account key signs a JWT (RS256, crypto/rsa) that is traded for an access token, or, on Google Cloud itself, the metadata server hands one out.

No Google SDK.

Emulators (the Pub/Sub emulator, fake-gcs-server) take no credentials: set Endpoint and leave the credentials empty.

Source: github.com/biodream-llc/perfuse/internal/gcp

26.51 generate

Package generate makes synthetic HL7 v2 messages.

Two uses. Somebody trying Perfuse needs traffic to look at, and a channel test needs a fixture that is realistic enough to exercise the thing being tested. A generator is a paid extension in Mirth, which tells you people want one.

Everything here is invented. The names come from a fixed list of obviously fictional ones, the identifiers are sequential within a run, and the addresses are made up. That is not a disclaimer, it is the design: a generator that produced plausible-looking real names would eventually put one into a test fixture, a bug report and then a repository.

Realism is spent where it matters for testing and nowhere else. Timestamps carry offsets, repeating fields actually repeat, a name sometimes has a middle initial and sometimes does not, and roughly one message in twelve is deliberately awkward: a missing optional field, an unusual but legal separator, an empty component. Feeding a channel nothing but perfectly formed messages tells you very little.

Source: github.com/biodream-llc/perfuse/internal/generate

26.52 hl7dict

Package hl7dict names the fields of HL7 v2 segments.

This exists for one screen: a message viewer that can tell you PID-5.1 is the patient's family name instead of leaving you to count carets. Anyone who has debugged an interface has done that counting by hand, and it is the single most common source of the off-by-one mistakes that put a value in the wrong field.

It is not the whole standard. HL7 v2 has hundreds of segments and thousands of fields, and the ones here are those that appear in ADT, ORU, ORM and SIU traffic. An unknown field is reported as unknown rather than guessed at.

Source: github.com/biodream-llc/perfuse/internal/hl7dict

26.53 hl7v3

Package hl7v3 reads HL7 version 3 messages.

Version 3 is a different thing from version 2 wearing the same name. Version 2 is a pipe-delimited stream of segments whose meaning is positional; version 3 is XML generated from a reference information model, where meaning is carried by element names and by a small set of datatypes that recur everywhere. Almost nothing transfers between the two beyond the clinical vocabulary.

In practice v3 arrives in one of two shapes. Either it is a CDA document, which is v3-derived and handled by the cda package, or it is an IHE transaction - PIX and PDQ v3 in particular, which is how a great many hospitals resolve patient identifiers across systems. This package targets the second.

Why the datatypes come first

The single most consequential thing about v3 is that an element can be present and explicitly null, with a stated reason. A birth date carrying nullFlavor="ASKU" means the patient was asked and declined to answer. One carrying nullFlavor="NAV" means the value exists but is not available right now, so asking again later may work. One that is simply absent means nobody recorded anything at all.

Collapsing those into an empty string is the mistake this package is arranged to prevent. It is not a cosmetic loss: a downstream system that receives an empty birth date where the source said "patient declined" will usually treat it as missing data to be chased, and a demographic match that should have been left unresolved gets resolved by a human guessing. So every datatype here distinguishes absent, null-with-reason, and present.

Source: github.com/biodream-llc/perfuse/internal/hl7v3

26.54 hl7xml

Package hl7xml converts HL7 v2 messages to and from the XML shape Mirth Connect uses, and back to ER7 encoding.

The shape is not a design choice. Mirth's scripts are written against the output of its own ER7-to-XML serialiser, so they contain expressions like

msg['PID']['PID.5']['PID.5.1'].toString()

and the promise that an existing script runs unchanged is only kept if the tree it walks has exactly the same element names, nesting and repetition behaviour. Every decision below is therefore "what does Mirth do", not "what would be tidier".

The two that matter most:

Every field is wrapped in numbered component elements even when the field has only one component, so PID-5 with the value "Doe" still produces <PID.5><PID.5.1>Doe</PID.5.1></PID.5>. Scripts index straight to .1 constantly and a tree that put the text on the field element would break nearly all of them.

MSH keeps standard HL7 field numbers, where MSH-1 is the field separator and MSH-9 is the message type. The parser in internal/hl7 indexes MSH with an offset because the separator occupies a position no delimiter splits, and that offset stops here: a script saying MSH.9 means the message type.

Source: github.com/biodream-llc/perfuse/internal/hl7xml

26.55 ibmmq

Package ibmmq sends to and reads from IBM MQ queues through the queue manager's messaging REST API (mqweb), which IBM MQ 9.1.5 and later ship on every platform, z/OS included.

It needs no MQ client library: the native MQI protocol is IBM's and undocumented, and the REST API is the supported way to reach MQ without one. (IBM MQ also speaks AMQP 1.0 on an AMQP channel; Perfuse's amqp connector covers that route.)

Reading is at least once: the first message is browsed, handed to the caller, and removed by its message id only once the caller has handled it. A message that could not be handled stays on the queue, first in line.

Source: github.com/biodream-llc/perfuse/internal/ibmmq

26.56 identity

Finding a message by what you know about it, whatever format it arrived in.

The existing content search matches metadata, offers a substring match against the payload, and can evaluate a filter expression - but the expression is evaluated against HL7 v2 only, and every expression requires knowing the path. So finding one patient's traffic meant knowing that identity lives at PID-3 in an HL7 v2 admission, at Patient.identifier in a FHIR resource, at (0010,0020) in a DICOM instance and at NM109 of an NM1*IL loop in an 837 claim. Four formats, four vocabularies, and during an incident the message being hunted might be in any of them.

This package maps a small set of concepts - who the message is about, which visit, which study, which claim - onto the place each format keeps them, so a search can be for "MRN 12345" rather than for a path. The concepts are deliberately few: these are the things people actually have in their hand when they need to find a message, which is a number off a phone call or a name off a complaint.

Why extraction happens once, at record time

The alternative is parsing payloads at query time, which is what the expression search does. That works and it is honest about being a bounded scan, but it cannot answer "every message about this patient across four feeds and two months" without reading every row. Extracting once into an indexed table turns that into an index lookup.

This is a privacy escalation, and it is optional

Patient names and identifiers currently sit inside an opaque payload column. Indexing them makes them queryable, which is the point, and also makes them enumerable, which is a real change in exposure: a database that could only be grepped can now be asked "list every patient". That is a decision for the site, not for this package, so extraction is switchable and every search through it is recorded in the PHI audit log.

Source: github.com/biodream-llc/perfuse/internal/identity

26.57 iis

Package iis speaks the transport every US immunization registry shares: the CDC's IIS SOAP web service.

In 2011 an expert panel convened by the CDC and the American Immunization Registry Association chose one SOAP interface for submitting HL7 v2 messages to Immunization Information Systems, and the registries adopted it. The WSDL is small - two operations, connectivityTest and submitSingleMessage - and the HL7 message (a VXU to report a shot, a QBP^Q11 to ask for a patient's history) travels as escaped text inside the SOAP body. The answer comes back the same way: an ACK, or an RSP^K11 carrying the history and the forecast.

The generic SOAP destination could be configured to do this, but every site would have to type the same envelope, get the namespace right, put the credentials in the body rather than in HTTP authentication, and then read the HL7 acknowledgement out of the reply to learn whether the registry kept the record. A registry answers a rejected shot with HTTP 200 and an AR inside; treating the 200 as success is how immunizations silently fail to be reported. So this package does the CDC envelope, and judges the acknowledgement, once.

What it does not do: the WSDL's username and password are the registry's application credentials and are sent as the WSDL says, in the body. Mutual TLS, where a registry requires it, is the destination's TLS setting as for any HTTPS call. Nothing here is specific to one state; where states differ (facility ID required or not, SOAP 1.1 accepted or not, warnings sent as AE) the destination's settings say so.

Source: github.com/biodream-llc/perfuse/internal/iis

26.58 kafka

Kafka, as a client rather than as something to replace.

A health system that has an event backbone has a Kafka one, and the systems built in the last decade publish to it rather than to a JMS broker. So this connector exists to make Perfuse the healthcare-aware edge of somebody else's event platform: it reads clinical messages off topics and publishes them onto topics, and it deliberately does not try to be a streaming platform. Windowing, joins, stream processing and schema registries are Kafka's ecosystem and stay there.

Why a dependency, when STOMP and MLLP were written by hand

The stated rule in this repository is that a dependency has to buy capability rather than convenience, and must be pure Go so one static binary still cross-compiles to every target. franz-go is pure Go and carries no cgo, which was checked against all six targets.

Kafka's protocol is not STOMP's. STOMP is a text protocol that fits in a few hundred lines. Kafka is a versioned binary protocol across roughly seventy request types, and the part that matters most here - consumer groups - is a distributed coordination protocol with join, sync, heartbeat and offset-commit phases and a rebalance dance between them. Getting that subtly wrong does not produce an obvious failure: it produces a partition nobody is reading, or two consumers reading the same one. In a clinical feed those are a missing lab result and a duplicated order. That is capability, and it is not worth hand-rolling to save a dependency.

Source: github.com/biodream-llc/perfuse/internal/kafka

26.59 ldap

Package ldap speaks enough LDAP to authenticate a person and read their groups.

Hospitals run Active Directory almost universally, and AD speaks LDAP. OpenID Connect is the better answer where it is reachable - see internal/oidc, and the note there about Mirth having no single sign-on at any price - but an integration engine often sits on an isolated VLAN where a browser cannot reach an identity provider at all. That is what this is for.

Hand-rolled BER rather than a dependency, for the same reason as DICOM and STOMP: the encoding is small, and what arrives on this socket decides whether somebody gets into a system that moves patient data.

encoding/asn1 in the standard library is not usable here. It targets DER and rejects the context-specific tags and implicit tagging that LDAP uses throughout, so the parts that matter would need hand-rolling anyway.

Source: github.com/biodream-llc/perfuse/internal/ldap

26.60 mailbox

Package mailbox reads mail from IMAP4rev1 (RFC 3501) and POP3 (RFC 1939) servers, signing in with a password or with OAuth 2.0 (SASL XOAUTH2, as Microsoft 365 and Gmail require), and takes messages apart (RFC 5322 and MIME) so an attachment or the text body can become a message on a channel.

Only what an inbound feed needs: select a mailbox, find unread mail, fetch it whole, then move, flag or delete it. Standard library only; TLS is crypto/tls.

Source: github.com/biodream-llc/perfuse/internal/mailbox

26.61 mapper

Package mapper suggests field mappings between systems with confidence scores.

This is a rules-based intelligent mapping engine. No LLM, no network calls, no external dependencies. It runs offline in the same binary and never sends patient data anywhere.

The engine combines multiple signals - fuzzy string matching on field names, healthcare identifier pattern recognition, code system lookups, and historical acceptance data - into a single confidence score per suggestion. When confidence is below a configurable threshold the mapper abstains rather than guessing wrong, because a wrong mapping in healthcare can route data to the wrong field and that is a patient-safety defect.

Abstention is the core design decision. A system that always produces an answer trains users to stop checking, and the one time it is wrong nobody notices until a lab result lands in a note field. A system that says "I don't know" forces a human decision exactly when the human is needed.

Source: github.com/biodream-llc/perfuse/internal/mapper

26.62 measure

Package measure evaluates FHIR Measures (digital quality measures) whose population criteria are FHIRPath, and writes the result as a FHIR MeasureReport: summary, individual or subject-list, with stratifiers and a proportion score.

Each criterion is evaluated once per patient, with the focus a Bundle of the patient's record (their compartment) and these variables: %subject (the Patient), %periodStart and %periodEnd (the measurement period, as DateTimes) and %"Measurement Period" (the same as a Period). Criteria in CQL are refused with the reason: Perfuse has no CQL engine.

Source: github.com/biodream-llc/perfuse/internal/measure

26.63 metrics

Package metrics collects what the engine is doing and keeps enough history to draw it.

An interface engine is watched, not just run. When a ward says results stopped arriving twenty minutes ago, the question is what changed twenty minutes ago, and that cannot be answered by current counters alone. So this keeps series rather than totals, in memory, at a fixed resolution.

Three decisions shape it.

Latency is kept as a histogram rather than a mean. An average delivery time is nearly useless in this domain: the number that matters is whether the slowest few per cent of messages are slow enough to breach a turnaround commitment, and a mean hides exactly that. Buckets are exponential and fixed at build time, so recording a duration is a comparison and an increment with no allocation.

Series are bounded ring buffers, sized when the collector is made. Nothing here grows without limit, because an interface engine runs for months at a time and a metrics system that needs restarting is worse than none.

Cardinality is capped. A label value taken from message data - a sending application, say - can take thousands of values if somebody misconfigures a feed, and unbounded labels are the standard way monitoring takes down the thing it monitors. Past the cap, values collapse into an "other" bucket and the cap is itself reported.

Source: github.com/biodream-llc/perfuse/internal/metrics

26.64 mirth

Java analysis for imported Mirth channels.

Why this exists

Mirth runs Rhino on the JVM, so a Mirth script can call any Java on the classpath, including Mirth's own server classes. Perfuse runs goja and has no Java runtime. It refuses those calls with an error naming the class - but that refusal happens when the script runs, which for a source connector means when the first real message arrives.

So a migration used to go: the import succeeds silently, the channel deploys, and the first message off the wire fails. A migration that looks clean and breaks on first traffic is worse than one that refuses at the door, because the person who did it has already told their colleagues it worked.

This finds those calls at import time and says what to do about each one.

Why four verdicts and not two

"Uses Java" is one bit of information and the wrong one, because the Java a real Mirth channel uses falls into groups with completely different amounts of work attached. Roughly half of what sites actually write is SimpleDateFormat and HashMap, which JavaScript does natively. Some of it is Mirth's documented script API, which Perfuse already implements under the same names. A little of it wants to start and stop channels, which Perfuse can do over its REST API but does not expose to scripts. And some of it is a vendor jar that is never going to run here.

Reporting those as one number tells a migrator nothing about whether their afternoon or their quarter is at stake.

Source: github.com/biodream-llc/perfuse/internal/mirth

26.65 mirthlive

Package mirthlive finds the running Mirth-family engines that the interoperability tests talk to.

Mirth Connect stopped being open source at 4.6 (March 2025). The sites leaving it go to one of three places: Mirth 4.5.2, the last open release, which they keep running; the Open Integration Engine, the Eclipse-hosted fork; or BridgeLink, Innovar's fork, which numbers its releases by year (26.9.0). A channel Perfuse exports has to load in all of them, and a channel any of them exports has to import here, so every live test runs once per engine that answers.

The engines are found by asking, not configured: ./scripts/interop-up.sh starts them on fixed ports, and an engine that is not running is reported as skipped rather than silently passing. PERFUSE_MIRTH_ENGINES overrides the list, as name=url pairs separated by commas.

Only tests import this. It lives outside a _test.go file because two packages share it.

Source: github.com/biodream-llc/perfuse/internal/mirth/mirthlive

26.66 mpi

Package mpi is a master patient index: it takes patient records from every source system (each identified by its assigning authority and number), decides which belong to the same person, and gives each person an enterprise identifier (EUID).

Certain matches link on their own; uncertain ones wait in a review queue for a person to decide; every link, unlink, merge and decision is recorded with who made it.

Matching is deterministic first (an identifier from a system the site trusts to be unique per person, such as a national identifier or an enterprise number) and probabilistic otherwise: Fellegi-Sunter weights over name, birth date, sex, postal code, phone and address, with Jaro-Winkler for names. It decides nothing clinical: it says which records are the same person, with its reasons, for a person to confirm when it is not sure.

Source: github.com/biodream-llc/perfuse/internal/mpi

26.67 mqtt

Package mqtt is an MQTT 3.1.1 client: connect (with TLS, a username and password, or a client certificate), publish at QoS 0 or 1, subscribe, and acknowledge each received QoS 1 message only once the caller says it was handled.

Written against the OASIS MQTT 3.1.1 standard rather than imported, as Perfuse's other wire protocols are. 3.1.1 is the version every broker speaks: Mosquitto, EMQX, HiveMQ, VerneMQ, RabbitMQ's MQTT plugin, IBM MQ's telemetry channel, AWS IoT Core and Azure Event Grid.

Delivery: a message received at QoS 1 is acknowledged (PUBACK) only when Ack is called. A caller that cannot handle a message closes the connection instead; with a persistent session (CleanSession false) the broker sends it again, marked as a duplicate, when the client reconnects. MQTT 3.1.1 has no negative acknowledgement, so this is the only way to ask for a message again.

Source: github.com/biodream-llc/perfuse/internal/mqtt

26.68 msgstore

Finding a stored message by what somebody knows about it.

The search a person actually needs is "here is a number off a phone call, show me every message about it". Before this that meant either a substring match over payloads, which is grep and slow, or a filter expression, which requires knowing that identity lives at PID-3 in HL7 v2 and somewhere else entirely in the other three formats on the same server.

Identity is extracted once when the message is recorded and indexed, so this is a lookup rather than a scan. The cost is paid on the recording path, once per message, instead of on every search.

Source: github.com/biodream-llc/perfuse/internal/msgstore

26.69 natid

Package natid knows the national patient and person identifiers of the UK and Europe: their formats, their check digits, the identifier system each country's FHIR guides name them by, and how much of one may be shown.

It exists for the patient index. A national number is the strongest evidence two records are one person, and also the easiest to get wrong: a digit keyed twice gives a number that belongs to somebody else, and one source sends the NHS number under an OID while another sends it under its FHIR URL. So every identifier the index stores goes through here: its check digit is tested (a wrong one is never used to link), its spacing removed, and its system written one way.

What is verified and what is not is said per scheme. The algorithms are public; the identifier system URIs are the country's own where a national FHIR guide names one (SystemVerified), and a documented default otherwise, which a site changes to its national value under Settings → Patient index. Nothing here decides anything clinical.

Source: github.com/biodream-llc/perfuse/internal/natid

26.70 ncpdp

Writing a field in an NCPDP transmission.

Why a write refuses an ambiguous path

Read returns every value a path addresses, because a filter asking whether any DUR conflict is a therapeutic duplication should see all of them. A write cannot work that way. If D7 addresses the product codes of two claims in one transmission, setting D7 has no single meaning: writing both would change a drug nobody asked about, and writing the first would silently leave the second.

So a write refuses a path that addresses more than one field, and the error names the qualified form that would be unambiguous. This is the same reasoning that gives the transformation steps no remove action: where the honest answer is that the configuration is ambiguous, saying so beats picking one.

Source: github.com/biodream-llc/perfuse/internal/ncpdp

26.71 oidc

Package oidc verifies OpenID Connect identities.

Written against the standard library rather than a JWT dependency. Authentication is the one place where an unaudited dependency is least acceptable, signature verification for the algorithms that matter is about a hundred lines of crypto/rsa and crypto/ecdsa, and Perfuse already carries enough that adding more needs a reason rather than a habit.

The scope is deliberately narrow: verify that an identity provider vouched for a person, and report what it said about them. Everything else - what they may do, how long the session lasts - stays with Perfuse, because an identity provider knows who somebody is and not what they are allowed to do in an interface engine.

Source: github.com/biodream-llc/perfuse/internal/oidc

26.72 parity

Package parity compares what Perfuse produces against what the engine being replaced produced.

Why this exists

Perfuse can import a Mirth channel and can trace what it would do with a message. Neither of those answers the question somebody actually has before moving a live feed, which is: will this produce the same output as the thing that has been running for six years?

No amount of design quality answers that. The only honest answer is evidence, and the evidence has to come from the old engine's own output on the site's own traffic.

Why the pairs have to be supplied

This cannot reach into Mirth, and pretending otherwise would be the worst kind of feature. What it takes is pairs: a message as it arrived, and the message the old engine produced from it. Both are in Mirth's own message store and can be exported from its message browser, which is a thing an administrator already knows how to do.

That constraint is stated rather than hidden. A tool that claimed to verify a migration without ever seeing the old engine's output would be verifying nothing.

Why differences are grouped rather than listed

A run over fifty thousand messages that reports four thousand differences has told somebody nothing they can act on. The same run reporting that PID-8 differs in every message because the old engine mapped M to Male has told them exactly one thing to decide about.

So findings are grouped by field, with a count, an example, and the distinct value pairs seen. One decision per finding is the unit somebody can work through.

Source: github.com/biodream-llc/perfuse/internal/parity

26.73 pas278

Package pas278 maps between Da Vinci prior authorisation and the X12 278 transaction.

Why this exists

CMS-0057-F requires payers to expose a FHIR prior authorisation API by 1 January 2027. It does not replace the X12 278, and it was never going to: a payer's utilisation management system decides authorisations and speaks 278. So a payer meeting the rule is translating FHIR to 278 and back, and a provider sending 278 today will be sending FHIR tomorrow to the same payers who still accept both.

That translation is the thing worth owning. An engine that holds both models and cannot convert between them leaves the hardest part of the work - and the part where the mistakes are expensive - to whoever is integrating it.

The rules this package follows, stated rather than implied

Written down for the same reason v2fhir writes its own down: mapping is where interoperability projects fail, and the failures are silent.

Source: github.com/biodream-llc/perfuse/internal/pas278

26.74 pdf

Package pdf writes simple text documents, using only the standard library.

Mirth's Document Writer renders HTML to PDF, which needs a layout engine. That is not what this is. This produces a plain, paginated text document with a monospaced font, and it exists because the actual requirement at almost every site that uses that connector is "put this report on a share so somebody can print it" - a discharge summary, a result report, a daily reconciliation. Those are text.

Refusing the feature entirely would leave a Tier 2 migration blocker in place. Pulling in an HTML rendering engine would cost more than the whole rest of the binary and bring a CVE stream with it, which would undo the argument the bill of materials makes. So: text, honestly described as text, and a site that genuinely needs typeset output is better served by a file destination and a tool built for it.

Why write a PDF at all rather than a text file

Because the receiving end is usually a person with a printer and a Windows share, and a .txt file printed from a share loses its pagination, its margins and often its line endings. A PDF prints the same everywhere, which is the entire reason anybody asked for one.

Source: github.com/biodream-llc/perfuse/internal/pdf

26.75 pgp

Package pgp encrypts, signs, decrypts and verifies OpenPGP messages (RFC 9580, and RFC 4880 for older peers), for payers, clearinghouses and state agencies that exchange files over SFTP or email under PGP.

The cryptography is github.com/ProtonMail/go-crypto, the maintained fork of golang.org/x/crypto/openpgp; nothing here implements a primitive. This package only loads keys and decides what a message must carry.

Source: github.com/biodream-llc/perfuse/internal/pgp

26.76 pixv3

Package pixv3 is the HL7 version 3 face of the patient index: the IHE Patient Identifier Cross-referencing and Patient Demographics Query profiles as most hospitals outside the FHIR world still speak them.

The answers come from the same patient index as PIXm, PDQm, Patient/$match and the index screen, so a record fed in v3 is found over FHIR and the reverse. The messages are written by hand, in schema order, for the reason hl7v3.Ack gives.

Source: github.com/biodream-llc/perfuse/internal/pixv3

26.77 priorauth

Package priorauth implements the provider-side prior authorization workflow required by CMS-0057. It submits prior auth requests to payer FHIR APIs, tracks responses, and converts between internal types and FHIR resources.

Source: github.com/biodream-llc/perfuse/internal/priorauth

26.78 privacy

Package privacy answers a data subject's requests under the GDPR and the UK GDPR: find everything this server holds about a person, give it to them (Articles 15 and 20), erase it (Article 17), and keep what a legal hold covers.

It reaches every place Perfuse keeps patient data: the message store (through its identity index), the delivery queue, the FHIR store (the person's Patient resources and everything that refers to them, with their history), stored prior authorization requests, and the patient index, whose links widen one identifier into all of the person's numbers. What it cannot reach it names: traces sent to a collector, the audit log (kept, as the record of who did what), and backups, which keep what was erased until they expire - so every erasure is written to a ledger, as salted hashes of the identifiers rather than the identifiers, and can be applied again to a restored copy.

It decides nothing on anybody's behalf. Whether a request must be granted, and whether an exemption applies, is the controller's decision; erasure needs a second administrator's approval, and a legal hold refuses it outright.

Source: github.com/biodream-llc/perfuse/internal/privacy

26.79 publichealth

Package publichealth implements bidirectional public health reporting: electronic case reporting (eCR), electronic lab reporting (ELR), and immunization registry queries.

Every hospital is legally required to report certain conditions — tuberculosis, measles, hepatitis, COVID-19, sexually transmitted infections, foodborne illness — to state and local public health agencies. The reporting happens through three distinct interfaces:

The package is bidirectional: it reports outward (ELR, eCR, VXU) and queries back (VXQ/VXR). All operations are real-time rather than nightly batch.

Thread safety: SubmissionLog is safe for concurrent use. The builder functions are pure and safe to call from any goroutine.

Source: github.com/biodream-llc/perfuse/internal/publichealth

26.80 published

Package published holds the guards for Perfuse's importable packages, in one place.

hl7 was the first package published and carries its own copy of these checks. fhir and pas were published so that a decision service such as Vaglio reads FHIR and PAS requests with exactly the code Perfuse uses, rather than a copy that drifts: four readers of the X12 action codes in this repository once disagreed, and one read a denial as an approval. The guards are kept here rather than copied into each package, for the same reason.

Source: github.com/biodream-llc/perfuse/internal/published

26.81 queue

Package queue is a durable, per-destination delivery queue.

Without it, a destination that is down for two minutes loses everything sent during those two minutes: retries happen in memory, and when they run out the message is recorded as failed and gone. With it, the message is on disk and keeps its place until the destination comes back.

Two design decisions shape everything else.

Order is preserved per destination, and that means a queued destination stops taking the fast path. HL7 is a stream of events about the same patients: an A01 admits, an A03 discharges. If a failed A01 goes to the queue while the next A03 is delivered directly, the receiving system sees a discharge for a patient it never admitted. So once anything is queued for a destination, everything for that destination queues behind it until the queue drains. That costs throughput during an outage and it is not optional, because the alternative is silent clinical nonsense. It is also the specific thing that goes wrong in other engines when somebody turns on concurrent queue threads to make a backlog drain faster.

A queued message is an accepted message. The sender is told AA, because the bytes are on disk and fsynced before the acknowledgement goes out. Saying AE would make a correctly functioning store-and-forward queue look like a fault and invite the sender to resend what we already hold.

Source: github.com/biodream-llc/perfuse/internal/queue

26.82 residency

Package residency says where this server's data may go: the cloud regions a site allows (the EEA, the UK, Switzerland, or regions named one by one), and whether a connector's region is one of them.

Perfuse checks what it can know. An AWS connector names its region (S3, SQS, SNS), and an AWS hostname carries one (sqs.eu-west-2.amazonaws.com); those are checked when a channel is saved and loaded, so a channel cannot send patient data to a bucket in Virginia from a server whose data must stay in Frankfurt. A plain hostname (a hospital's own system, a partner's MLLP listener) says nothing about where it is, and is not judged here: the record of processing lists every host a channel sends to, for the controller to check.

Source: github.com/biodream-llc/perfuse/internal/residency

26.83 rtpb

Package rtpb answers NCPDP Real-Time Prescription Benefit requests from a formulary: what a drug will cost this member at this pharmacy, whether it needs a prior authorization or a step first, and which alternatives cost less.

CMS-0062-P proposes RTPB for impacted payers from 1 October 2027. The XML is NCPDP RTPB v13 (PBv13). NCPDP's schema is licensed, so element names and order follow the public Surescripts RTPB examples and NCPDP's public RTPB Implementation Recommendations (coverage restriction codes and the coverage status each maps to); a response is not checked against the XSD here, and a network certification is where that happens.

Source: github.com/biodream-llc/perfuse/internal/rtpb

26.84 s3put

Package s3put writes objects to S3 using only the standard library.

The obvious way to talk to S3 is the AWS SDK, and the SDK is enormous. This project sells a static binary with eight direct dependencies and a bill of materials that fits on one screen - that is a real argument with the person who reviews software before a hospital installs it, and trading it away for one connector would be a bad bargain.

What S3 actually needs is an HTTP PUT and a signature. Signature Version 4 is fully specified, it is deterministic, and AWS publishes test vectors for it. So it is implemented here: about two hundred lines against crypto/hmac and net/http, with no new dependency and nothing to patch when the SDK has an advisory.

What this deliberately does not do

No multipart upload, no retries of its own, no credential chain beyond static keys and the standard environment variables. An HL7 message is kilobytes, so multipart solves a problem this does not have; the engine already retries; and an instance-role credential chain would mean IMDS calls, caching and refresh logic, which is where a small implementation stops being small. If somebody needs those, the honest answer is a file destination and a sync tool, not a half-built SDK.

It works with anything speaking the S3 API - MinIO, Ceph, Wasabi, Backblaze - because it is just signed HTTP. That is a side effect worth having, since a hospital object store is often not AWS.

Source: github.com/biodream-llc/perfuse/internal/s3put

26.85 saml

Exclusive XML Canonicalization (exc-c14n) per https://www.w3.org/TR/xml-exc-c14n/

This is the byte-exact canonical form that an XML signature signs. Getting it wrong means valid signatures appear invalid and - worse - an implementation that gets it mostly right will pass in testing against its own signatures while rejecting real IdP responses.

The rules that matter for SAML: - Namespace declarations are rendered only if visibly utilised by the element or its attributes. - Inherited namespaces that an element uses but did not declare are rendered as if declared on that element. - Attributes are sorted: namespace declarations first (sorted by prefix), then other attributes (sorted by expanded name: namespace URI then local name). - Empty elements use start-tag/end-tag, never self-closing. - Text nodes are output verbatim; CDATA is replaced by its content with entity escaping. - Processing instructions and comments outside the document element are excluded.

Source: github.com/biodream-llc/perfuse/internal/saml

26.86 scim

Package scim implements SCIM 2.0, the protocol identity providers use to provision accounts.

Okta, Entra ID, Google Workspace and OneLogin all speak it. An administrator adds somebody to a group in the identity provider, and within a minute an account appears here with the right role. They remove them, and the account goes away.

Deprovisioning is the point

Provisioning is convenience. Deprovisioning is the reason this is worth building, and it is the half that goes wrong.

When somebody leaves an organisation, the identity provider is the system that knows first - usually because HR terminates them and the provider is driven from the HR record. It then tells every connected application to disable the account. If that call fails, or succeeds without actually removing access, a former employee keeps working access to a clinical integration engine and nobody finds out, because nothing is watching for a thing that did not happen.

So the parts of this package that matter most are the ones that make deprovisioning real:

Why it is a separate package

SCIM's shapes are not Perfuse's. It has its own error format, its own list envelope, its own patch language, and its own opinions about attribute casing. Keeping the translation in one place means the handlers can be about what happens to an account rather than about JSON.

Source: github.com/biodream-llc/perfuse/internal/scim

26.87 script

Package script runs Mirth Connect JavaScript.

The goal is that a channel's scripts move across untouched. That is a compatibility exercise, not a design exercise, so the shape of everything here is dictated by what Mirth exposes: msg and tmp as E4X trees, six maps with Java's Map methods, the $ family of lookup functions, logger, and a handful of utility objects whose method names and date-format strings are Java's.

Three decisions are ours rather than Mirth's, and each is a deliberate departure:

A script runs with a deadline and is interrupted when it passes. Mirth will let a script loop forever, which takes the channel with it; an interface that stops accepting admissions because of a typo in a transformer is not acceptable, so there is always a timeout.

Java interoperability is refused with an error naming the class, rather than returning undefined. A script reaching into Packages.java.sql cannot be made to work here, and the useful outcome is a message saying exactly that at the line where it happens, not a NullPointerException three lines later.

File and database access are off unless a channel turns them on. Mirth grants both to every script by default. A transformer that can read any file the process can read is a reasonable thing to want and an unreasonable default.

Source: github.com/biodream-llc/perfuse/internal/script

26.88 settings

Package settings is the single description of everything about a Perfuse server that an operator may change.

Why this exists

Before this package, roughly thirty things were command-line flags and nothing else. Changing how long messages are kept meant editing a service definition and restarting, which in a hospital means a change ticket - so in practice nobody changed them, and a default chosen on the first afternoon became the setting forever.

Why one registry rather than a form

The obvious way to build a settings page is to write the form. That produces two descriptions of every setting - one in the server that validates it and one in the interface that renders it - and they drift. The drift is quiet: a field the server stopped accepting still has a control, so somebody sets it, sees it saved, and it does nothing.

So a setting is declared once, here, with everything needed to both check it and draw it: what kind of value it is, what control suits it, which group it belongs in, what it means, and whether changing it takes effect now. The interface asks for that description and renders whatever it is told. Adding a setting is one entry in this package and no interface work at all.

TestEverySettingIsFullyDescribed enforces the parts a human has to supply, because a setting with no help text is a setting somebody guesses at.

Why a file rather than the database

Settings live in a YAML file the interface edits in place, matching how channels and alert rules already work. Three reasons, in order of weight: a hospital's change control wants configuration that can be diffed and put in version control; an operator with the machine but not the application can read a file and cannot read a database; and restoring yesterday's settings should not require the application to be working.

Why flags only seed it

The file wins once it exists, and flags supply the initial contents. The alternative - flags always winning - would make the interface a liar, since anything ever passed on a command line could be edited and saved and silently ignored.

A flag that disagrees with the file is reported at startup, naming both values, because otherwise the flag stays in the service definition for years and the next person to read it believes it.

Source: github.com/biodream-llc/perfuse/internal/settings

26.89 sftpconn

Package sftpconn opens SFTP connections.

Split out from the connectors because the interesting part is authentication and host key verification, and both are identical whether a channel is collecting files or writing them. Two copies would drift, and the half that drifted would be the half that stopped verifying.

Source: github.com/biodream-llc/perfuse/internal/sftpconn

26.90 shadow

Package shadow compares a candidate channel against the live one.

The comparison itself is the easy part. The whole value is in two guarantees, and both are enforced by construction rather than by configuration:

Anything less than that and shadow mode becomes a way to break production while trying to avoid breaking production.

Source: github.com/biodream-llc/perfuse/internal/shadow

26.91 shl

Package shl implements SMART Health Links and SMART Health Cards: the QR codes CMS's "Kill the Clipboard" asks providers to accept at check-in, and to hand back with the visit record.

A SMART Health Link (SMART Health Links STU 1) is shlink:/ followed by base64url JSON: a manifest URL, a 256-bit key, and optionally an expiry, a label and flags (P passcode, L long-term, U a single file by GET). The receiver posts to the manifest URL and gets back files, each a JWE encrypted with that key - alg dir, enc A256GCM, optionally DEFLATE-compressed - holding FHIR JSON, SMART Health Cards, or a SMART access token. The server that hosts the files never sees the key: it travels only in the link, which is the patient's.

A SMART Health Card (SMART Health Cards Framework 1.4) is a signed FHIR bundle: a compact JWS, ES256, with a DEFLATE-compressed payload naming its issuer, whose public key is at <iss>/.well-known/jwks.json. It travels as a numeric shc:/ QR code or a .smart-health-card file.

Only the standard library: crypto/aes, crypto/cipher, crypto/ecdsa and compress/flate cover everything, and a JOSE library would bring a much larger surface for two algorithms.

Source: github.com/biodream-llc/perfuse/internal/shl

26.92 smartauth

Package smartauth is a SMART App Launch authorization server: it issues the access tokens Perfuse's FHIR endpoint accepts.

Written against the standard library, like internal/oidc, which verifies what this signs. The tokens are JWTs signed RS256, the one algorithm every SMART client and the specification's ID token rules can rely on.

Source: github.com/biodream-llc/perfuse/internal/smartauth

26.93 snmp

Package snmp is a small read-only SNMPv2c agent: GET, GETNEXT and GETBULK over UDP against a table of values the caller fills in, and SNMPv2 traps.

Messages are BER (X.690) encoded here; nothing in it is cryptography (v2c's community string is a plain-text password, which is why the agent only answers the networks it is told to).

Source: github.com/biodream-llc/perfuse/internal/snmp

26.94 soap

Package soap sends SOAP requests and reads SOAP faults, using only the standard library.

SOAP is a fair amount of ceremony around an HTTP POST, and it is still how a great deal of hospital middleware accepts data - patient administration systems, document repositories, regional record services, anything specified in the decade when this was the answer. Mirth has a Web Service connector, so this is a Tier 2 migration blocker rather than a nice-to-have.

Why not a generated client

The usual approach is to take a WSDL and generate Go types from it. That is the right answer for an application talking to one known service, and the wrong one here: an integration engine's destination is configured by somebody editing a channel, not recompiled, so the envelope has to be assembled at run time from a template they can see and change. It also means there is no code generation step and nothing to regenerate when the far end publishes a new WSDL.

What this deliberately does not do

No WSDL parsing, no WS-Security, no MTOM, no WS-Addressing. Those are each a specification in their own right, and pretending to support one while getting a detail wrong is worse than not offering it - a signature that almost verifies is indistinguishable from an attack at the far end. WS-Security in particular is where a half-implementation would do real harm.

What is here: SOAP 1.1 and 1.2 envelopes, a templated body, the SOAPAction header, and fault detection that reads the fault string rather than reporting "500".

Source: github.com/biodream-llc/perfuse/internal/soap

26.95 sof

Package sof runs SQL on FHIR v2 ViewDefinitions: a view turns each FHIR resource of one type into rows of named columns, by FHIRPath, so FHIR data can be read as tables by SQL engines, spreadsheets and quality measures.

It follows the ViewDefinition's processing algorithm (column, select, forEach, forEachOrNull, repeat, unionAll, where, constants, %rowIndex, getResourceKey and getReferenceKey) and is checked against the official sql-on-fhir test fixtures.

Source: github.com/biodream-llc/perfuse/internal/sof

26.96 sqldb

Package sqldb opens database connections for the database source and destination.

The drivers are registered here and nowhere else, so there is exactly one place that decides what Perfuse can connect to.

Source: github.com/biodream-llc/perfuse/internal/sqldb

26.97 sqlitedb

Package sqlitedb opens SQLite the way a server needs it: one connection for writes and several for reads.

The reason is a failure mode that only appears once a deployment has been running for a while. SQLite in WAL mode lets readers run alongside a writer, but Go's connection pool sits above SQLite, so a pool of one serialises everything regardless of what the database would allow. A message browser search scanning the payload column then holds the only connection, and every arriving message waits behind it.

Measured on this machine, a write takes 104µs with the store idle and 2.6ms while a search scans four thousand messages: twenty-five times slower. That scales with the table, so at a few hundred thousand stored messages a report turns into seconds of delivery latency, and a write is on the acknowledgement path. The sender sees a timeout, resends, and somebody spends a morning looking for a problem in the network.

Splitting the pools costs nothing and removes the interaction: reports get their own connections, and the writer keeps one to itself because SQLite permits exactly one writer anyway.

Source: github.com/biodream-llc/perfuse/internal/sqlitedb

26.98 steps

Declarative transformation steps, for any message format.

Why this is shared

The step vocabulary - set, copy, clear, map, replace, trim, case, each with a description and an optional condition - has nothing to do with any particular format. Only three things differ between formats: how a path is written, how a value is read, and how it is written back.

This is the same conclusion the filter grammar reached in internal/expr, and for the same reason. X12 had a step engine and NCPDP needed one; copying it would have produced a second four-hundred-line engine to keep in step by hand, and then a third for delimited. The behaviour worth having is subtle enough that a copy would not keep it - the reason the result is re-read after every write, for instance, took a real defect to discover.

What a format supplies

An Accessor: a path compiler, a condition compiler, and an optional veto on steps it cannot honour. Nothing else.

Source: github.com/biodream-llc/perfuse/internal/steps

26.99 stomp

Package stomp speaks STOMP 1.2 to a message broker.

This is what "JMS support" has to mean in a program that is not Java. JMS is an API rather than a protocol, so there is nothing to implement: what a site actually needs is for Perfuse to talk to the broker sitting behind their JMS applications.

STOMP rather than OpenWire, and the reasoning is worth recording because the obvious answer is wrong. OpenWire is ActiveMQ's native protocol and a binary serialisation of JMS commands - weeks of work, specific to one broker family, and undocumented except by its implementation. ActiveMQ auto-detects STOMP, AMQP and MQTT on the same port it serves OpenWire on, so a text protocol reaches the same brokers. STOMP covers ActiveMQ Classic, Artemis and RabbitMQ; AMQP 1.0 would add Azure Service Bus and IBM MQ, and is the sensible next one rather than OpenWire.

Written against the standard library. A broker client is not the place for a dependency whose failure modes are somebody else's to explain, and the framing here is small enough that owning it costs less than reading their issue tracker.

Source: github.com/biodream-llc/perfuse/internal/stomp

26.100 store

Package store holds the data that is genuinely stateful: users, sessions and an audit trail.

Channels are deliberately not in here. They live in files, because that is what gives git, diffs, code review and environment promotion for free. Putting channels in a database is the decision that forces every one of those to be bought separately, and it is the reason a third-party product exists purely to shuttle Mirth channels between its database and a git repository.

So the split is: configuration in files, identity and history in SQLite.

Source: github.com/biodream-llc/perfuse/internal/store

26.101 tefca

Package tefca implements the parts of TEFCA participation that do not require a QHIN connection.

What is real

Purpose-of-use validation, configuration validation, and the audit trail - including its persistence, its query interface and its obligation to record failed attempts as well as successful ones. That last part is not a technicality: a run of failed deliveries to one recipient is how a misconfigured partner shows up, and a run of failed queries for different patients is how probing shows up.

What is not

The network. There is no QHIN transport in this build, so no exchange happens, and every exchange function refuses with ErrExchangeNotImplemented rather than returning success.

That distinction was not always drawn, and the way it went wrong is worth keeping. All four exchange functions used to validate their inputs, make no network call, and return success; Deliver returned an Accepted response with a tracking identifier built from the clock. The audit layer then recorded each as a completed exchange - in the log that exists to prove what was exchanged when somebody asks months later. Four tests asserted that behaviour and passed.

What completing it requires

Onboarding with a Qualified Health Information Network, mutually authenticated TLS using certificates issued through that process, and either the IHE profiles the Common Agreement started with or Facilitated FHIR as defined by the Sequoia Project's standard operating procedure, effective 8 March 2026. None of it can be written speculatively: it is tested against a real QHIN or it is not tested, and an untested exchange implementation is the same defect as no implementation wearing a better disguise.

Source: github.com/biodream-llc/perfuse/internal/tefca

26.102 tenant

Package tenant provides the identity and isolation rules for multi-tenant operation.

An HIE, a clearinghouse or a managed service provider runs one engine on behalf of many organisations. Mirth cannot do this - one instance serves one organisation, and an MSP ends up running fifty JVMs and fifty databases.

What a tenant owns

Its channels, as a directory of files. Its users. Its messages, queue, audit trail and FHIR resources. Its metrics labels. Nothing is shared except the process, the listening ports it was allocated, and the platform administrators.

Isolation is structural, not remembered

The dangerous failure here is not a crash. It is tenant A seeing tenant B's messages, which is a reportable breach rather than a bug. A design where every query takes a tenant_id parameter fails the first time somebody writes a query and forgets one, and that query looks perfectly correct in review.

So the tenant is not a parameter. Data access goes through a handle that already carries it, and there is no way to reach the tables without one. Forgetting is not available.

Source: github.com/biodream-llc/perfuse/internal/tenant

26.103 terminology

Package terminology keeps published code systems (LOINC, SNOMED CT, RxNorm, ICD-10-CM, CPT and local ones) for looking codes up, and the values mapping tables did not have, for somebody to map.

The code systems are imported from the files their publishers distribute. Several are licensed (LOINC needs a free account, SNOMED CT an affiliate licence, RxNorm a UMLS licence, CPT an AMA licence), so none ships with Perfuse: an administrator who holds the licence uploads the file, and Perfuse records who imported it and when.

Source: github.com/biodream-llc/perfuse/internal/terminology

26.104 tlsconf

Package tlsconf builds TLS configuration from files and describes certificates.

This is the free equivalent of an extension Mirth now charges for. Most of the value is not the encryption, which is a few lines, but the part that stops somebody deploying something they think is secure and is not: refusing to skip verification silently, insisting a client certificate is actually checked when one is requested, and saying plainly when a certificate is about to expire.

An expired certificate on an interface feed is a genuine hospital outage, and it happens because nobody was told. The describing half of this package exists for that reason.

Source: github.com/biodream-llc/perfuse/internal/tlsconf

26.105 tomirth

Package tomirth writes a Perfuse channel back out as Mirth channel XML.

Why this exists. The objection to adopting a new integration engine is rarely "is it any good". It is "what if we are wrong and we are stuck", and the cheapest answer to that is being able to walk back out. Perfuse has read Mirth's channels since the beginning; until now it could not write one, which made the reassurance one-directional and therefore not much of a reassurance.

How it avoids the trap that made the first attempt useless. Mirth's XML is XStream's output, and XStream reads the class and version attributes as instructions: an element it does not recognise is dropped without a word, and a channel it cannot assemble is stored with its description replaced by "This channel is invalid. Verify all required extensions are loaded correctly" and its destinations discarded. Nothing in that response names the element at fault. An exporter that composes property blocks from a reading of the format therefore fails in the one way that is expensive to diagnose, which is what happened: this repository carried an exporter with nine passing tests whose output Mirth threw away, because every test compared it against our own parser instead of a server.

So none of the property blocks are composed here. scripts/mirth-dump-connector-templates.sh has Mirth's own ObjectXMLSerializer serialise the defaults of each connector properties class, and those documents are committed under templates/. This package substitutes values into them, and mirth.SetRawProperty refuses any path the template does not already contain - so a mistake is an error naming the path, at the moment of export, rather than a channel that vanishes on import.

What it will not do. A transport with no Mirth equivalent is refused by name rather than approximated. An export that quietly turned an S3 destination into a file writer would be worse than no export at all, because the channel would import cleanly and deliver to the wrong place.

Source: github.com/biodream-llc/perfuse/internal/tomirth

26.106 trace

Package trace produces distributed traces for messages moving through a channel, and exports them over OTLP/HTTP with the JSON encoding.

Why this exists rather than the OpenTelemetry SDK: the SDK plus an OTLP exporter pulls in roughly sixty modules and adds about as much to the binary as the whole of the rest of this program. What is actually needed here is narrow - W3C trace context propagation, a span model, head sampling, and a batched HTTP POST - and OTLP/JSON is a documented stable encoding that can be produced with the standard library alone. Same trade as choosing lib/pq over pgx: take the small thing when only part of the big thing is wanted.

The consequence worth stating: there is no auto-instrumentation and no vendor-specific exporter. Spans are the ones this program creates deliberately, and they go to any OTLP endpoint - a collector, Jaeger, Grafana Tempo, Honeycomb, Datadog's OTLP ingest.

The property that matters more than the spans

A message frequently arrives carrying a traceparent header from the system that sent it, and leaves toward a system that will record one of its own. Continuing that trace rather than starting a fresh one is what makes a single view of "the EHR sent this, the engine transformed it, the registry rejected it" possible at all. Interface problems are almost always reported as "we sent it and nothing happened", and that question is unanswerable without an identifier that survives the hop.

Tracing must never affect delivery

Every operation here is best-effort. Spans are dropped when the buffer is full, export failures are counted and never retried into the message path, and no call blocks on the network. A dropped span is an inconvenience. A message delayed or lost because the tracing backend was slow is a clinical incident, so the trade is never in question.

Source: github.com/biodream-llc/perfuse/internal/trace

26.107 transform

Package transform applies declarative changes to a message.

This is the layer meant to carry the ordinary work, with scripting kept as a marked escape hatch. The distinction is not stylistic. A step declared in YAML can be validated before it runs, shown in a diff, reviewed by somebody who does not read JavaScript, and reported on; a script can only be executed and hoped about. Most of what Mirth transformers actually do is boring - copy a field, pad an identifier, map a code through a table, clear something that should not leave the building - and all of that belongs here.

Every step names its target explicitly and every step is reversible in the sense that it says what it did, because the most expensive failure in an interface is a transformation nobody knew was happening.

Source: github.com/biodream-llc/perfuse/internal/transform

26.108 translate

Package translate converts a Mirth channel into a Perfuse channel definition.

perfuse explain reports what blocks a migration. This does most of the migration, which is a different and much larger claim, so it is deliberately conservative about what it will assert.

Three rules shape the whole thing.

Nothing is silently dropped. Every part of the source channel is either translated, carried across verbatim as a script, or reported as needing a human. A translator that quietly omitted a step would produce a channel that starts, runs, and loses data — the worst possible outcome and the reason people distrust migration tools.

Declarative where it is certain, script where it is not. A Mapper step that copies a field becomes a declarative step, because that is readable and checkable. A JavaScript step becomes a script, because Perfuse runs Mirth's JavaScript unchanged and rewriting it into something that looks equivalent is exactly where a translator would introduce a difference nobody notices.

The output is meant to be read. It carries comments explaining what came from where, and what was left for a person. A migration is reviewed by somebody who has to sign off on it, and an unannotated wall of YAML cannot be reviewed.

Source: github.com/biodream-llc/perfuse/internal/translate

26.109 udap

Package udap implements the client half of HL7 Security for Scalable Registration, Authentication, and Authorization — the specification TEFCA's Facilitated FHIR exchange rests on.

UDAP is a public key infrastructure bolted onto OAuth 2.0. A trust community issues X.509 certificates to its members; a member proves who it is by signing JWTs with the private key and attaching the certificate chain in the JWT header. That replaces the shared client secret of ordinary OAuth, which does not scale to thousands of organisations who have never met.

Why this package exists at all. TEFCA exchange cannot be fully verified without a real QHIN: joining one needs onboarding and issued certificates, and cannot be faked. But the security profile underneath it is a published HL7 implementation guide with public reference servers, and one of them answers on the open internet. So the part that can be verified is verified here, and the part that cannot is refused at the boundary rather than guessed at.

The distinction matters because of what happened with SAML in this repository: a thousand lines of tests passed, five of them specifically about signature canonicalisation, while no real identity provider could authenticate anybody. Both halves had been written here and agreed with each other. The lesson taken from it is that a security implementation is worth nothing until something somebody else wrote has accepted or rejected it.

Specification references: - HL7 UDAP Security STU 1: http://hl7.org/fhir/us/udap-security/STU1/ - TEFCA SOP Facilitated FHIR Implementation v2.0, effective 8 March 2026

Source: github.com/biodream-llc/perfuse/internal/udap

Package umlink carries X12 278s to a payer's utilization management system, and its answers back, the ways those systems are reached in practice: X12 in the body of an HTTPS POST; CAQH CORE's SOAP envelope (Connectivity Rule vC2.2.0, the one most payers and CMS's esMD run, and vC4.0.0); CAQH CORE's HTTP MIME multipart envelope (vC2.2.0); and batch files over SFTP.

Written from CAQH's published Connectivity Rules (vC2.2.0 and vC4.0.0), their XML Schema and WSDL, and CMS's esMD X12N 278 Companion Guide. It is not CORE certified: certification is done by CAQH's authorised testing vendors.

The real-time links return the answer the UM system gave, which may be a 278 response or, when it could not read the request, a 999 or TA1: the caller reads which. The batch link returns ErrAnswerLater once the file is delivered, and Poll collects what comes back.

Source: github.com/biodream-llc/perfuse/internal/umlink

26.111 v2fhir

Package v2fhir maps HL7 v2 messages to FHIR resources.

Mapping is where interoperability projects actually fail, so the rules this package follows are stated rather than implied:

Source: github.com/biodream-llc/perfuse/internal/v2fhir

26.112 vcs

Package vcs reads the history of channel files from git.

Channel history is a paid extension elsewhere, and it is a paid extension because those engines keep channels in a database, so tracking changes means building a change-tracking system. Perfuse keeps channels in files, so the history already exists and has since the first commit. This package only has to read it.

It shells out to git rather than embedding a git library. The library would add a large dependency to a binary whose selling point is having four, and it would buy nothing: a deployment keeping its channels under version control has git installed, and one that does not gets a clear "not a git repository" rather than a subtly different implementation of the same thing.

Source: github.com/biodream-llc/perfuse/internal/vcs

26.113 vfs

Package vfs is the small set of file operations a polling connector needs.

Why an interface rather than four connectors

Perfuse collects files from a local disk, an SFTP server, an FTP server, a Windows share and a WebDAV server. The transfer differs in every case. Everything that actually decides whether messages survive does not:

Written once per transport, those rules would be subtly different five times, and the differences would only appear as messages that went missing at one site. So they are written once, here, over the smallest interface that supports them.

What is deliberately not here

No writing, beyond what disposal needs. Sending files is a different problem with different failure modes - a partially written file at the far end, which the existing senders handle by writing to a temporary name and renaming. Folding both directions into one interface would make each harder to reason about.

No recursion. A poller that descends into subdirectories collects the archive directory it just moved a file into.

Source: github.com/biodream-llc/perfuse/internal/vfs

26.114 vpn

Package vpn reports the state of the site-to-site VPN tunnels partner traffic runs over - AWS Site-to-Site VPN, Azure VPN Gateway connections and strongSwan - and keeps each partner's connection sheet: both sides' tunnel settings, and whether they agree.

A tunnel that is down looks, from an interface engine, exactly like a partner whose engine is stopped: connections time out. Saying "the tunnel is down" instead is the difference between calling the network team and calling the partner.

Read only. Nothing here changes a tunnel, and no pre-shared key is ever read, kept or shown.

Source: github.com/biodream-llc/perfuse/internal/vpn

26.115 web

Package web serves the built front end from inside the binary.

The assets are embedded rather than shipped alongside, so deployment stays one file. That is the same reason the engine avoids cgo: an operator should be able to copy a binary onto a server and run it, with no JVM, no installer and no asset directory to get out of step with the executable.

The built output is committed so that go build works on a checkout without Node installed. Rebuild it with make web.

Source: github.com/biodream-llc/perfuse/internal/web

26.116 webauthn

Package webauthn verifies passkeys.

A passkey is a key pair the authenticator holds - Touch ID, Windows Hello, a YubiKey, a phone - where the private half never leaves the device. Signing in means the server sends a random challenge, the authenticator signs it after checking the user is present, and the server verifies the signature against the public key it stored at registration.

The property that matters is not that it is convenient. It is that there is nothing to phish. A password can be typed into a convincing copy of a login page; a passkey signature is bound to the origin it was created for, so the copy gets a signature its server cannot use. For an integration engine holding a hospital's interfaces, that is the difference worth having.

What this implementation refuses to do

The specification is permissive in places where being permissive is how implementations get broken. This one is not:

Source: github.com/biodream-llc/perfuse/internal/webauthn

26.117 winservice

Package winservice runs Perfuse as a Windows service.

This file is the everywhere-else half. It exists so that cmd/perfuse can call into this package without wrapping every call site in a build tag: the subcommand is always compiled, and on Linux or macOS it explains that services are a Windows idea rather than failing to exist.

Refusing with a clear sentence is better than hiding the subcommand. Somebody following Windows instructions on the wrong machine gets told what is actually going on, and the equivalent for their platform.

Source: github.com/biodream-llc/perfuse/internal/winservice

26.118 x12

Reading and writing values at a path.

Why this is separate from Path

path.go decides what a path expression means. This file resolves one against an actual interchange. Kept apart because the parsing rules are worth reading without the byte handling, and because the parser is used by the configuration validator, which has no message to resolve against.

Why a write re-parses instead of editing bytes in place

Segment.elements are subslices of Segment.raw, and Message.segs indexes into Message.raw. Editing a value in place changes the length of everything after it, so every one of those slices would need adjusting, and any that was missed would read correct until the one message where it did not.

So a write rebuilds the segment, rebuilds the interchange, and parses it again. That costs a parse per changed message and buys the guarantee that a Message returned from here is exactly what parsing its own bytes produces. There is no state where the indexes and the bytes disagree, because that state is unrepresentable.

Source: github.com/biodream-llc/perfuse/internal/x12

26.119 x12xml

Package x12xml converts X12 (and other delimited EDI) to and from the XML that Mirth Connect and Open Integration Engine write for it, and back.

Why that XML and not one of our own

The point of an XML form of X12 is the systems that already use one. A site moving from Mirth has receivers built against what Mirth's EDI/X12 data type emits, scripts that walk msg['CLM']['CLM.05']['CLM.05.1'], and upstream engines that send X12 as that XML. A tidier shape of our own would serve none of them. So this writes exactly what Open Integration Engine 4.6 writes, and reads exactly what it reads, and the tests compare the two byte for byte against OIE's own serializer.

An earlier version of this package invented its own shape (an X12Interchange root, elements named CLM05) and said it matched Mirth's. It did not; it had never been compared with Mirth, and nothing in Perfuse used it.

The format

The root is X12Transaction when the message starts with an ISA segment and EDIMessage otherwise, carrying the three delimiters as attributes (segmentDelimiter, elementDelimiter, subelementDelimiter). Each segment is an element named by its identifier. Each data element is SEG.NN, numbered from 01 (ISA.01, CLM.05), and holds its components as SEG.NN.1, SEG.NN.2 and so on: always at least SEG.NN.1 for an element with a value, none for an empty one.

The delimiters are read from the ISA when there is one: the element separator from position 3, the component separator from position 104 and the segment terminator from position 105, with a line feed after it counted as part of it. Otherwise they are ~ * and :. The repetition separator (ISA11) is not interpreted, which is also OIE's behaviour: a repeated element keeps its ^ inside one value.

This is a reimplementation of the behaviour, written from observing OIE's output and from its documented format; no code of Mirth's is in it.

Source: github.com/biodream-llc/perfuse/internal/x12xml

26.120 xds

Package xds is IHE cross-enterprise document sharing: an XDS.b Document Registry and Repository (ITI-41 Provide and Register, ITI-18 Registry Stored Query, ITI-43 Retrieve Document Set), an XCA Responding Gateway over the same store (ITI-38, ITI-39), and the client side of each, so a channel can publish a document to another organisation's repository and the console can query a registry or a community.

The metadata is ebXML Registry 3.0 (ebRIM), as XDS.b defines it in IHE ITI TF-3 chapter 4. It is modelled here as Go structs whose XML tags carry the namespaces, so a message is read the same whichever prefixes the sender chose, and written namespace-correct.

Source: github.com/biodream-llc/perfuse/internal/xds

26.121 xmldsig

Package xmldsig implements XML Digital Signatures and the XAdES profile of them.

Why this is written rather than borrowed

A signed clinical document is a legal artefact. It asserts that a named person, at a named time, took responsibility for a specific set of bytes. Getting that wrong in either direction is serious: a signature that cannot be verified makes a valid document look forged, and a verifier that is too generous accepts a document somebody altered.

Canonicalisation is the whole problem

XML has many byte sequences that mean the same thing. Attribute order is not significant, namespace prefixes can be renamed, empty elements can be written two ways, and whitespace inside a tag is free. A signature is over bytes, so before hashing anything both ends must agree on exactly one byte sequence for a given document. That agreement is canonicalisation, and it is where implementations go wrong, because every mistake produces a signature that verifies against your own output and fails against everybody else's.

So this file is the canonicalisation, on its own, with its own tests. Nothing about signing appears here.

Exclusive rather than inclusive, by default

Inclusive canonicalisation pulls in every namespace declaration that is in scope, including ones the signed element never uses. That makes a signature break when the document is later embedded inside something that declares an unrelated namespace on an ancestor, which is precisely what happens when a CDA is put inside a SOAP envelope or an IHE metadata wrapper. Exclusive canonicalisation includes only the declarations the element actually uses, and is what document exchange profiles specify for that reason.

Source: github.com/biodream-llc/perfuse/internal/xmldsig

26.122 xtree

Package xtree is a mutable XML tree.

Three very different features need to modify XML: HL7 transformations, which Mirth models as XML because that is what its scripts operate on; clinical documents, which are XML natively; and the declarative transformation steps. They share this one tree rather than each growing their own, because the alternative is three models that disagree about what an empty element means.

The design goal is faithfulness to E4X semantics, since Mirth scripts are E4X and the whole point of running them unchanged is that they behave the same. That drives two decisions that would otherwise look strange: a node keeps a pointer to its parent, because E4X exposes parent(), and children are ordered with duplicates allowed, because an HL7 field repetition is literally a repeated element and scripts index into them by position.

Source: github.com/biodream-llc/perfuse/internal/xtree

26.123 pas

Package pas reads Da Vinci Prior Authorization Support (PAS) request Bundles.

It is the reader Perfuse's own PAS server uses, published so that whatever decides a prior authorization - a payer's rules, a utilization management system, a decision service such as Vaglio - reads a request with the same code, and so the same answers about what a request contains. Two readers of one message format are two opinions about it.

It has no dependencies outside the standard library. A request is kept as decoded JSON (map[string]any) rather than as typed resources, because a PAS Bundle carries whatever supporting resources the provider chose to send, and a reader that dropped what it did not model would hide evidence from the decision.

req, problems := pas.Read(bundle, false) if len(problems) > 0 { // refuse: the request cannot be answered correctly } for _, svc := range req.Services() { // svc.Codes, svc.Item, and req.Resolve for anything the item references }

Source: github.com/biodream-llc/perfuse/pas

27 Index of Configuration Keys

Every key in alphabetical order, with the blocks it can appear in. A key that appears in several blocks means the same thing in each one; where it does not, the blocks are documented separately and the difference is stated there.

27.1 All keys

KeyAppears in
access_key_idAWS Access, FHIR Auth, S3 Destination
accountAzure Blob Access
ackHTTP Source, SOAP Source, Source
ack_sender_idX12 Options
ack_sender_qualifierX12 Options
acknowledgeEDIFACT Options, HL7v3 Options, X12 Options
acksKafka Destination
actionSOAP Destination
addrAMQP Connection, Broker Destination, Broker Source, MQTT Connection
addressAMQP Connection, DICOM Destination, DICOM Query Source, Destination, Direct Address, TCP Dest
ae_titleDICOM Source
after_queryDatabase Source
after_readAzure Blob Source, Direct IMAP, Email Source, File Poll, GCS Source, S3 Source, SFTP Source
allowScripts
allow_clear_passwordFTP Destination
allow_unencryptedAS2 Partner, PGP Receive
allow_unsignedAS2 Partner
allowed_calling_aeDICOM Source
allowed_sendersEmail Source
amqpDestination, Source
anchors_fileDirect Address
api_versionIBMMQ Connection
appendDestination, SFTP Destination
applicationAck
armorPGP Send
arrives_asX12 Options
as2Destination, Source
attachSMTP Destination
attach_nameDirect Destination, SMTP Destination
attachment_namesEmail Source
attachmentsChannel
attemptsRetry
authFHIR Destination, PAS Destination
authoritiesMPI Destination
azure_blobDestination, Source
backoffQueue Config, Retry
base64SOAP Source
batch_sizeDatabase Source, File Poll
baudSerial Source
bccSMTP Destination
bearer_tokenCDA Destination, FHIR Destination, HTTP Destination, PAS Destination
big_endianTCP Framing
blobAzure Blob Destination
bodySMTP Destination, SOAP Destination
brokerDestination, Source
brokersKafka Destination, Kafka Source
bucketGCS Access, S3 Destination, S3 Source
bundle_scriptFHIR Destination
called_aeDICOM Destination, DICOM Query Source
calling_aeDICOM Destination, DICOM Query Source
ccSMTP Destination
cdaDestination
cert_fileAS2 Destination, AS2 Partner, AS2 Source, Direct Address
channelDestination, Shadow
check_everyContract Ref
cityECR Facility
claim_us_coreFHIR Destination
classXDS Destination
client_ca_fileDirect XDR
client_idEmail O Auth, FHIR Auth, MQTT Connection
client_secretEmail O Auth, FHIR Auth
codeXDS Code
columnDatabase Source
columnsDelimited
commentDelimited
commit_after_deliveryKafka Source
compareShadow
compressAS2 Destination
compressionKafka Destination
configELR Destination
containerAzure Blob Access
content_typeAMQP Destination, AS2 Destination, Azure Blob Destination, Broker Destination, GCS Destination, HTTP Destination, S3 Destination, XDS Destination
contractChannel
credentials_fileGCP Access
dataTypeChannel
data_bitsSerial Source
databaseDestination, Source
decrypt_withPGP Receive
default_identifier_systemFHIR Destination
delimitedChannel
delimiterDelimited, TCP Framing
deployScripts
descriptionChannel
destinationBroker Destination, Broker Source
destinationsChannel
dicomChannel, Destination, Source
dicom_querySource
dicomwebDestination, Source
dirCDA Destination, Destination, Document Destination, FTP Destination, File Poll, SFTP Destination, SFTP Source
directDestination, Source
direct_fromXDS Destination
direct_toXDS Destination
displayXDS Code
dnsDirect Destination
documentDestination
domainSMB Source
driverDatabase Destination, Database Source
dsnDatabase Destination, Database Source
duplicate_windowAS2 Source
ecrFHIR Destination
edifactChannel
elementSOAP Source
elrDestination
emailSource
emit_on_first_pollDICOM Query Source
enabledChannel, Destination, Queue Config
encrypt_toPGP Send
endpointAWS Access, Azure Blob Access, GCP Access, S3 Destination
envelopeEDIFACT Options, X12 Options
error_dirFile Poll, SFTP Source
error_folderDirect IMAP, Email Source
error_prefixAzure Blob Source, GCS Source, S3 Source
expect_replyTCP Dest
extractAttachments
facilityAck, ECR Destination
facility_idSOAP Destination
facility_typeXDS Destination
fail_on_bodyHTTP Destination
fault_is_successSOAP Destination
fault_on_nakSOAP Source
fhirDestination
fileContract Ref, Source
file_nameAS2 Destination, Destination, Document Destination, FTP Destination, PGP Send, SFTP Destination
file_rootsScripts
filterChannel, Delimited, Destination, HL7v3 Options, Scripts
flow_controlSerial Source
follow_redirectsHTTP Destination
follow_symlinksFile Source
font_sizeDocument Destination
formatDocument Destination, S3 Destination
framedAzure Blob Source, FTP Destination, File Poll, GCS Source, S3 Destination, S3 Source, SFTP Destination, SFTP Source
framingTCP Framing
fromAS2 Destination, SMTP Destination
from_beginningKafka Source
ftpDestination, Source
gcsDestination, Source
groupChannel, Kafka Source
group_bySNS Destination, SQS Destination
has_headerDelimited
headerSOAP Destination
headersBroker Destination, CDA Destination, DICO Mweb Destination, FHIR Destination, HTTP Destination, Kafka Destination, SOAP Destination, XDS Destination
heartbeatBroker Source
hl7v3Channel
hostBroker Destination, Broker Source, Direct IMAP, Email Source, FTP Destination, FTP Source, SFTP Destination, SFTP Source, SMB Source, SMTP Destination
hostnameAMQP Connection
httpDestination, Source
ibm_mqDestination, Source
idAS2 Partner, AS2 Source
id_typeMPI Destination
identifier_systemsCDA Destination, FHIR Destination
idle_timeoutDICOM Source, Source, TCP Source
ignoreShadow
imapDirect Source
includeScripts
include_trigger_eventAck
insecure_skip_host_key_checkSFTP Destination, SFTP Source
insecure_skip_verifyFTP Destination, FTP Source, SMTP Destination, Web DAV Source
intervalDICOM Query Source
javascriptDestination, Source
kafkaDestination, Source
keep_aliveTCP Dest
keep_blank_linesDelimited
keep_delimiterTCP Framing
keyAzure Blob Access, Kafka Destination, S3 Destination
key_columnDatabase Source
key_fileAS2 Destination, AS2 Source, Direct Address, SFTP Destination, SFTP Source
key_passphraseSFTP Destination, SFTP Source
known_hosts_fileSFTP Destination, SFTP Source
landscapeDocument Destination
languageScripts
ldapDirect Destination
length_bytesTCP Framing
length_includes_headerTCP Framing
levelDICOM Query Source
limitDICOM Query Source
lineECR Facility
listenAS2 Source, DICO Mweb Source, DICOM Source, Direct Source, Direct XDR, HTTP Source, SOAP Source, Source, TCP Source
loginBroker Destination, Broker Source
mailboxDirect IMAP, Email Source
matchDICOM Query Source
max_attemptsDatabase Source, Queue Config
max_backoffQueue Config, Retry
max_bytesDirect Source
max_connectionsSource, TCP Source
max_depthQueue Config
max_differencesShadow
max_file_sizeFile Poll, SFTP Source
max_message_sizeAS2 Source, Email Source, HTTP Source, Kafka Source, SOAP Source, Serial Source, Source, TCP Dest, TCP Source
max_messagesPub Sub Source, SQS Source
max_object_bytesDICO Mweb Source, DICOM Source
max_object_sizeAzure Blob Source, GCS Source, S3 Source
max_open_connsDatabase Destination
mechanismKafka SASL
methodHTTP Destination
move_toAzure Blob Source, Direct IMAP, Email Source, File Poll, GCS Source, S3 Source, SFTP Source
mpiDestination
mqttDestination, Source
nameChannel, Channel Destination, Destination, ECR Facility
ncpdpChannel
no_compressPGP Send
no_mergeMPI Destination
non_persistentIBMMQ Destination
npiECR Facility
oauthEmail Source
objectGCS Destination
on_no_documentCDA Destination
order_byPub Sub Destination
overContract Ref
overlapDICOM Query Source
paramsDatabase Destination
paritySerial Source
partner_cert_fileAS2 Destination
partnersAS2 Source
pasDestination
pass_plainPGP Receive
passcodeBroker Destination, Broker Source
passphrasePGP Receive, PGP Send
passwordAMQP Connection, AS2 Destination, DICO Mweb Destination, DICO Mweb Source, Direct Destination, Direct IMAP, Direct Source, Email Source, FTP Destination, FTP Source, HTTP Destination, IBMMQ Connection, Kafka SASL, MQTT Connection, SFTP Destination, SFTP Source, SMB Source, SMTP Destination, SOAP Destination, SOAP Source, Web DAV Source, XDS Destination
pathAS2 Source, DICO Mweb Source, HTTP Source, SOAP Source
path_styleS3 Destination, S3 Source
patient_domainXDS Destination
patient_rootDICOM Query Source
patternFile Poll, SFTP Source
payerPAS Destination
persistentBroker Destination
pgpDestination, Source
phoneECR Facility
poll_intervalAzure Blob Source, Database Source, Direct IMAP, Email Source, File Poll, GCS Source, IBMMQ Source, Java Script Source, S3 Source, SFTP Source
portDirect IMAP, Email Source, Serial Source
postal_codeECR Facility
postprocessorScripts
practice_settingXDS Destination
prefetchAMQP Source
prefixAzure Blob Source, GCS Source, S3 Source
preprocessorScripts
profileSOAP Destination, SOAP Source
projectGCP Access
protocolEmail Source
providerEmail O Auth
pubsubDestination, Source
purposeTEFCA Destination
qosMQTT Connection
queryDatabase Source
query_timeoutDatabase Source
queueDestination, IBMMQ Connection
queue_managerIBMMQ Connection
queue_urlSQS Destination, SQS Source
quiet_afterSerial Source
quoteDelimited
rawFile Poll
rctcECR Destination, ELR Destination
read_timeoutHTTP Source, SOAP Source
reassembleAttachments
receiptAS2 Destination
receipt_urlAS2 Destination
recipient_certs_dirDirect Destination
reconnectAMQP Source, Broker Source, MQTT Source, Pub Sub Source
record_lengthTCP Framing
refresh_tokenEmail O Auth
regionAWS Access, FHIR Auth, S3 Destination
reject_on_warningFHIR Destination
relaxedDelimited
reopen_afterSerial Source
replySerial Source, TCP Source
reply_textSerial Source, TCP Source
request_mdnDirect Destination
require_agreementCDA Destination
require_signaturePGP Receive
response_elementSOAP Source
response_namespaceSOAP Source
response_transformerDestination
results_toPAS Destination, TEFCA Destination
retainMQTT Destination
retain_hoursDestination, Queue Config
retryDestination
returnDICOM Query Source
rootFTP Source, File Source, SMB Source
s3Destination, Source
sampleShadow
sasAzure Blob Access
saslKafka Destination, Kafka Source
schemeXDS Code
scopeEmail O Auth, FHIR Auth
scriptChannel, Java Script Destination, Java Script Source
scriptsChannel
secret_access_keyAWS Access, FHIR Auth, S3 Destination
securityDirect IMAP, Email Source, FTP Destination, FTP Source
selectorBroker Source
sender_deviceHL7v3 Options
sender_oidHL7v3 Options
serialSource
server_side_encryptionS3 Destination
session_timeoutKafka Source
session_tokenAWS Access, FHIR Auth, S3 Destination
sftpDestination, Source
shadowChannel
shareSMB Source
sign_withPGP Send
smbSource
smtpDestination
smtp_hostDirect Destination, Direct Source
snsDestination
soapDestination, Source
sop_classesDICOM Source
sort_byFile Poll
sourceChannel, ECR Destination
source_idXDS Destination
splitDelimited, EDIFACT Options, X12 Options
sqsDestination, Source
ssn_systemMPI Destination
stable_forFile Poll, SFTP Source
start_blockTCP Framing
starttlsSMTP Destination
stateECR Facility
statementDatabase Destination
stop_bitsSerial Source
storage_classGCS Destination, S3 Destination
subjectAS2 Destination, Direct Destination, SMTP Destination, SNS Destination
subscriptionPub Sub Source
subscription_idBroker Source
success_on_undefinedJava Script Destination
success_statusHTTP Destination
suffixAzure Blob Source, GCS Source, S3 Source
tablesChannel
takeEmail Source
tcpDestination, Source
tefcaDestination
temp_suffixDestination, Document Destination, FTP Destination, SFTP Destination
templateDatabase Source, Document Destination
tenantEmail O Auth
tenant_idFHIR Auth
textDirect Destination
tierAzure Blob Destination
timeoutAMQP Connection, Broker Destination, Broker Source, DICO Mweb Destination, DICOM Destination, DICOM Query Source, Database Destination, Destination, Direct Destination, Document Destination, FTP Destination, FTP Source, IBMMQ Connection, Java Script Destination, Java Script Source, Kafka Destination, Kafka Source, MQTT Connection, S3 Destination, SFTP Destination, SFTP Source, SMB Source, SMTP Destination, SOAP Destination, Scripts, Shadow, TCP Dest, Web DAV Source, XDS Destination
timezoneFHIR Destination
titleDocument Destination
tlsAMQP Connection, AS2 Destination, AS2 Source, Broker Destination, Broker Source, DICO Mweb Destination, DICO Mweb Source, DICOM Destination, DICOM Query Source, DICOM Source, Destination, Direct IMAP, Email Source, HTTP Destination, HTTP Source, IBMMQ Connection, Kafka Destination, Kafka Source, MQTT Connection, SOAP Destination, SOAP Source, Source, TCP Dest, TCP Source, XDS Destination
tls_certDirect Source, Direct XDR
tls_keyDirect Source, Direct XDR
toAS2 Destination, Direct Destination, SMTP Destination
tokenDICO Mweb Destination, DICO Mweb Source, HTTP Source, SOAP Source, XDS Destination
token_urlEmail O Auth, FHIR Auth
topicKafka Destination, MQTT Destination, MQTT Source, Pub Sub Destination
topic_arnSNS Destination
topicsKafka Source
transfer_syntaxesDICOM Destination, DICOM Source
transformationsChannel, DICOM Options, Delimited, HL7v3 Options, NCPDP Options, Script Options, X12 Options
transformerScripts
trim_paddingTCP Framing
trim_spaceDelimited
typeDestination, FHIR Auth, Source
undeployScripts
unencryptedAS2 Destination
unsignedAS2 Destination
unsigned_receiptAS2 Destination
urlAS2 Destination, CDA Destination, DICO Mweb Destination, FHIR Destination, HTTP Destination, IBMMQ Connection, SOAP Destination, Web DAV Source, XDS Destination
userFTP Destination, FTP Source, SFTP Destination, SFTP Source, SMB Source, Web DAV Source
usernameAMQP Connection, AS2 Destination, DICO Mweb Destination, DICO Mweb Source, Direct Destination, Direct IMAP, Direct Source, Email Source, HTTP Destination, IBMMQ Connection, Kafka SASL, MQTT Connection, SMTP Destination, SOAP Destination, SOAP Source, XDS Destination
validate_before_sendFHIR Destination
verify_withPGP Receive
versionCDA Destination, FHIR Destination, SOAP Destination, SOAP Source
visibility_timeoutSQS Source
wait_secondsSQS Source
warnings_are_errorsSOAP Destination
webdavSource
whenAck
windowDICOM Query Source
writeCDA Destination
wsdlSOAP Source
x12Channel
x12_asDestination
xdmDirect Destination
xdm_source_idDirect Destination
xdrDirect Source
xdsDestination