Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Introduction

Ductus is an integration engine: it receives messages, runs your code on them, and delivers them reliably to the systems that need them. Messages come in over HL7 v2's MLLP, HTTP, files in a folder or on an SFTP server, or are made on a schedule; they go out the same ways, or into another interface's queue. Interfaces are written as code (TypeScript, with a typed SDK) and configured in plain YAML files that live in Git, next to their tests.

Every message is stored in PostgreSQL before the sender is told it arrived, delivered at least once, in order where you ask for it, and kept for a window you choose. What happened to each one can be looked up, sent again or corrected, by people with the right to, and every such action is in the audit log.

Pre-release. Ductus is under active development. Downloads before v1.0 are development builds: there is no production support, there has been no external security audit, and the Windows installer isn't signed yet. Try it, test it, tell us what's missing; don't send real patient data through it yet.

What it is, and what it isn't

It is one program (ductus, with its web UI inside) and a PostgreSQL database. There is no message broker, no Java runtime and no Node.js to install: the engine runs your TypeScript in an embedded V8, and uses Postgres as its store and its queue. It runs as a service on Linux (systemd) or Windows, in a container, or on a laptop with a database of its own (ductus dev).

It is for the work an interface team does: feeds from an EHR to a lab or billing system, an API that other systems call and wait on, a nightly file from a partner, a lookup in a database on the way. It's written with healthcare in mind (HL7 v2 is a first-class format, retention and the audit log follow HIPAA's habits), and it works the same for any feed, API or batch job.

It isn't a low-code tool: there are no drag-and-drop mappings. Simple interfaces are a few lines of YAML (a feed relayed as it is needs no code at all), and anything more is TypeScript with types, tests and an editor that knows HL7's field names. It isn't a database or an archive either: messages are kept for one, three or seven weeks, and longer history belongs in the systems that produced it. And before v1.0 it doesn't cluster: one engine runs each interface, and a standby is started by a person (why).

Where to start

You want toRead
Decide where to run itChoosing Linux or Windows
Try it on your own machine in ten minutesOn your laptop: ductus dev, then Your first interface
Install it on a serverInstall, PostgreSQL, Ports and firewalls
Set up a project for realThe project and the pages after it
Know what could surprise youNuances, by topic and by level

How to read these docs

  • Guides (Install, Configure and the pages around them) explain how to do something, start to finish, and why each step is there. They link to the reference for every setting they name.
  • The reference lists everything: every command, API operation and setting, with its default and an example. It's made from the engine itself, so it always matches the version it comes with.
  • Nuances say what could surprise you, why it is so, and what to do about it, each with a level from 1 (everyone needs it on day one) to 5 (expert). Guides link to them rather than repeat them.
  • Architecture is how the engine works inside: for the curious, and for anyone who must explain its guarantees to someone else.

Every setting, command and page aims to say what it does, why you'd use it, when not to, its default, and an example. If one doesn't, that's a bug: please report it.

Choosing Linux or Windows

Ductus runs on both, from one code base and one project format: the same interfaces, the same YAML, the same TypeScript, the same database. A project moves from one to the other with only its file paths changed. Where they differ is in how each operating system starts, stops and upgrades a service, how it logs in to other Windows systems, and how much the build has been exercised. This page lists every difference we know of, each with the measurement or nuance behind it.

Our recommendation: Linux, unless the engine must log in to SQL Server with Windows accounts, or your team runs only Windows servers and would rather not keep one Linux server. Both are supported, and both behave the same in everything not in this table.

The differences

Linux (recommended)WindowsWhat it means for you
Install.deb or .rpm (Ubuntu 22.04+, Debian 12+, RHEL/Rocky/Alma 9), or the .tar.gzThe MSI, or the .zipSame engine and project files on both; a project moves between them, paths aside.
Runs asA systemd service, user ductus, sandboxed by the unit: it can write only /var/lib/ductus (the unit)A Windows service, NT SERVICE\Ductus by default; a gMSA, or a password account that needs one command after each upgrade (nuance)On Windows the account is what SQL Server and file shares see; on both, the engine can touch only what you give it (Windows).
Restart, crashsystemd keeps the listening sockets: 0 connections refused; senders wait 0.2–0.3 s for a restart, ~5 s after a crash (measured)Restart-Service: 0 refused, a few connections reset, longest wait ~1.1 s (measured)Senders resend on their own in both cases.
UpgradeThe handover: 0 refused, sending paused ~35 ms (1 interface) to ~0.6 s (1,001 interfaces), receiving never stops; a broken new version leaves the old one running (measured, broken version)The MSI: the engine is down 3.1–3.5 s (a few refused, nothing lost); where Group Policy turns Restart Manager off, msiexec first waits 100–160 s while the engine keeps running (measured, why the wait). Zero-downtime Windows upgrades are planned for v1.0Linux upgrades need no quiet moment; on Windows, pick one.
Deploy project changessystemctl reload ductus: the same handover (nuance)Restart-Service Ductus: the same short gap as a restartAs above.
SQL Server Windows loginsNTLM only, with a warning; no Kerberos before v1.0 (nuance)Integrated: Kerberos, with NTLM as Windows falls back; as a gMSA or a named account (nuance)A DBA who allows only Windows logins: prefer Windows.
An outgoing connection refusedKnown at onceTakes ~1 s (Windows tries again before it says so); localhost may be ::1 first (measured)Write 127.0.0.1, not localhost; keep an MLLP destination's timeout_ms above ~2 s on Windows.
LogsThe journal: journalctl -u ductusFiles in C:\ProgramData\Ductus\logs (14 days), and the Event Log for what operations watch: IDs 1000–1100, each kind at most once a minute (nuance)SCOM, Splunk and Event Viewer read the Event Log on Windows; the journal feeds the same tools on Linux.
CertificatesThe system's store; a private CA (for LDAP, Postgres, a partner) usually needs ca: (nuance)The machine's store; the domain's CA is usually there already (nuance)Name a private CA per connection on both; it always works.
Paths/etc/ductus, /var/lib/ductusC:\ProgramData\Ductus; a path without a drive is read from the project's drive; single-quote backslashes in YAML (nuance)ductus validate warns about Linux paths on Windows.
ContainersThe Docker image (a development build, not published before v1.0)NoneKubernetes is planned for v1.2.
Tested on every pushEvery test, all the end-to-end demos, the packages under real systemd (255 and 252), the web UI in a browserEvery Rust test except those needing Linux helpers (a TLS Postgres, OpenLDAP, sending from 127.0.0.2), SQL Server Express with Windows logins, the MSI (install, service, Event Log, upgrades, uninstall) (the workflow)Linux has more mileage. Neither has been tested against a real Active Directory yet: the first pilot with a domain does that.

Plan for these on either

  • Reboots (patch nights): a reboot is a stop and a start. The engine stops cleanly (Windows gives the service its preshutdown time, 90 s; systemd sends SIGTERM and waits 45 s), and it starts with the server. While the server is down, senders are refused and retry, as with any engine; systemd's socket store doesn't survive a reboot. Reboot in a quiet moment, or move the interfaces to another engine first (several engines).
  • Antivirus and endpoint protection that scans every file write slows the database far more than the engine: the engine writes only its logs; Postgres writes every message. Ask for the usual exclusions for Postgres's data and WAL folders on its server (your DBA will know them), and for the engine's project folder if a scan on read slows its start.
  • Postgres is the same on both: the DBA's server, on Linux or Windows, whatever the engine runs on (PostgreSQL).
  • Time zone: cron schedules, quiet hours and monitor: times are in the server's local time on both. A server (or container) that runs on UTC reads "06:00-22:00" as UTC.

How the two are kept the same

One code base and one project format; the whole Rust test suite runs on both on every push; Windows-only behaviour is written down as a nuance the day it's found (the Windows section, and the Windows rows in other sections). When a difference is fixed (a resident launcher for zero-downtime Windows upgrades is planned for v1.0, a Kerberos keytab on Linux too), its row here changes in the same commit.

Install

Ductus is one program, ductus (ductus.exe on Windows): the engine, its command line and the web UI, all in one file of about 140 MB. It needs a PostgreSQL database, which is never bundled: in production that is your DBA's Postgres, on the same server or another. Everything the engine maintains in it (tables, daily partitions, retention, cleanup) it does by itself, while it runs: there are no cron jobs or scheduled tasks to set up.

Which way to install

WayForPage
.deb / .rpmA Linux server: the service, its user, its folders, upgrades in place with no connection refusedLinux packages
.msiA Windows server: the service, its account, the Event Log, upgrades in placeWindows installer
.tar.gz / .zipChecking an upgrade before installing it; servers where the packages can't be usedThe portable archive
Docker imageTrying it with Compose; containers in developmentDocker
ductus devYour own laptop: the engine with a Postgres of its own, nothing else to installOn your laptop

Not sure between Linux and Windows? Choosing Linux or Windows lists every difference.

Each release on GitHub holds all of these, plus the docs for that version as a zip, SBOMs and SHA256SUMS. Check a file before you install it: Downloading and checking a release.

What you need first

  • A server: 64-bit x86 (x86_64/amd64; no ARM builds yet), Linux with glibc 2.34 or later (RHEL 9 and its rebuilds, Ubuntu 22.04, Debian 12 and later; RHEL 8 can't run it), or 64-bit Windows (tested on every push on GitHub's current Windows Server runner, and on Windows 11).
  • Memory: the engine itself is small (measured: 1,001 interfaces loaded and idle in 111 MB); what grows is what interface code holds, up to isolates.heap_mb (512 MB) per worker thread at most, which most interfaces never come near. Watch the process once real traffic runs (systemctl status ductus shows its memory; Task Manager on Windows), and see memory for interface code for what makes it grow. During a Linux upgrade two engines run for a few seconds, so leave room for a second copy.
  • CPU: one worker thread per core by default. A feed of a million HL7 messages a day averages 12 a second; one core runs a realistic HL7 transform at about 14,800 a second (measured), so CPU is rarely what limits it: the database's disk is.
  • PostgreSQL 14 or later (18 recommended), with a login the engine owns its tables with: PostgreSQL.
  • The ports your senders connect to, and the ones the engine connects out on, opened in the firewall: Ports and firewalls.
  • A project: the folder of interfaces the engine runs, usually a Git repository. A new one starts with ductus init; Your first interface walks through it.

What every install has in common

The engine is configured by a few environment variables, the same on every platform (the packages read them from a settings file: /etc/ductus/ductus.env on Linux, C:\ProgramData\Ductus\ductus.env on Windows):

VariableWhat it saysDefault
DUCTUS_DATABASE_URLThe engine's database, as a PostgreSQL URL: postgres://ductus:PASSWORD@db1.hospital.org/ductus?sslmode=verify-full. The engine creates its tables at its first start.None: ductus run refuses to start without it, and says so
DUCTUS_PROJECTThe project folder (interfaces/, ductus.yaml, environments/).The current folder
DUCTUS_ENVWhich environment's variables and secrets to use (environments).dev
DUCTUS_SECRET_KEYThe key that opens secrets/<env>.json.None (no secrets)
DUCTUS_API_LISTENWhere the REST API and web UI listen, in place of api.listen in ductus.yaml.127.0.0.1:7700 (this machine only)
DUCTUS_THREADSWorker threads for interface code.One per CPU core
DUCTUS_DB_CONNECTIONSThe most connections the engine opens to its own database (plus one it holds for its lock).32
DUCTUS_STOP_TIMEOUTHow long a stop may take to finish the messages in hand.30s
RUST_LOGHow much the log says: info, or info,ductus_runtime=debug for one part in detail.info

Every ductus command takes --env-file FILE to read a file of these (NAME=value lines) before anything else: ductus doctor --env-file /etc/ductus/ductus.env runs with exactly the service's settings. Variables already set win over the file.

After installing

  1. Give it a database: PostgreSQL.
  2. Put the project in place and set DUCTUS_PROJECT and DUCTUS_ENV.
  3. Run ductus doctor --env-file … on the server: it checks every connection the project makes, from where the engine will make it, and says in plain words what a failure means.
  4. Start the service, and make the first admin account: ductus user create you --role admin --env-file … (people).
  5. Open the web UI: http://127.0.0.1:7700 on the server itself, or set up api.tls to use it from your own computer.

Downloading and checking a release

Every release is on GitHub: github.com/thorst/ductus/releases. Each one holds the packages for every way to install, the docs for that version as a download, lists of every library inside (SBOMs), and what you need to check that a file is the one the project built: its SHA-256 checksum and GitHub's record of the build (an attestation).

Check before you install, every time: a file downloaded through a proxy, a mirror or a colleague's share could have been changed on the way, and on Windows the installer isn't code-signed before v1.0, so nothing else tells you it's genuine. You can skip it for the ductus dev laptop setup built from source, where nothing was downloaded.

Pre-releases until v1.0. Every release before 1.0 is marked pre-release: for trying, testing and pilots, not production. Settings can change between releases; the upgrade page and ductus upgrade --check say what each one changes.

Which file

FileForPage
ductus_0.6.0_amd64.debUbuntu 22.04 and later, Debian 12 and laterLinux packages
ductus-0.6.0-1.x86_64.rpmRHEL, Rocky, Alma 9Linux packages
ductus-0.6.0-x64.msiWindows Server, Windows 11Windows installer
ductus-0.6.0-linux-x64.tar.gz, ductus-0.6.0-windows-x64.zipThe program alone: checking an upgrade before installing it, servers without the packagesThe portable archive
ductus-docs-0.6.0.zipThese docs, as they are for this version, to read offlineThe docs as a download
ductus-0.6.0-linux-x64.cdx.json, ductus-0.6.0-windows-x64.cdx.json, ductus-0.6.0-web-ui.cdx.jsonYour security team: every library in each buildThe SBOMs
SHA256SUMSChecking any of the files aboveBelow

Every file comes from the same CI run, on the tagged commit, and only after every test and demo passed on it: the Rust tests on Linux and Windows, the end-to-end demos, the packages installed, upgraded and removed under real systemd, the MSI installed, upgraded and removed as a service. Nothing is built by hand.

Check the checksum

SHA256SUMS lists every file of the release with its SHA-256. Download it beside the files you took, and check them.

On Linux, in the folder with the downloads (--ignore-missing skips the files you didn't download):

sha256sum --check --ignore-missing SHA256SUMS

Each file says OK. Anything else (FAILED, No such file) means the file isn't the one the release holds: download it again from the release page, and don't install it. On the release job's dry run (version 0.0.0-dryrun.118; a release's files are named the same way with its version), with only the .deb downloaded beside SHA256SUMS, then the same file with one byte added to it:

$ sha256sum --check --ignore-missing SHA256SUMS
ductus_0.0.0~dryrun.118_amd64.deb: OK
$ sha256sum --check --ignore-missing SHA256SUMS
sha256sum: WARNING: 1 computed checksum did NOT match
sha256sum: SHA256SUMS: no file was verified
ductus_0.0.0~dryrun.118_amd64.deb: FAILED

The second exits 1, so a script can stop on it. (A pre-release's .deb and .rpm are named with ~ where the version has -: that is how Debian and RPM spell "sorts before the release".)

On Windows, in PowerShell, the hash of the file, then the line SHA256SUMS has for it:

(Get-FileHash .\ductus-0.6.0-x64.msi -Algorithm SHA256).Hash
Select-String ductus-0.6.0-x64.msi .\SHA256SUMS

The two must be the same 64 characters (Get-FileHash writes them in capitals, SHA256SUMS in small letters: the case doesn't matter). On the dry run's MSI:

PS> (Get-FileHash .\ductus-0.0.0-dryrun.118-x64.msi -Algorithm SHA256).Hash
3F8DB975E899350CCC54FC0DAF3F659A7BC5A1BA69DE074ADE95AF47BE43A47D
PS> Select-String ductus-0.0.0-dryrun.118-x64.msi .\SHA256SUMS

SHA256SUMS:6:3f8db975e899350ccc54fc0daf3f659a7bc5a1ba69de074ade95af47be43a47d  ductus-0.0.0-dryrun.118-x64.msi

Check who built it: the attestation

A checksum proves the file matches SHA256SUMS; it doesn't prove who wrote SHA256SUMS. Every file of a release, SHA256SUMS included, is also attested by GitHub: a signed record, made by the release job as it built the file, that names the repository, the workflow and the exact commit. GitHub signs it through Sigstore's public log, so the record can't be changed afterwards, even by the project.

Check one with the GitHub CLI, signed in with any GitHub account (gh auth login):

gh attestation verify ductus_0.6.0_amd64.deb -R thorst/ductus

It says whether it found an attestation for exactly this file, made by a workflow of thorst/ductus, and ends with the verification succeeding. If it finds none, or one from another repository, don't install the file. On Windows it's the same command (gh installs with winget install GitHub.cli); it's the check that replaces a code signature until v1.0 (nuance).

When to use which: the checksum always (it needs nothing but the system's own tools); the attestation once per release on the machine you download on, before the file is copied to servers, or wherever your security policy asks for proof of origin.

The SBOMs: every library inside

A Software Bill of Materials lists every library a program is made of, with its version and license, in a format tools read: these are CycloneDX 1.5 JSON, the format Dependency-Track, Microsoft Defender and most scanners import.

FileLists
ductus-0.6.0-linux-x64.cdx.jsonEvery Rust crate compiled into the Linux binary (the .deb, .rpm and .tar.gz hold the same one)
ductus-0.6.0-windows-x64.cdx.jsonThe same for ductus.exe (the .msi and .zip): a few crates differ by platform
ductus-0.6.0-web-ui.cdx.jsonThe web UI's npm packages (the UI is built into both binaries)

On the dry run: 530 crates in the Linux binary's, 534 in the Windows one's, 95 npm packages in the web UI's, about 700 KB, 700 KB and 100 KB. They leave out the tools used only while building (code generators, the build scripts' own libraries): those aren't in the program you run. The same lists, for reading, with the notable libraries first (V8, TypeScript, rustls, sqlx, tokio), are on Libraries and versions; that page also counts build-time crates, so its numbers are a little higher. Use the SBOMs when your security team asks "what's in it", or to be told when a library in it has a published vulnerability. Scanning them for you is planned for v0.9.

The docs as a download

ductus-docs-0.6.0.zip is this site as it is for that version: unzip it anywhere, open ductus-docs-0.6.0/index.html in a browser. Every page says the version in its title. It's about 3.2 MB (3,212,719 bytes on the dry run). It's for:

  • A server room or network without internet access: the docs are with the download, nothing is fetched from anywhere.
  • The version you run, not the newest: docs.getductus.com describes the latest release; the zip describes the one you installed, and stays that way.

Search works from the unzipped folder (opened as file://): it's mdBook's own search, which loads its index from the folder. The site's search (Pagefind) needs a web server, so the zip doesn't have it. Don't put the zip's folder on a web server as your copy of the site: it works, but people then read an old version's docs as current; link to docs.getductus.com instead.

Before a release: development builds

Until a release exists, every push to main builds the packages, the archives and the installer in CI as development builds (versions like 0.6.0-dev.2 and 0.6.0-ci, kept 90 days as the run's artifacts linux-packages and windows-installer; no checksums, attestations or SBOMs), and the packages and the installer can be built from a clone of the repository. They are for testing the project itself: use a release for anything else.

Linux packages

The .deb (Ubuntu, Debian) and .rpm (RHEL, Rocky, Alma) install the engine as a systemd service with its own user, a settings file and a folder for the project. Upgrades hand the running engine over to the new version with no connection refused.

Use them on any Linux server that runs Ductus for real. Don't where you can't install packages (then the portable archive has the same unit and settings file as samples), or in a container (the image).

Both packages hold the same binary, built once on Rocky Linux 9: it runs wherever glibc is 2.34 or later (RHEL 9 and its rebuilds, Ubuntu 22.04, Debian 12 and later). RHEL 8 (glibc 2.28) can't run it.

Install

# Ubuntu, Debian
sudo apt install ./ductus_0.6.0_amd64.deb
# RHEL, Rocky, Alma
sudo dnf install ./ductus-0.6.0-1.x86_64.rpm

Both files are on the release page. Check the one you downloaded first, in its folder with the release's SHA256SUMS (each file you have says OK):

sha256sum --check --ignore-missing SHA256SUMS
gh attestation verify ductus_0.6.0_amd64.deb -R thorst/ductus    # GitHub built it, from the tagged source

Before v0.6.0 is released, packages are development builds: docker build --target packages --build-arg VERSION=0.6.0-dev.1 --output dist . in a clone of the repository makes both.

The install creates the ductus user and group, and doesn't start anything: nothing is configured yet. It prints what to do next, which is the rest of this page.

What the package installs

PathWhat
/usr/bin/ductusThe engine and its command line (the web UI is inside it).
/usr/lib/systemd/system/ductus.serviceThe service: runs as ductus, sandboxed (below).
/etc/ductus/ductus.envSettings: the database URL, the project, the environment, the secrets key. Readable by root and the ductus group only. An upgrade never replaces it.
/var/lib/ductusThe service's home and the only place it can write. The project goes in /var/lib/ductus/project.

The sandbox. The unit lets the engine use the network and its own folder, and nothing else: the rest of the file system is read-only to it (ProtectSystem=strict), home folders are hidden (ProtectHome=yes), and it has no capabilities. That's on purpose: one bad interface can't write anywhere on the server. It means a file destination or a file source that moves files must work in a folder you give it:

sudo systemctl edit ductus
# in the editor that opens:
#   [Service]
#   ReadWritePaths=/data/outbound /data/inbound
sudo systemctl restart ductus

Without that the interface's messages fail with "Read-only file system", and wait to be tried again (nuance). A folder for a source needs the same when the source moves or deletes what it read (the default, after: move). A port below 1024 (an MLLP port of 104, say) needs a capability too; the unit says which two lines to add with systemctl edit.

Configure

  1. Settings. Edit /etc/ductus/ductus.env (as root). At least the database:

    # /etc/ductus/ductus.env
    DUCTUS_DATABASE_URL=postgres://ductus:PASSWORD@db1.hospital.org/ductus?sslmode=verify-full
    DUCTUS_PROJECT=/var/lib/ductus/project
    DUCTUS_ENV=prod
    DUCTUS_SECRET_KEY=…
    

    The file's comments explain each line; every variable is in the install overview. For the database login and TLS, see PostgreSQL. DUCTUS_SECRET_KEY is the key your project's secrets/prod.json was encrypted with (secrets).

  2. The project. Clone it as the service's user, so the files are its own:

    sudo -u ductus git clone git@git.hospital.org:interfaces/ductus.git /var/lib/ductus/project
    

    The ductus user has no shell (nologin), but sudo -u ductus runs a command as it. For a repository that needs a login, give the user a deploy key that can only read (/var/lib/ductus/.ssh/), or copy the project in from your deployment tool. The engine only reads the project: it never writes there.

  3. Check before starting. As root or the ductus user, with the service's own settings:

    sudo -u ductus ductus validate --env-file /etc/ductus/ductus.env --root /var/lib/ductus/project
    sudo -u ductus ductus doctor --env-file /etc/ductus/ductus.env --root /var/lib/ductus/project
    

    validate checks the project as the engine will read it, in the environment it will run in, and against the database (reading only): lane changes that would strand waiting messages, retention changes still running. --root is needed: unlike run, these commands don't read DUCTUS_PROJECT. doctor connects to everything the project connects to, from this server, and says in words what a failure means: a firewall, a certificate, a login.

Start, stop, look

sudo systemctl enable --now ductus     # start now and at every boot
systemctl status ductus                # "running 12 interfaces" once every one is up
journalctl -u ductus -f                # the log; "started interfaces=12 …" when ready
  • systemctl start waits until every interface is up (the engine tells systemd when it's ready). A start migrates the database first, so after an upgrade it can take longer; there's no time limit.
  • A stop is clean: no new connections, the messages in hand finished and saved, exit 0 (what finishes). systemd waits 45 s, longer than the engine's own limit (DUCTUS_STOP_TIMEOUT, 30 s).
  • A restart or a crash refuses no connection: systemd keeps the listening sockets and hands them to the next start; senders wait in the queue (0.2–0.3 s for a restart, ~5 s after a crash: measured). A stop, a failed start and a drain do close the ports, on purpose (why).
  • A refused start isn't retried. Exit code 3 means another engine runs some of these interfaces on the same database, or a change to an interface's lanes would strand waiting messages. Trying again changes neither, so systemd doesn't (RestartPreventExitStatus=3): the log says what to do, then systemctl start ductus. Any other failure is retried every 5 s.
  • Logs go to the journal, plain text with fields (interface=adt-in). RUST_LOG in ductus.env sets how much: info by default.

Deploy a change, upgrade

Project changes (new code, a new interface): pull the project, then reload. The new project starts beside the running engine and takes over once it's ready; nothing is refused, and a project that can't start leaves the running one serving (deploying):

sudo -u ductus git -C /var/lib/ductus/project pull
sudo -u ductus ductus validate --env-file /etc/ductus/ductus.env --root /var/lib/ductus/project
sudo systemctl reload ductus

Upgrades: run the new version's ductus upgrade --check from its portable archive first, then install the new package the same way as the first. The package hands the running engine over to the new version (systemctl reload again: 0 refused, sending paused for a moment: measured). A new version that can't start leaves the old one running, and the install says so. Rolling back is installing the previous package: the database changes a version makes only ever add, so the version before runs on them.

Remove

sudo apt remove ductus (or dnf remove) stops and disables the service and removes the program. The ductus user, /etc/ductus/ductus.env and /var/lib/ductus (the project) stay, and so does everything in the database: removing Ductus never deletes messages.

Windows installer

The MSI installs the engine as a Windows service, Ductus, with a settings file, a folder for the project and its logs, and an Event Log source. It installs silently for Group Policy, SCCM or Intune, and upgrades in place.

Use it on a Windows server that runs Ductus for real. Don't where you can't run an installer (then the .zip has the program alone), or to try Ductus on your own machine (ductus dev needs no installer and no administrator).

Unsigned before v1.0. The installer isn't signed with a code-signing certificate yet, so Windows SmartScreen says "Windows protected your PC" and "unknown publisher" when it's run from a download. Check the file before you run it: its SHA-256 against the release's SHA256SUMS (Get-FileHash ductus-0.6.0-x64.msi), and that GitHub built it from the tagged source (gh attestation verify ductus-0.6.0-x64.msi -R thorst/ductus): both are on Downloading and checking a release. Then "More info" → "Run anyway", or unblock it first (Unblock-File). Signing comes with v1.0 (nuance).

Install

As an administrator:

msiexec /i ductus-0.6.0-x64.msi /qn          # silent; without /qn, the usual wizard

Before v0.6.0 is released, MSIs are development builds: packaging/windows/build.ps1 in a clone of the repository makes one (it needs Rust, the Visual Studio Build Tools and WiX 5: see packaging/README.md).

The install registers the service (it starts automatically at boot) but doesn't start it: nothing is configured yet, and without a database it would stop at once.

What the installer puts where

PathWhat
C:\Program Files\Ductus\ductus.exeThe engine and its command line (the web UI is inside it). The folder is on the system PATH.
C:\ProgramData\Ductus\ductus.envSettings (NAME=value lines): the database URL, the project, the environment, the secrets key. Written once as a template; never overwritten.
C:\ProgramData\Ductus\project\The project (a Git clone, usually).
C:\ProgramData\Ductus\logs\ductus.<date>.log, a file a day, the last 14 kept.
Service Ductus"Ductus Integration Engine": ductus.exe run --service --env-file C:\ProgramData\Ductus\ductus.env, as NT SERVICE\Ductus unless you choose another account (below).
Event Log source DuctusIn the Application log: what operations teams watch for (IDs).

C:\ProgramData\Ductus can be read by administrators, SYSTEM and the service only: ductus.env holds the database password. Keep it that way.

The service's account

The account is what SQL Server, file shares and anything else on the network see when the engine logs in with Windows. Three choices (nuance):

AccountHowWhen
NT SERVICE\Ductus (the default)Nothing to do. A virtual account: no password; on the network it is the computer (HOSPITAL\SERVER01$).The engine logs in to nothing with Windows, or a DBA grants the computer's account.
A gMSA (group managed service account)msiexec /i ductus-0.6.0-x64.msi /qn SERVICEACCOUNT=HOSPITAL\svc-ductus$The engine needs its own identity on the network (a SQL Server Windows login, a share). Windows keeps the password; upgrades keep the account. The best choice when you need one.
An account with a passwordInstall, then ductus service account HOSPITAL\svc-ductus --start (it asks for the password; --password-stdin in scripts)Only where a gMSA isn't possible: after every upgrade the service waits for the password again (why).

The installer (and ductus service account) grants the account "Log on as a service". Where Group Policy manages that right, ask for the account to be added to the policy, or the next policy refresh takes it away (nuance). ductus service account with no name says who the service runs as now.

Folders outside C:\ProgramData\Ductus. The service can touch only what you give it: a file interface's inbox, archive or output folder needs a grant (nuance):

icacls "D:\Interfaces" /grant "NT SERVICE\Ductus:(OI)(CI)M"

That works whatever account runs the service (its own SID is in its token). For a network share, grant the computer account (HOSPITAL\SERVER01$), or the gMSA.

Configure

  1. Settings. Edit C:\ProgramData\Ductus\ductus.env as an administrator (Notepad run as administrator works). At least the database:

    DUCTUS_DATABASE_URL=postgres://ductus:PASSWORD@db1.hospital.org/ductus?sslmode=verify-full
    DUCTUS_PROJECT=C:\ProgramData\Ductus\project
    DUCTUS_ENV=prod
    DUCTUS_SECRET_KEY=…
    

    No quotes are needed, and backslashes are plain backslashes here. Every variable is in the install overview; the database's login and TLS are on the PostgreSQL page. Secrets are never installer properties (they'd be written to the install log): the password lives in this file only.

  2. The project. Clone it into C:\ProgramData\Ductus\project (or point DUCTUS_PROJECT elsewhere and grant the service read access there). In YAML, write Windows paths with their drive and in single quotes ('D:\Interfaces\in'): in double quotes a backslash starts an escape (nuance).

  3. Check before starting, with the service's own settings:

    ductus validate --env-file C:\ProgramData\Ductus\ductus.env --root C:\ProgramData\Ductus\project
    ductus doctor --env-file C:\ProgramData\Ductus\ductus.env --root C:\ProgramData\Ductus\project
    

    These run as you, not as the service's account: a folder or a SQL Server Windows login can pass here and fail for the service. The service's log says "access denied" then.

Start, stop, look

Start-Service Ductus           # after a change to ductus.env: Restart-Service Ductus
Get-Service Ductus
Get-ChildItem C:\ProgramData\Ductus\logs\ductus.*.log | Sort-Object LastWriteTime |
  Select-Object -Last 1 | Get-Content -Tail 50 -Wait      # the newest log, as it grows

The log files are named by the date in UTC (ductus.2026-10-30.log), so near midnight the newest one may carry tomorrow's or yesterday's date.

  • Starting and stopping report progress to the service manager, so a long migration or a stop finishing the messages in hand isn't taken for a hang.
  • A stop is clean, whether from the service manager, a Windows shutdown (the service takes its preshutdown time, 90 s) or ductus drain --stop: the messages in hand are finished and saved (what finishes).
  • A restart is a short gap: measured 0 refused, a few connections reset, the longest wait for an ACK ~1.1 s (measured). Senders resend by themselves.
  • If it fails it's started again after a minute, twice, then waits for a person. A start refused because another engine runs these interfaces isn't retried.
  • The Event Log gets what operations watch for: started, stopped, couldn't start (with the reason), an interface stopped, a critical alert, and other errors at most once a minute per kind (IDs, nuance). The detail is in the log files.

Upgrade

Run the new version's ductus upgrade --check from its .zip first, then the new MSI the same way as the first (msiexec /i … /qn; no need to uninstall). The installer stops the service cleanly, replaces the program and starts it again; settings, project and logs are never touched. The engine is down for 3.1–3.5 s (measured: a few connections refused, nothing lost), so pick a quiet moment (measured). A sender sending through an upgrade over TLS, on CI's runner: 8 connection attempts refused, the slowest message 3.3 s to its ACK, 12,000 of 12,000 received once each and in order (the demo's output).

  • A service someone stopped on purpose stays stopped after the upgrade (nuance).
  • A password account leaves the service stopped, waiting for ductus service account NAME --start (Event Log 1005 says so).
  • Where Group Policy turns Restart Manager off, msiexec may wait 100–160 s before it stops the service (the engine keeps running meanwhile). Close Event Viewer first, or stop the service yourself first to save the wait (why).
  • Rolling back is uninstalling, then installing the previous MSI: the installer refuses to put an older version over a newer one ("A newer version of Ductus is already installed. Uninstall it first to go back."). C:\ProgramData\Ductus and the database stay; give SERVICEACCOUNT= again if the service ran as another account. The database changes a version makes only ever add, so the version before runs on them (Going back). demo-windows.ps1 goes back this way on every push.

Uninstall

"Apps" in Settings, or msiexec /x ductus-0.6.0-x64.msi /qn: removes the service, the program and the Event Log source. C:\ProgramData\Ductus (settings, project, logs) stays, and so does everything in the database.

The portable archive

The engine as one program, without an installer: ductus-0.6.0-linux-x64.tar.gz and ductus-0.6.0-windows-x64.zip, each unpacking into a folder ductus-0.6.0. Unpack it anywhere and run it; nothing is registered, and nothing is installed. Both are on each release, with SHA256SUMS to check them against.

Use it to check an upgrade before installing it (the main reason it exists), to try a new version beside the installed one, to run the command line on a workstation (doctor, replay, user), and on servers that can't take packages. Don't use it as the way to run production when a package will do: the packages set up the service, its user, its sandbox and its folders, and upgrade in place with no connection refused.

FileWhat
ductus (ductus.exe)The engine and its command line; the web UI is inside it.
LICENSEApache-2.0.
README.mdWhat this page says, in short.
ductus.service, ductus.env (Linux only)The systemd unit and settings file the packages install, as samples.

Before an upgrade

Run the new version's program against the database and project your engines use now, before the change window:

tar xzf ductus-0.6.1-linux-x64.tar.gz && cd ductus-0.6.1
./ductus upgrade --check --env-file /etc/ductus/ductus.env --root /var/lib/ductus/project
Expand-Archive ductus-0.6.1-windows-x64.zip -DestinationPath C:\Temp     # makes C:\Temp\ductus-0.6.1
C:\Temp\ductus-0.6.1\ductus.exe upgrade --check --env-file C:\ProgramData\Ductus\ductus.env --root C:\ProgramData\Ductus\project

It reads only, changing nothing in the database or the project, and says what the new version's first start would do: the database changes it makes (and how big the tables they work through are now), which engines run on the database and on which versions, whether the project validates and passes its tests with the new version, and which settings it deprecates. It exits 1 on anything that would refuse the start or fail the project; --json gives one document to attach to a change ticket (nuance).

A read-only database login is enough, and safer for a program you're trying out:

CREATE ROLE ductus_check LOGIN PASSWORD '…';
GRANT pg_read_all_data TO ductus_check;

Then DUCTUS_DATABASE_URL=postgres://ductus_check:…@db1.hospital.org/ductus?sslmode=verify-full in place of --env-file (keep --env prod and the secrets key, which the check needs to read the project as the engine does). The same login suits ductus replay, which runs a day's traffic through the new version and compares (nuance).

Installing from the archive

Prefer the packages. Without them, on Linux, the archive's samples give the same setup by hand:

sudo install -m 0755 ductus /usr/bin/ductus
sudo useradd --system --home-dir /var/lib/ductus --create-home --shell /usr/sbin/nologin ductus
sudo install -d -m 0755 /etc/ductus
sudo install -m 0640 -g ductus ductus.env /etc/ductus/ductus.env   # then edit it
sudo install -m 0644 ductus.service /usr/lib/systemd/system/ductus.service
sudo systemctl daemon-reload && sudo systemctl enable --now ductus

Everything on Linux packages from "What the package installs" on then applies, except that upgrades are yours: replace /usr/bin/ductus and run sudo systemctl reload ductus (the handover, as the package does). On Windows, the .zip has no service setup: use the MSI for a service; ductus.exe from the zip is for the command line and for trying a version.

The program needs glibc 2.34 or later on Linux (RHEL 9 and its rebuilds, Ubuntu 22.04, Debian 12 and later), and 64-bit Windows.

Docker

The image runs the same engine as the packages, in a container: Debian 13 slim, the binary at /usr/local/bin/ductus, the system's CA certificates, running as user ductus (uid 10001), never root. The project is mounted read-only at /project.

Use it to try Ductus with its database in one command, and to develop with containers. Don't use it for production before v1.0: the image is a development build, it isn't published to a registry yet (you build it), and a container's restart refuses senders for a moment, where the Linux package's service refuses none (nuance). From v1.0, production images will be built FROM the engine's image with the project inside: one image per release of your interfaces. Kubernetes, with rolling upgrades, is planned for v1.2.

Try it with Compose

From a clone of the repository:

docker build -t ductus:dev .
PROJECT=$PWD/examples/adt-relay docker compose -f packaging/docker/compose.yaml up

That starts Postgres 18 and the engine on the example project. Then, in a second terminal, make yourself an account (the database is the container's, so the command runs there):

docker compose -f packaging/docker/compose.yaml exec engine ductus user create me --role admin

and sign in to the web UI at http://127.0.0.1:7700.

The Compose file is short and worth reading before you change it:

# compose.yaml (packaging/docker/compose.yaml, in part)
services:
  engine:
    image: ${IMAGE:-ductus:dev}
    environment:
      DUCTUS_DATABASE_URL: postgres://ductus:${POSTGRES_PASSWORD:-ductus}@postgres/ductus
      DUCTUS_ENV: ${DUCTUS_ENV:-dev}
      DUCTUS_SECRET_KEY: ${DUCTUS_SECRET_KEY:-}
    volumes:
      - ${PROJECT:?set PROJECT to your project folder}:/project:ro
    ports:
      - 127.0.0.1:7700:7700   # the API and web UI: this machine only
      - 6661:6661             # each interface's listener
    stop_grace_period: 45s

What to know

  • Settings are environment variables, the same as everywhere (the list). Keep DUCTUS_SECRET_KEY and the database password out of the Compose file: Compose reads ${...} from a .env file beside it (one you don't commit), and an orchestrator can pass its secrets in as environment variables. The engine reads them only as variables, not as files.
  • The API listens on 0.0.0.0:7700 inside the container, so Docker can publish it: the image sets DUCTUS_API_LISTEN=0.0.0.0:7700, which wins over api.listen in ductus.yaml (set the variable to move it). Publish it to 127.0.0.1 (as above) unless the API has tls: or a TLS proxy is in front: the web UI's sign-in needs HTTPS anywhere but on the machine itself.
  • Publish each interface's listener (6661:6661 for an MLLP port). ductus ports lists every port a project listens on.
  • Give a stop time. Docker waits 10 s by default before it kills a container; a clean stop can need up to DUCTUS_STOP_TIMEOUT (30 s). stop_grace_period: 45s (or docker stop --time 45) lets the engine finish the messages in hand (what finishes).
  • The project is read-only: interfaces change through Git and a new container, never inside a running one. A file destination needs a volume of its own, writable by uid 10001.
  • The database in the Compose file is for trying it out. For anything that matters, point DUCTUS_DATABASE_URL at the DBA's Postgres (PostgreSQL).
  • Time zone: the image runs on UTC unless you set TZ. Cron schedules, quiet hours and monitor: times are read in the container's local time.

On your laptop: ductus dev

ductus dev runs a project with a PostgreSQL of its own: no database to install, nobody to ask, nothing to configure. It's how you write and try interfaces on your own machine, on Linux or Windows.

Use it for development, demos and learning. Don't use it to run anything that matters: it uses Postgres's default settings, nobody backs its data up, and it has no service, no handover and no restart on failure (nuance). Everywhere else, use ductus run (or a package) with your DBA's Postgres.

Get the program

Download the portable archive for your system and put ductus (ductus.exe) on your PATH, or build it from source (cargo build --release in a clone; see the repository's README). No administrator rights are needed on either system.

Run it

ductus init hospital      # a new project, with one interface to start from
cd hospital
ductus dev

The first run downloads Postgres 18.6 once for your user (about 30 MB, from Maven Central), then prints where everything is:

Postgres 18.6.0 is running for this project:
  postgresql://postgres:…@127.0.0.1:42731/ductus
  (data in ./.ductus/postgres/data; stops with the engine)
Web UI login: user `dev`, password `…` (kept in ./.ductus/dev-login)
… INFO ductus_runtime::api: REST API listening (/api/v1) host=127.0.0.1 port=7700 https=false
… INFO ductus_runtime: started interfaces=1 mllp=1 http=0 files=0 schedules=0 …
  • The web UI is at http://127.0.0.1:7700, with the dev login it printed.
  • The database URL works with any SQL tool (psql, DBeaver, DataGrip) while the engine runs, and with every command that needs a database: export DUCTUS_DATABASE_URL=… (PowerShell: $env:DUCTUS_DATABASE_URL = "…"), then ductus status, ductus user create, ductus token create and the rest talk to it.
  • Ctrl-C stops the engine cleanly, then its Postgres.
  • The data stays in .ductus/postgres between runs, and so does its port. ductus init's .gitignore leaves .ductus/ out of Git.
OptionWhat it does
--resetThrows this project's dev database away and starts with an empty one (asks first; --yes doesn't).
--database-onlyStarts only the Postgres and prints its URL: for tests, or to run ductus run yourself against it.
--port NPostgres's port, instead of the one kept from the last run (or a free one the first time).
--env NAMEThe environment's variables and secrets, as with run (environments).
--api-listen HOST:PORTWhere the web UI and API listen (off for neither).

All of them are in the reference.

Without internet access

The download is the only thing ductus dev fetches, and it keeps it in a per-user folder: ~/.cache/ductus/postgres on Linux ($XDG_CACHE_HOME when set), %LOCALAPPDATA%\ductus\postgres on Windows. Where the download is blocked, copy that folder to the same place from a machine of the same system that has run ductus dev once; or unpack Postgres's binaries yourself into a folder holding bin/, and point DUCTUS_POSTGRES_DIR at it. Or skip dev and run ductus run against any Postgres you have, with DUCTUS_DATABASE_URL.

What's different from a server

  • One engine per project folder at a time: a second ductus dev in the same folder says it's already running.
  • No handover: systemctl reload is the packages'. After a change to the project, stop and start ductus dev again (it starts in well under a second).
  • DUCTUS_DATABASE_URL is ignored: dev always uses its own Postgres.

PostgreSQL

Ductus keeps everything in PostgreSQL: every message as received, each destination's delivery and its status, the queue, the audit log, accounts, alerts. There is no other store and no message broker. This page is what to ask your DBA for, and why; it's written so you can hand it to them.

What the engine does in its database, by itself: creates its tables at its first start, and applies a new version's changes at that version's first start (they only ever add: the version before keeps working on them); makes each day's partitions ahead of time, hourly; drops whole partitions once their messages are past retention (no row-by-row deletes, so no bloat from them); holds one session for the whole time it runs, with an advisory lock that says which engine owns which interfaces. It needs no superuser, no extension, and no cron job or scheduled task.

What it doesn't do: back the database up, replicate it, or tune it. Those are the DBA's, as for any database that matters (backups).

Version

PostgreSQL 18 is what Ductus is tested on, on every push, and what ductus dev runs. Any version from 14 on should work: the oldest feature the engine relies on is DETACH PARTITION … CONCURRENTLY (new in 14), which lets it drop old messages without blocking anyone. 14–17 aren't tested yet. For a new install, use 18.

Managed services work the same: Azure Database for PostgreSQL (flexible server), Amazon RDS and Aurora PostgreSQL, Google Cloud SQL. They require TLS: see TLS.

The login and its database

Give Ductus a database of its own, owned by its login:

CREATE ROLE ductus LOGIN PASSWORD '…';
CREATE DATABASE ductus OWNER ductus;

(Or, on the database server, sudo -u postgres createuser --pwprompt ductus and sudo -u postgres createdb --owner ductus ductus.)

  • Why the owner: the engine creates and changes its own tables, functions and partitions: at each new version's first start, and every hour for partitions. Owning the database (and so its public schema) is the one grant that covers all of it, now and in later versions.

  • Why its own database: its tables are named for it (messages, deliveries, users…) and live in public; sharing a database with an application invites a clash. Several engines share one database without trouble: each runs its own interfaces, and an engine started with interfaces another runs is refused (nuance).

  • No superuser, no CREATEDB, no CREATEROLE, no extensions.

  • A read-only login for checks and audits: ductus upgrade --check and ductus replay only read, so give them one that can't write (Postgres 14+):

    CREATE ROLE ductus_check LOGIN PASSWORD '…';
    GRANT pg_read_all_data TO ductus_check;
    

Then the engine's URL, in DUCTUS_DATABASE_URL:

postgres://ductus:PASSWORD@db1.hospital.org:5432/ductus?sslmode=verify-full

A password with @, :, /, ? or # in it must be percent-encoded in the URL (@ is %40). The URL means what it means to psql and every libpq tool, so a URL that works there works here.

Postgres on the engine's own server can skip the password and TLS: connect over its Unix socket, postgres:///ductus?host=/var/run/postgresql, and Postgres's peer login maps the ductus service user to the ductus role (local ductus ductus peer in pg_hba.conf, often there already as local all all peer). It's also the fastest connection.

Connections

The engine opens up to 32 connections to its database (DUCTUS_DB_CONNECTIONS), as it needs them, plus one it holds for its lock. Budget for:

  • each engine on the database: 33;
  • twice that for a few seconds during a Linux upgrade or deploy: the new engine starts beside the running one, with its own pool, before the old one leaves (handover);
  • a few more for the command line (ductus status, doctor, user) and the DBA's own tools.

The pool is for the engine's own database only. The databases interfaces query (db(), in resources.yaml) have pools of their own, set per entry (max_connections).

PgBouncer and other poolers: session mode only. In transaction mode the engine's lock is lost at once, since each transaction lands on another server connection (nuance). The engine pools its own connections, so a pooler in front of it saves little.

TLS to the database

Without sslmode in the URL, the connection is libpq's prefer: TLS when the server offers it, and the server's certificate is never checked. That stops someone listening, not someone answering in the database's place. For a database on another machine, ask for its certificate to be checked (nuance):

sslmodeTLSCheckedUse for
disablenoA database on this machine over TCP, when nothing else is possible (prefer the Unix socket).
prefer (the default)if offerednothingNothing you'd choose: it's the default only so a URL that works in psql works here.
requireyesnothingA network you trust, against listening only.
verify-cayesa trusted CA issued itRarely: the name isn't checked.
verify-fullyesa trusted CA issued it, for this host nameEvery database on another machine.

The CA is trusted when the engine's server trusts it (Linux: update-ca-certificates; Windows: the machine's store), or when the URL names it: &sslrootcert=/etc/ductus/tls/db-ca.pem (nuance). The engine's start log, ductus validate, ductus doctor and the web UI's Connections page all say how the connection is protected ("TLS 1.3, verified (sslmode=verify-full)"), and warn when a database on another machine isn't verified. A verified database's certificate is also watched: its expiry alerts at 30, 14 and 7 days like every other certificate the engine uses.

Managed Postgres (nuance):

  • Azure: sslmode=verify-full. Its certificates chain to public roots servers already trust.
  • Amazon RDS / Aurora: sslmode=verify-full&sslrootcert=/etc/ductus/tls/rds-global-bundle.pem, with Amazon's RDS CA bundle downloaded from AWS: its CAs aren't in the system's store.
  • Google Cloud SQL: sslmode=verify-full&sslrootcert=… with the instance's server CA, downloaded from the console. Connect by a name its certificate has.

Always connect by the endpoint's DNS name, the one on its certificate, not its address.

A client certificate instead of (or beside) a password, where the DBA logs the engine in by certificate (cert in pg_hba.conf): &sslcert=/etc/ductus/tls/db-client.pem&sslkey=/etc/ductus/tls/db-client.key. A renewed pair is used by the next new connection (nuance).

A verified connection's certificate (verify-full, verify-ca) is watched like any other: alerts at 30, 14 and 7 days (nuance). Opening a connection over TLS costs about 60 ms for now, which pools hide (measured).

Sizing

The disk decides the speed. Every message acknowledged with durability: strict (the default) waits for one durable commit, so a sender that sends one message at a time can't go faster than the database's disk can flush: about 400 a second at 2.4 ms a flush. Commits from many senders at once are grouped, so the engine as a whole goes much faster. ductus doctor measures the commit time from the engine's server and says what it means (nuance). Put Postgres's write-ahead log on the fastest disk you have (NVMe, or a cloud disk with low write latency). For scale: a million HL7 messages a day averages 12 a second.

Space is roughly: messages a day × their average size × the days kept (retention: 7, 21 or 49 days; retention), plus a small row per destination per message (its delivery and status), the indexes, what destinations keep with save_output (about 1.15 GB per million deliveries, measured), and anything waiting or quarantined past its window (kept until it's resolved: the held area). Measure it on your own traffic after a week: SELECT pg_size_pretty(pg_database_size('ductus')). Space comes back as whole partitions are dropped, a day or a week at a time: there's nothing to vacuum away.

Long queries against the message tables (a report run on the store) can make the engine's hourly partition work wait; keep them short, or run them on a replica (nuance). Autovacuum's defaults are fine.

Backups are the DBA's

Back the database up as you back up any database that holds records you must not lose: with continuous WAL archiving and base backups (pgBackRest, Barman, or the managed service's own point-in-time restore). The engine doesn't take backups and needs nothing from them; nothing in it is scheduled outside the database.

What a backup means for messages:

  • A restore to an earlier point loses what was acknowledged after it. Senders were told those messages arrived, so they won't send them again: get them resent from the source systems for the gap. The same holds for a failover to a replica that was behind (asynchronous replication): if no message may be lost, the DBA's answer is synchronous replication to the standby.
  • The engine reconnects by itself after a restart or failover of the database. While it's cut off, nothing can be stored, so nothing is acknowledged and senders try again; when it can't hold its lock, it pauses sending until it can (nuance).
  • Messages are kept 1, 3 or 7 weeks by design, not forever: longer history belongs in the source systems or an archive, not in backups of this database.

Ports and firewalls

What to open for Ductus, in each direction, and how to get the exact list for a firewall request. The short answer: in, one port for the web UI and API, and each interface's own listener; out, the database and whatever your interfaces connect to. Nothing else: no thick client, no extra ports for the web UI, no cluster port.

What connects to the engine (in)

PortWhatWho connectsOpen it to
7700 (api.listen)The REST API and the web UI, one port for bothPeople's browsers, the ductus command line from other machines, monitoring (Prometheus), scriptsDevelopers' and operators' networks, the monitoring server. Listens on 127.0.0.1 (this machine only) until you change it.
Each interface's portIts MLLP or HTTP listener, from its interface.yamlThe systems that send to it: the EHR, a lab system, another engineThe sending systems' addresses only.

The API and web UI listen on this machine only by default (api.listen: 127.0.0.1:7700), so opening them to the network is a choice you make. When you do, give them a certificate and an allow list, in ductus.yaml:

# ductus.yaml, in part
api:
  listen: 0.0.0.0:7700
  tls: { certificate: ductus-api }          # a certificates: entry in resources.yaml
  allow: [127.0.0.1, 10.1.50.0/24]          # this machine, and the operators' subnet

Without tls, passwords and tokens cross the network readable and the web UI's sign-in doesn't work at all (its cookie is only sent over HTTPS: nuance). Keep 127.0.0.1 in the list, or commands run on the server itself are refused (nuance). Developers need this port and whatever they deploy with (SSH, Git), nothing more.

Interfaces' listeners: each MLLP source has a port of its own; HTTP sources can share one, by path. ductus new interface picks a free one from the ranges in ductus.yaml (ports: MLLP 6661–6699, HTTP 8080–8099 by default). Ask the firewall team for a range once, set ports: to it, and new interfaces land inside it with no new request:

# ductus.yaml, in part
ports:
  mllp: 7000-7099     # the range the firewall team opened
  http: 9080-9099

The engine checks who may connect itself too: allow: on a source lists the senders' addresses, subnets or host names, and anyone else is disconnected before a byte is read (who may connect). Keep the firewall rules as well: each catches the other's mistakes (nuance).

A port below 1024 (MLLP on 104, say) needs a capability under the Linux package's service; the unit file says which two lines to add with systemctl edit ductus. Above 1024 needs nothing.

What the engine connects to (out)

ToPortWhen
Its PostgreSQL5432 (or the URL's)Always.
Receivers of MLLP destinationsas configuredFor each destination: { mllp: … }.
HTTP destinations, APIs (api(), fetch()), webhooks, the heartbeat443 (or the URL's)For each one configured.
Databases interfaces query (db())Postgres 5432; SQL Server 1433, and UDP 1434 for a named instance (the SQL Server Browser)For each databases: entry.
SFTP servers22 (or the entry's)For each sftp: entry.
The directory (Active Directory, OpenLDAP)636 (ldaps), or 389 with StartTLSWith auth.ldap.
The mail server587 (STARTTLS), 465 (TLS) or 25With alerts.smtp.
DNS53To look up every host name above.
Maven Central (repo1.maven.org, 443)Only ductus dev, once per user, on developers' machines. Never on a server.

The engine never connects anywhere to "phone home": no update checks, no telemetry.

The exact list for a firewall request

ductus ports prints every port a project listens on and every host it connects to, for the environment you name, with conflicts (two interfaces on one port) flagged:

For production, give it --env prod. Here, the project from Your first interface, with TLS:

$ ductus ports
environment: dev

Listening
  PORT  PROTOCOL       HOST       INTERFACE          ROUTE    TLS                                                            ALLOWED
  6661  MLLP over TLS  0.0.0.0    adt-in                      presents ductus-mllp, clients need a certificate from test-ca  anyone
  7700  HTTP           127.0.0.1  (engine REST API)  /api/v1  none                                                           anyone

Connects to
  (nothing)

✓ no port conflicts

ductus doctor, run on the engine's server, then checks each outbound connection from there: DNS, TCP (refused, or no answer at all, which is a firewall), TLS, and each system's login. For each one it prints the source address the connection left from: that is the address a firewall request must name, which on a server with several networks isn't always the one you'd guess. --report doctor.txt saves it as text for the network team or a vendor (doctor.json as JSON) (what a pass means).

Opening a range on the server itself

# RHEL, Rocky, Alma (firewalld)
sudo firewall-cmd --permanent --add-port=6661-6699/tcp && sudo firewall-cmd --reload
# Ubuntu (ufw)
sudo ufw allow from 10.20.30.0/24 to any port 6661:6699 proto tcp
# Windows
New-NetFirewallRule -DisplayName "Ductus MLLP" -Direction Inbound -Protocol TCP -LocalPort 6661-6699 -RemoteAddress 10.20.30.0/24 -Action Allow

Your first interface

From nothing to HL7 messages flowing over MLLP, then the same over TLS with client certificates, on your own machine, in about fifteen minutes. Every command here is real: run them as you read. You need the ductus program on your PATH (how) and, for the TLS part, openssl (it plays the hospital's certificate authority).

1. A new project

ductus init hospital
cd hospital
git init        # a project is a Git repository: what runs is what's committed

ductus init makes a project that passes its tests and validates as it is:

hospital/
├── ductus.yaml              the project's settings (all optional; the defaults written out)
├── resources.yaml           databases, servers and APIs interfaces share (none yet)
├── environments/
│   ├── dev.yaml             values for `--env dev` (the default)
│   └── prod.yaml            the same names, production's values
├── interfaces/
│   └── adt-in/              one interface: HL7 over MLLP on port 6661, written to files
│       ├── interface.yaml   where messages come from and go
│       ├── transform.ts     what happens to each one
│       ├── transform.test.ts
│       └── fixtures/sample.hl7
├── .ductus/, tsconfig.json, .vscode/settings.json    for your editor (below)
├── .gitignore, README.md

Open the folder in VS Code. With Red Hat's YAML extension, every setting in the YAML files completes as you type and shows its description on hover; in transform.ts, hovering msg.get("PID-5") shows PID-5 Patient Name. The schemas and typings in .ductus/ come from the ductus you have installed, so they match it (editor support).

2. The interface

interfaces/adt-in/interface.yaml, as generated (its comments show what else you could write):

# interfaces/adt-in/interface.yaml
name: adt-in
about:
  description: ""
  owner: ""
  from: { application: "", vendor: "", hosts: [] }   # hosts: where senders connect from (IPs or subnets)
  to: { application: "", vendor: "" }
source: { mllp: { port: 6661 } }
script: transform.ts
destination: { file: { dir: out/adt-in } }
  • source: listen for HL7 over MLLP on port 6661. Each message is stored before its ACK goes back to the sender.
  • script: run transform.ts on each message.
  • destination: write each result as a file. The folder is relative to the interface's own folder, so the files land in interfaces/adt-in/out/adt-in/.
  • about: what the interface is for, who owns it, and the systems on each side. Nothing here changes how messages flow; it's what ductus impact and the web UI read.

The code, transform.ts, gets each message and returns what to send:

import type { Message } from "@ductus/sdk";

export function transform(msg: Message) {
  // Example: set the receiving application.
  msg.set("MSH-5", "RECEIVER");
  return msg;
}

Returning null would drop the message (filtered, with the reason kept); throwing would quarantine it for a person to look at (nuance).

3. Test and validate

$ ductus test
adt-in
  ✓ sets the receiving application and keeps the patient (0ms)

1 passed, 0 failed

The tests run in the same embedded JavaScript engine the interfaces run in, with no database or network. Then check the whole project as the engine would read it:

$ ductus validate
environment: dev
✓ adt-in: config and code load
⚠ adt-in: about.from: add the sending system's `application` or `vendor`
⚠ adt-in: about.to: add the receiving system's `application` or `vendor`
  (warnings: `ductus impact` can't find what isn't named; `require_about: true` in ductus.yaml makes them errors)
note: not checked against a database (set DUCTUS_DATABASE_URL): lanes that would strand waiting messages, retention changes
✓ types: no errors

valid

The warnings are about about:, which is still empty. Fill it in: the day a vendor has an outage or a breach, ductus impact --vendor "Epic Systems" lists every interface it touches, but only the ones that say so.

# interfaces/adt-in/interface.yaml
name: adt-in
about:
  description: ADT from the EHR, kept as files for the lab's archive
  owner: interfaces@hospital.org
  from: { application: Epic, vendor: Epic Systems, hosts: [10.20.30.0/24] }
  to: { application: Lab archive, vendor: In-house }
source: { mllp: { port: 6661 } }
script: transform.ts
destination: { file: { dir: out/adt-in } }

ductus validate now has no warnings.

4. Run it

$ ductus dev
Postgres 18.6.0 is running for this project:
  postgresql://postgres:…@127.0.0.1:42731/ductus
  (data in ./.ductus/postgres/data; stops with the engine)
Web UI login: user `dev`, password `…` (kept in ./.ductus/dev-login)
… INFO ductus_runtime::api: REST API listening (/api/v1) host=127.0.0.1 port=7700 https=false
… INFO ductus_runtime: started interfaces=1 mllp=1 http=0 files=0 schedules=0 internal=0 …

ductus dev is the engine with a Postgres of its own (downloaded once, the first time: more). In a second terminal, send it ten synthetic ADT messages, as an EHR would:

$ ductus gen --count 10
sent 10 messages over 1 connection(s) in 0.0s (297/s), retries 0, rejected 0, refused 0, dropped 0, longest wait 23 ms

Each one was stored before its ACK went back ("longest wait" is the slowest ACK). Look at what arrived:

ls interfaces/adt-in/out/adt-in/       # 1.hl7 … 10.hl7, named by message id

and open the web UI at http://127.0.0.1:7700, signing in with the dev login ductus dev printed. The interface's Messages tab lists the ten, and each message shows its HL7 fields by name, what was received, and what happened at each destination.

5. Change it

Make the code do something of its own: say the lab wants patient names in capitals. In transform.ts:

import type { Message } from "@ductus/sdk";

export function transform(msg: Message) {
  msg.set("MSH-5", "LAB");
  msg.set("PID-5.1", msg.get("PID-5.1").toUpperCase());   // PID-5.1: family name
  return msg;
}

and say so in its test, transform.test.ts:

import { expect, input, type Message, test } from "@ductus/sdk";
import { transform } from "./transform.ts";

test("addresses the lab and capitalizes the family name", () => {
  const out = transform(input<Message>("sample.hl7"));
  expect(out.get("MSH-5")).toBe("LAB");
  expect(out.get("PID-5.1")).toBe("SMITH");
  expect(out.get("PID-3.1")).toBe("123456");
});

ductus test, then stop ductus dev (Ctrl-C) and start it again: it starts in well under a second, and new messages get the new code. HL7 is numbered as the standard numbers it, from 1, and a field that isn't there reads as "", never an error (nuance).

6. The same over TLS, with client certificates

MLLP crossing a network you don't own should be encrypted, and the listener should know which systems may send. That's two settings: a certificate the listener presents, and the CA whose certificates it takes from senders.

A certificate authority for the lab. In real life the hospital's PKI team signs your certificate. Here, openssl plays that part (keep its folder out of Git: ca.key signs anything, and the server will trust the hospital's CA, not this one):

mkdir -p lab-ca tls
echo "lab-ca/" >>.gitignore
openssl req -x509 -newkey rsa:2048 -nodes -keyout lab-ca/ca.key -out lab-ca/ca.pem -days 365 -subj "/CN=Test Hospital CA"

The listener's certificate. ductus cert request makes a private key that never leaves this machine, and a request (CSR) to send to the CA. It says what to send and what comes back:

$ ductus cert request tls/mllp --dns localhost --ip 127.0.0.1
wrote tls/mllp.key (the new RSA 2048 private key: it stays on this server; only you can read it) and tls/mllp.csr (the request)

Send tls/mllp.csr to the hospital's CA (its PKI or security team). It asks for a certificate for localhost, 127.0.0.1, for a TLS server and client (one certificate can serve listeners and be the engine's client certificate). Never send tls/mllp.key.
…

Sign it as the CA would, and do the same for a sender (in real life, the sending system's team asks their CA, or yours, for theirs):

openssl x509 -req -in tls/mllp.csr -CA lab-ca/ca.pem -CAkey lab-ca/ca.key -CAcreateserial -out tls/mllp.pem -days 90 -copy_extensions copy
ductus cert request tls/sender --dns sender.test
openssl x509 -req -in tls/sender.csr -CA lab-ca/ca.pem -CAkey lab-ca/ca.key -CAcreateserial -out tls/sender.pem -days 90 -copy_extensions copy

Name them in resources.yaml, so interfaces refer to them by name:

# resources.yaml
certificates:
  ductus-mllp: { cert_file: tls/mllp.pem, key_file: tls/mllp.key }
  test-ca: { ca_file: lab-ca/ca.pem }

certificates holds both kinds: a pair the engine presents (ductus-mllp), and a CA it trusts or checks clients against (test-ca). Ask ductus cert show what the engine will make of one, before a sender finds out:

$ ductus cert show ductus-mllp
✓ certificate `ductus-mllp` (resources.yaml): ./tls/mllp.pem
  subject  CN=localhost
  names    localhost, 127.0.0.1
  issuer   issued by Test Hospital CA
  valid    2026-10-31 → 2027-01-29: 89 days left
  chain    complete: localhost, issued by Test Hospital CA (the CA `test-ca` in resources.yaml)
  key      matches (./tls/mllp.key)
→ Senders must trust Test Hospital CA (the hospital's CA): ask them to confirm.

(Shown the file alone, ductus cert show tls/mllp.pem warns that the chain is incomplete: it can't know Test Hospital CA is a root until resources.yaml names it. With a real CA, append its intermediate certificates to mllp.pem after yours: why.)

Turn TLS on for the listener, and ask senders for a certificate from test-ca naming sender.test:

# interfaces/adt-in/interface.yaml
name: adt-in
about:
  description: ADT from the EHR, kept as files for the lab's archive
  owner: interfaces@hospital.org
  from: { application: Epic, vendor: Epic Systems, hosts: [10.20.30.0/24] }
  to: { application: Lab archive, vendor: In-house }
source:
  mllp:
    port: 6661
    tls:
      certificate: ductus-mllp
      clients: { ca: test-ca, subjects: ["CN=sender.test"] }
script: transform.ts
destination: { file: { dir: out/adt-in } }

ductus validate checks the certificates as the engine's start will (a certificate it can't use stops the start, with the reason), and ductus ports shows the port is TLS now:

$ ductus ports
…
  PORT  PROTOCOL       HOST       INTERFACE          ROUTE    TLS                                                            ALLOWED
  6661  MLLP over TLS  0.0.0.0    adt-in                      presents ductus-mllp, clients need a certificate from test-ca  anyone

Send. Start ductus dev again, then send as a sender with its certificate, trusting the lab CA for the engine's:

$ ductus gen --count 10 --to localhost:6661 --ca lab-ca/ca.pem --cert tls/sender.pem --key tls/sender.key
sent 10 messages over 1 connection(s) in 0.1s (79/s), retries 0, rejected 0, refused 0, dropped 0, longest wait 110 ms

And see what other senders get. One without TLS (plain ductus gen --count 1) sees its connection closed with no ACK, and keeps retrying as a real sender would (Ctrl-C to stop it):

sending failed, retrying: connection closed before ACK

while the engine's log says why, once a minute per sender address (nuance):

WARN ductus_runtime::tls: TLS handshake refused (logged once a minute per sender) listener="interface `adt-in` (MLLP)" peer=127.0.0.1 reason=the sender isn't speaking TLS (a plain MLLP or HTTP sender on a TLS port?)

One with TLS but no certificate of its own (--ca lab-ca/ca.pem without --cert) is told TLS: the receiver wants a client certificate (name one in --cert).

The certificate is watched from now on: the engine alerts 30, 14 and 7 days before it expires, and a renewal is two files replaced, with no restart (renewing).

7. To a server

What you built is a folder of files: commit it, and the server runs what's committed.

git add -A && git commit -m "adt-in: ADT from Epic to the lab archive"

.gitignore already leaves out what mustn't be in Git (.ductus/, *.key). For the server:

  1. Install the engine there, and give it a database.
  2. Clone the project as the service's user, and set DUCTUS_PROJECT and DUCTUS_ENV=prod.
  3. Certificates on the server: run ductus cert request there, so the key never leaves it, with the name senders connect to (--dns mllp.hospital.org), and send the CSR to the hospital's CA. Paths that differ per server belong in the environment (environments), and the CA is the hospital's, not lab-ca.
  4. ductus doctor on the server, then start the service.

The Configure pages go through every file in turn.

On a new server, start to finish

What: the steps above on a fresh Ubuntu 24.04 server, with the Linux package, a database on another machine (db1.hospital.org), MLLP over TLS with client certificates, and a sending system that has its own certificate. Every command and output below is from the v0.6 demo (scripts/demo-v06.sh), which runs them under real systemd on every push to the repository; only what's marked … is left out.

Why: a laptop hides what a server adds: a service account that owns the files, a database elsewhere, a CA that isn't yours, and senders that must be told what to trust. Seeing it once end to end shows where each of those comes in.

When not: this builds the project on the server, to show every step in one place. In real life the project comes from Git (step 2 above), and only the certificate's key is made on the server.

Install, then tell the engine where its database is. The package doesn't start the engine:

$ cd /tmp
$ sudo apt install ./ductus_0.6.0~dev.1_amd64.deb
…
Unpacking ductus (0.6.0~dev.1) ...
Setting up ductus (0.6.0~dev.1) ...

Ductus is installed. It isn't running yet: first tell it where its database and project are.
  1. Edit /etc/ductus/ductus.env (the database URL, the project folder, the environment).
  2. Put the project in /var/lib/ductus/project (git clone), readable by the ductus user.
  3. sudo systemctl enable --now ductus
  4. journalctl -u ductus -f      (it says "started" once every interface is up)

(0.6.0~dev.1 is the demo's development package; a release is ductus_0.6.0_amd64.deb.) In /etc/ductus/ductus.env the demo sets one line, the database; DUCTUS_ENV=prod and DUCTUS_PROJECT=/var/lib/ductus/project are already the package's defaults (the file):

# /etc/ductus/ductus.env, in part
DUCTUS_DATABASE_URL=postgres://ductus:…@db1.hospital.org/ductus

The project, made as the ductus user so the service owns it (on a real server: sudo -u ductus git clone … instead), and the listener's certificate, its key made on this server:

$ cd /var/lib/ductus
$ sudo -u ductus ductus init project
created interfaces/adt-in/ (MLLP on port 6661)
…
$ cd /var/lib/ductus/project
$ sudo -u ductus ductus cert request tls/mllp --dns mllp.hospital.org
wrote tls/mllp.key (the new RSA 2048 private key: it stays on this server; only you can read it) and tls/mllp.csr (the request)

Send tls/mllp.csr to the hospital's CA (its PKI or security team). It asks for a certificate for mllp.hospital.org, for a TLS server and client (one certificate can serve listeners and be the engine's client certificate). Never send tls/mllp.key.
…

The hospital's PKI team signs the request and sends back the certificate and their CA's own. In the demo openssl plays the CA, on the same machine:

$ openssl x509 -req -in tls/mllp.csr -CA /root/hospital-ca/ca.pem -CAkey /root/hospital-ca/ca.key -CAcreateserial -out tls/mllp.pem -days 90 -copy_extensions copy
Certificate request self-signature ok
subject=CN = mllp.hospital.org
$ cp /root/hospital-ca/ca.pem tls/hospital-ca.pem

The certificates by name, and the interface sending to the lab system's receiver instead of files (a service writes only where its unit lets it):

# resources.yaml
certificates:
  ductus-mllp: { cert_file: tls/mllp.pem, key_file: tls/mllp.key }
  hospital-ca: { ca_file: tls/hospital-ca.pem }
# interfaces/adt-in/interface.yaml
name: adt-in
about:
  description: ADT from the EHR to the lab system
  owner: interfaces@hospital.org
  from: { application: Epic, vendor: Epic Systems }
  to: { application: Lab system, vendor: In-house }
source:
  mllp:
    port: 6661
    tls:
      certificate: ductus-mllp
      clients: { ca: hospital-ca, subjects: ["CN=epic.hospital.org"] }
script: transform.ts
destination: { mllp: { host: 127.0.0.1, port: 6662 } }

Check before starting, with the service's own settings (--env-file reads ductus.env as the service does), then start it and let ductus doctor check every connection from the server:

$ sudo -u ductus ductus cert show ductus-mllp
✓ certificate `ductus-mllp` (resources.yaml): ./tls/mllp.pem
  subject  CN=mllp.hospital.org
  names    mllp.hospital.org
  issuer   issued by Test Hospital CA
  valid    2026-10-31 → 2027-01-29: 89 days left
  chain    complete: mllp.hospital.org, issued by Test Hospital CA (the CA `hospital-ca` in resources.yaml)
  key      matches (./tls/mllp.key)
→ Senders must trust Test Hospital CA (the hospital's CA): ask them to confirm.
$ sudo -u ductus ductus validate --env-file /etc/ductus/ductus.env
environment: prod
✓ resources: 0 database(s), 0 SFTP server(s), 0 API(s), 2 certificate(s)
✓ adt-in: config and code load
✓ certificate `ductus-mllp`: CN=mllp.hospital.org (mllp.hospital.org), expires 2027-01-29 (89 days)
✓ certificate `hospital-ca`: a CA
⚠ database: plain, no TLS: the server doesn't offer it (sslmode=prefer); whoever answers at its address is believed: add sslmode=verify-full to DUCTUS_DATABASE_URL (sslrootcert=/path/ca.pem for a private CA)
✓ database: new (no engine has run on it yet): no messages wait, no retention is applied
✓ types: no errors

valid
$ sudo systemctl enable --now ductus
Created symlink /etc/systemd/system/multi-user.target.wants/ductus.service → /usr/lib/systemd/system/ductus.service.
$ systemctl show -p StatusText ductus
StatusText=running 1 interface
$ sudo -u ductus ductus doctor --env-file /etc/ductus/ductus.env
ductus doctor · environment prod · 2026-10-31 23:46:41 +00:00
on ductus1 · ductus 0.6.0-dev
checked 3 connection(s) in 0.1 s: 1 ok, 2 warning(s), 0 failed

⚠ engine database  db1.hospital.org:5432 Postgres
    used by  the engine (DUCTUS_DATABASE_URL)
    DNS      db1.hospital.org → 172.18.0.2 (0.9 ms)
    connect  172.18.0.2:5432 from 172.18.0.3 in 0.2 ms
  ⚠ TLS      none: the server doesn't offer TLS (sslmode=prefer), the connection is plain
    login    as `ductus`, database `ductus`: PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2); plain, no TLS: the server doesn't offer it (sslmode=prefer)
    commits  2.2 ms median of 20 (each waits for the disk): strict ingest ≈ 450 msg/s per sender connection, one commit per ACK; more connections share commits. A 1M/day ADT feed averages ~12 msg/s.
  → Queries, their results and the login cross the network readable: ask the DBA to turn on ssl, then set sslmode=verify-full.

✓ MLLP destination  127.0.0.1:6662 MLLP
    used by  adt-in
    address  127.0.0.1 (an address, nothing to look up)
    connect  127.0.0.1:6662 from 127.0.0.1 in 0.3 ms

⚠ listening port  0.0.0.0:6661 MLLP over TLS
    used by  adt-in
  ⚠ port     in use: by a running engine, or another program
    address  127.0.0.1 (an address, nothing to look up)
    connect  127.0.0.1:6661 from 127.0.0.1 in 0.2 ms
    TLS      TLS 1.3, post-quantum key exchange (X25519MLKEM768), serves `ductus-mllp`: certificate for mllp.hospital.org, issued by Test Hospital CA, valid until 2027-01-29 (89 days); covers mllp.hospital.org
    chain    complete: mllp.hospital.org, issued by Test Hospital CA (the CA `hospital-ca` in resources.yaml)
  → If the engine isn't running on this server, another program holds port 6661: find it with `ss -ltnp` (Linux) or `netstat -ano` (Windows).
  → Senders must trust Test Hospital CA (the hospital's CA): ask them to confirm.
  → Doctor can't know the name senders connect with: it must be one of mllp.hospital.org (ask senders which name or address they use).

Doctor passes (nothing failed: it exits 0) with two warnings to read, not ignore. The database connection isn't encrypted: the demo's database server offers no TLS, and a real one should (PostgreSQL); the warning goes once DUCTUS_DATABASE_URL says sslmode=verify-full. Port 6661 is in use: here by the engine itself, which doctor can't tell from another program; run before the start, the same check says whether the port is free.

The sending system gets its own certificate from the same CA (in real life its team asks for it), and sends:

$ ductus cert request /root/epic/sender --dns epic.hospital.org
…
$ openssl x509 -req -in /root/epic/sender.csr -CA /root/hospital-ca/ca.pem -CAkey /root/hospital-ca/ca.key -CAcreateserial -out /root/epic/sender.pem -days 90 -copy_extensions copy
Certificate request self-signature ok
subject=CN = epic.hospital.org
$ ductus gen --to mllp.hospital.org:6661 --ca /root/hospital-ca/ca.pem --cert /root/epic/sender.pem --key /root/epic/sender.key --count 100
sent 100 messages over 1 connection(s) in 0.6s (156/s), retries 0, rejected 0, refused 0, dropped 0, longest wait 139 ms

The lab system's receiver (ductus sink in the demo) got all 100, once each, in order. A sender without a certificate of its own is refused before anything is read, and the engine's log says why, once a minute per sender:

$ timeout 5 ductus gen --to mllp.hospital.org:6661 --ca /root/hospital-ca/ca.pem --count 1
sending failed, retrying: TLS: the receiver wants a client certificate (name one in --cert)
$ journalctl -u ductus -o cat --no-pager | grep "TLS handshake refused" | tail -1
2026-10-31T23:47:03.775177Z  WARN ductus_runtime::tls: TLS handshake refused (logged once a minute per sender) listener="interface `adt-in` (MLLP)" peer=127.0.0.1 reason=the sender presented no client certificate

The demo then upgrades this server and goes back, both under traffic: Upgrade shows it.

The project

Everything an engine runs is in one folder of plain files, the project: its interfaces, their code and tests, and a few files of settings. It lives in Git, so every change is reviewed, recorded and can be undone; the engine runs what's in the folder, and never writes there.

Why files and Git rather than settings in a database behind a GUI: a change is a diff someone reviews before it goes live, the history says who changed what and why, the same project runs in test and production with only its environment's values differing, and rolling back is a git revert. Nothing about an interface exists only inside a running engine.

The layout

hospital/
├── ductus.yaml                    the project's settings: retention, the API, sign-in, alerts
├── resources.yaml                 what interfaces share, by name: databases, servers, APIs, certificates
├── docs-schema.yaml               your company's own documentation fields (optional)
├── environments/
│   ├── dev.yaml                   each environment's values: hosts, ports, paths
│   ├── test.yaml
│   └── prod.yaml
├── secrets/
│   ├── dev.json                   each environment's secrets, encrypted (committed)
│   └── prod.json
├── interfaces/
│   ├── adt-in/
│   │   ├── interface.yaml         one interface: where messages come from and go
│   │   ├── transform.ts           its code (optional)
│   │   ├── transform.test.ts      its tests
│   │   └── fixtures/              sample messages the tests read
│   └── orders-to-lab/
│       └── …
└── lib/                           code several interfaces import (optional; any name)
FileWhat it holdsPage
interfaces/<name>/interface.yamlOne interface: its source, format, destinations, and how messages are delivered, kept and watched.interface.yaml
resources.yamlDatabases, SFTP servers, HTTP APIs, directories and certificates, each defined once by a name that interfaces and code use. No address or credential is ever written in an interface or its code.resources.yaml
ductus.yamlSettings for the whole project and the engine that runs it. Optional: every setting has a default.ductus.yaml
environments/<env>.yamlThe values that differ between environments (${NAME} in the other files).Environments
secrets/<env>.jsonPasswords, tokens and keys, encrypted (${secret:NAME}).Secrets
docs-schema.yamlDocumentation fields your company keeps about interfaces, beyond Ductus's own. Optional.Documentation fields

ductus init makes a new project with all of these but docs-schema.yaml and secrets/, and one interface; ductus new interface <name> --source mllp|http|file|sftp|schedule adds another, with its code, a test and a sample message, on a free port.

How the engine reads it

At start, the engine reads ductus.yaml, then the environment it was started with (DUCTUS_ENV, or --env), fills every ${NAME} and ${secret:NAME} in from it, reads resources.yaml, then every interfaces/*/interface.yaml, and loads each interface's code. Any mistake stops the start with the file and what's wrong: a typo in a setting's name too, since every file refuses keys it doesn't know. The engine doesn't read its configuration again while it runs (only certificate files, every minute, so a renewal needs no restart); a change takes effect at the next start, or with systemctl reload ductus on Linux (deploying).

ductus validate reads it the same way without starting anything, for an environment (--env prod), and also type-checks every interface's code with the TypeScript compiler built into the engine. Run it before every commit, and in CI; with DUCTUS_DATABASE_URL set it also checks the project against the database, reading only. ductus test runs every interface's tests.

In your editor

ductus types (which ductus test and ductus validate also run) writes what VS Code needs into the project:

  • .ductus/types/: the SDK's typings, so transform.ts knows Message, db(), api() and every HL7 field's name, and tsconfig.json pointing at them;
  • .ductus/schema/: a JSON Schema for each configuration file, and .vscode/settings.json mapping them to the files (unless it already maps them elsewhere).

With Red Hat's YAML extension, every setting then completes as you type, shows its description (the same text as the reference) on hover, and is underlined when it's wrong. They come from the ductus you have installed, so they match the engine that will run the project: run ductus types again after an upgrade. .ductus/ isn't committed (the .gitignore from ductus init leaves it out).

Values written once

Two rules keep a project readable as it grows:

  • Anything that differs between environments is a variable (${LAB_HOST}), defined in each environments/<env>.yaml. The interface itself is the same file in test and production.
  • Anything an interface connects to that others might too is a resource, by name: code says db("patient_db"), a destination says api: billing, a certificate is ductus-mllp. When the database moves, one line changes, and ductus impact can say which interfaces it touches.

Environments, variables and secrets

One project runs in every environment (a developer's laptop, test, production) with only its environment's values differing: the hosts and ports it connects to, the folders it uses, and its passwords and tokens. Interfaces never say which environment they're in.

WhereWrittenHolds
Variablesenvironments/<env>.yaml${NAME}Anything that differs and isn't secret: hosts, ports, URLs, folders.
Secretssecrets/<env>.json, encrypted${secret:NAME}Passwords, tokens, keys, webhook URLs.

The engine uses one environment, the one it's started with: DUCTUS_ENV=prod (the packages' settings file sets it), or --env prod on the command line. Without either it's dev.

Variables

Each environment's file holds its variables, by name:

# environments/prod.yaml
variables:
  EPIC_PORT: 6661
  LAB_HOST: lab.hospital.org
  LAB_PORT: 7001
  PATIENT_DB_HOST: patients-db.hospital.org
  BILLING_DB_HOST: billing-sql.hospital.org
  EMPI_URL: https://empi.hospital.org/api/v2
  QUEST_SFTP_HOST: sftp.quest.example.com
  OUTBOUND_DIR: /data/outbound
  TLS_DIR: /etc/ductus/tls
# environments/dev.yaml
variables:
  EPIC_PORT: 6661
  LAB_HOST: 127.0.0.1
  LAB_PORT: 7001
  PATIENT_DB_HOST: localhost
  BILLING_DB_HOST: localhost
  EMPI_URL: http://127.0.0.1:8090/api/v2
  QUEST_SFTP_HOST: 127.0.0.1
  OUTBOUND_DIR: out
  TLS_DIR: tls

Then any value in interface.yaml, resources.yaml, or the alerts: section of ductus.yaml can be a variable:

# interfaces/orders-to-lab/interface.yaml
name: orders-to-lab
source: { mllp: { port: ${EPIC_PORT} } }
destination: { mllp: { host: ${LAB_HOST}, port: ${LAB_PORT} } }
  • A value that is only a reference keeps the variable's type: port: ${LAB_PORT} is the number 7001. Inside text it's text: url: "${EMPI_URL}/patients".
  • Give every environment the same names. ductus validate checks the environment you name, and a variable missing from it stops the start, naming it: run ductus validate --env prod before deploying to production.
  • $${ writes a literal ${, for the rare value that needs one.
  • Not in the rest of ductus.yaml: only its alerts: section is read per environment (mail servers and webhooks differ; retention and sign-in rules shouldn't).
  • Not in code: an interface's TypeScript reads the environment's values with env("NAME") and its secrets with secret("NAME"), from the same files.

When not to use a variable: for what is the same everywhere (an interface's port, when every environment uses the same one, is clearer written out), and never for secrets: environment files are plain text in Git.

A number in quotes is text. LAB_PORT: "6670" makes port: ${LAB_PORT} the text "6670", which the interface refuses ("invalid type: string"); write LAB_PORT: 6670 (nuance).

Secrets

Secrets are encrypted, one value at a time, in secrets/<env>.json, and committed to Git encrypted: the repository has them, readable only with the environment's key, which never is. That way a secret changes with a commit and a review like everything else, and a cloned project has all it needs except the key.

Make a key for each environment, once:

ductus secret init          # prints a new random key

Keep it where your other production secrets live (a vault, a password manager), and give it to the engine as DUCTUS_SECRET_KEY: in /etc/ductus/ductus.env on Linux, C:\ProgramData\Ductus\ductus.env on Windows. Use a different key for each environment, so a developer with the test key can't read production's secrets.

Set a secret (the value is read from the terminal, so it stays out of your shell's history):

export DUCTUS_SECRET_KEY=…              # the prod key
ductus secret set PATIENT_DB_PASSWORD --env prod
ductus secret list --env prod           # names only, never values

and use it anywhere a variable can go:

# resources.yaml
databases:
  patient_db:
    postgres:
      host: ${PATIENT_DB_HOST}
      database: patients
      user: ductus
      password: ${secret:PATIENT_DB_PASSWORD}
      tls: verify-full
  • What belongs in a secret: database and SFTP passwords, SFTP private keys, API tokens (Authorization: "Bearer ${secret:EMPI_TOKEN}"), the directory's service password, the mail server's password, chat webhook URLs (a webhook's URL is its own password), the heartbeat's URL, certificates and keys kept in a vault rather than in files.
  • Where values show: ductus secret list gives names only, and the engine shows a webhook's URL by its scheme and host only. Anyone who can run ductus on the server with the key can read the values, so the key is what to protect.
  • Without the key, a project that uses a secret can't be read for that environment: the engine's start and ductus validate stop and say which secret is missing.
  • Rotating a secret: ductus secret set the new value, commit, deploy (systemctl reload ductus on Linux). Rotating the key means setting every secret again with the new key.

Choosing environment names

dev (the default), test and prod cover most teams; any name works (staging, dr-site). Each name needs its environments/<name>.yaml (the engine refuses an environment without one, once the folder exists) and, if it uses secrets, its secrets/<name>.json and key. A disaster-recovery site that connects to different hosts is its own environment.

ductus.yaml: the project's settings

ductus.yaml sits at the top of the project and holds what applies to every interface in it and to the engine that runs them: how long messages are kept, where the web UI listens, how people sign in, the audit log, alerts, and the engine's own checks. It's optional: every setting has a default, and ductus init writes the main ones out so you can see them.

# ductus.yaml
ports:
  mllp: 6661-6699
  http: 8080-8099
retention: 7w
quarantined: keep
api:
  listen: 127.0.0.1:7700
isolates:
  heap_mb: 512
require_about: false

That is ductus init's file, and also exactly the defaults. Every setting is in the reference; this page says what each section is for and when to change it.

Only the alerts: section may use ${NAME} and ${secret:NAME} (environments): mail servers and webhooks differ per environment, but how long messages are kept and who may sign in shouldn't.

Ports for new interfaces

ports is where ductus new interface picks a new interface's port from: the first free one in the range. It doesn't limit anything: each interface's port is written in its own interface.yaml, and the engine listens on whatever that says. Set it to the ranges your firewall team opened, so new interfaces land inside them with no new request (ports and firewalls). Default: MLLP 6661-6699, HTTP 8080-8099.

How long messages are kept

retention is how long every interface keeps its messages, unless its interface.yaml says otherwise: 1w, 3w or 7w (the default), or the same in days (7d, 21d, 49d).

# ductus.yaml, in part
retention: 3w
quarantined: keep
  • Why only three: each window is a set of partitions of the message tables, and old messages go by dropping a whole partition at once, which costs nothing however busy the feed. Deleting rows one at a time, as engines with any number of days must, slows the busiest tables and bloats them (nuance).
  • Kept at least the window, and gone within a day (1w) or a week (3w, 7w) after it.
  • Nothing waiting is ever deleted. A message still waiting for a destination past the window moves to the held area and is sent when it can be (nuance).
  • Choose the window that covers how far back your team looks into problems: 1w for busy feeds nobody looks back on, 7w where problems surface weeks later (billing, a monthly reconciliation). Longer history belongs in the source system or an archive, not here.
  • Shortening it later deletes messages, so the engine waits for ductus retention confirm <interface> before it applies; a longer window applies at once (nuance).

quarantined says what happens to quarantined messages (ones a destination refused for good, or the code rejected) once the window has passed: keep (the default) holds them in the held area until a person resends, corrects or dismisses them; purge lets them go with the rest. Keep is the safe choice: a quarantined message is a question nobody has answered yet. purge suits a feed whose sender resends anyway. Watch what's held with ductus retention and the ductus_held_messages metric.

The web UI and the API

api is where the REST API and the web UI listen (one port for both), whether over HTTPS, and who may connect. By default only this machine can reach them: 127.0.0.1:7700, plain HTTP.

# ductus.yaml, in part
api:
  listen: 0.0.0.0:7700
  tls: { certificate: ductus-api }
  allow: [127.0.0.1, 10.1.50.0/24]
  • listen: host:port, or off to run without the API and the web UI (then the command line's status --token, drain and pause can't reach this engine either). Listen on the server's address, or 0.0.0.0, for people to use the web UI from their own computers. DUCTUS_API_LISTEN (or --api-listen) overrides it for one engine.
  • tls: the certificate it presents, a certificates: entry in resources.yaml. Needed as soon as listen isn't this machine only: the web UI's sign-in cookie is only sent over HTTPS, and tokens would otherwise cross the network readable. The start and ductus validate warn without it (TLS for the web UI).
  • allow: the addresses, subnets and host names that may connect (operators', developers', the monitoring server). List 127.0.0.1 too, or commands on the server itself are refused.

Every call needs a token or a signed-in person either way: the network settings decide who can reach the port, not who may do what (people).

By default the API and the console listen on this machine only. Anyone else reaches them once you set api.listen to an address on the network, and then they need tls (nuance).

Signing in

auth sets how people sign in to the web UI and the API: how long a session lasts (12 hours idle, 7 days at most), the shortest password (12 characters), how many wrong passwords lock a username (5 in 15 minutes, for 15 minutes), whether people may make their own API tokens, and a directory (Active Directory, OpenLDAP) to sign in with. The defaults suit most sites; People: users, tokens, the directory explains each, and when to change it.

The audit log

audit is how long the record of who did what is kept (sign-ins, resends, edits, pauses, account changes, payload reads) and whether reading a message's payload is recorded.

# ductus.yaml, in part
audit:
  keep: 6y              # the default: HIPAA's usual documentation period
  payload_reads: true   # the default: who looked at patient data
  • keep: at least a year, in years (6y) or any duration. Six years is the usual HIPAA documentation period; ask your compliance office for yours. Older rows are deleted hourly.
  • payload_reads: with patient data, who looked matters as much as who changed. Turn it off only where messages hold no personal data. Reading needs the payloads permission either way (nuance).

The audit log and the messages it names are kept for different times: the log for audit.keep (six years), a message for its interface's retention, so an audit row can outlive the payload it mentions (nuance). An engine that crashed shows in the log as a start whose previous run never stopped (nuance).

Alerts and owners

alerts says where notifications go (a mail server, channels: email lists and chat webhooks), which health states alert and how badly, and when to remind and to stay quiet. owners gives the address of owners that aren't accounts (a team's mailing list, by the name interfaces give). Nothing alerts until you write rules: Alerts and owners is the whole story.

The engine's own connection checks

doctor is what the engine checks by itself, as ductus doctor would: every six hours, only as far as each server's certificate (DNS, TCP, the TLS handshake; nothing logs in). That's what makes certificates alert 30, 14 and 7 days before they expire, including the ones of servers the engine connects to.

# ductus.yaml, in part
doctor:
  schedule: 6h          # the default; `off` to turn it off
  mode: certificates    # the default; `full` also logs in
  • mode: full also logs in to each database, SFTP server and the directory, so a changed password is noticed before a message needs it. Use it where the servers' owners agree to a login every schedule (a service account's logins show in their logs).
  • Turn it off only if something else watches every certificate the engine connects to.
  • The first check runs a minute or two after the engine starts, at a random moment, so engines started together don't check together (nuance).

Memory for interface code

isolates.heap_mb is the memory limit, in MB, of each copy of the JavaScript engine that runs interface code (an isolate). Default: 512.

There is one isolate per thread of the shared pool (one thread per CPU core, or DUCTUS_THREADS), and one per worker of each interface with isolation: dedicated. So interface code can use up to about heap_mb × (threads + dedicated workers) in all; most interfaces use a few MB, so that's a ceiling, not what you'll see.

  • Code that fills its isolate is stopped, its message quarantined and its interface stopped until a restart. The engine and every other interface keep running.
  • In the shared pool the limit is shared by every interface on the thread, so the one that runs out isn't always the one that filled it: a cache in another interface that only grows (nuance, what you keep is what costs).
  • Raise it for code that holds a lot on purpose (a large lookup table loaded once), or give that interface isolation: dedicated with its own isolates. Lower it on a small server with many cores, so the ceiling fits in its memory.

Insisting on documentation

require_about makes ductus validate fail, not only warn, when an interface doesn't name the application or vendor on each side in its about:. That's what lets ductus impact --vendor "Epic Systems" answer "what does this vendor's outage touch?" completely. Turn it on once the existing interfaces are documented, so every new one must be. Default: false.

resources.yaml: what interfaces share

resources.yaml defines, once and by a name, everything interfaces connect to that isn't a source or a destination of their own: the databases their code queries, SFTP servers, HTTP APIs, directories people sign in with, and certificates. Interfaces and their code use the name:

const row = await db("patient_db").one("SELECT mrn FROM patients WHERE id = $1", [id]);
const patient = await api("empi").get(`/patients/${mrn}`);

Why names: no address, login or password is ever written in an interface or its code. The code is the same in every environment; resources.yaml fills in each environment's host with ${NAME} and its password with ${secret:NAME} (environments). The engine logs in on the code's behalf, so the code never sees a credential. And because every connection is named, ductus doctor can check each one from the server, ductus impact can say which interfaces use it, and its certificate is watched for expiry.

When not: an interface's own source and destinations (an MLLP receiver, a folder) are in its interface.yaml, not here, unless several share them: an SFTP server and an HTTP API are always resources, so their login is in one place.

Every entry of every kind also takes application, vendor and owner (who to ask, best an email address: its certificate's expiry alerts reach them with to: owner), for ductus impact and alerts, and custom:, your own documentation fields.

Databases

databases are the databases interfaces' code queries with db("<name>"): lookups, inserts, a nightly extract. Each is postgres or sqlserver.

# resources.yaml
databases:
  patient_db:
    postgres:
      host: ${PATIENT_DB_HOST}
      database: patients
      user: ductus_lookup
      password: ${secret:PATIENT_DB_PASSWORD}
      tls: verify-full
      max_connections: 5
      application: Patient index
      owner: dba@hospital.org
  billing_db:
    sqlserver:
      host: ${BILLING_DB_HOST}
      database: Billing
      user: ductus
      password: ${secret:BILLING_DB_PASSWORD}
      application: Billing
      vendor: Cerner
  • The login: give it only what the queries need, often read-only on a few tables or views. A wrong password makes messages wait and retry rather than quarantine: fixing it fixes them all (nuance).
  • Connections: each entry has a pool, shared by every interface's calls, of up to max_connections (default 5), opened as needed. A call that finds them all busy waits up to 10 s, then fails as a temporary error (the message waits and is tried again). Raise it when calls queue while the database itself is quick, within what its DBA allows for every engine; don't raise it to cure slow queries. A busy interface with isolation: dedicated can have connections of its own (dedicated_pools).
  • Postgres TLS works as for the engine's own database: tls: verify-full checks the server's certificate against this server's trusted CAs (ca: names a private one); without tls, it's prefer, which checks nothing (PostgreSQL's TLS).
  • SQL Server is always TLS, verified: encrypt: mandatory by default, strict (TDS 8) for servers set to force it; there's no unencrypted mode. ca: for the domain's CA where this server doesn't trust it; name_in_certificate when connecting by an alias or a listener's name (nuance). A named instance is instance: PROD: its port comes from the SQL Server Browser (UDP 1434, which the firewall must let through).
  • SQL Server Windows logins (auth: windows), where the DBA allows only those: without user, the engine logs in as the Windows service's own account (a gMSA is best: no password anywhere); with user and password, as that domain account. From Linux that is always NTLM, which some servers refuse (nuance, from Linux).
# resources.yaml
databases:
  billing_db:
    sqlserver:
      host: ${BILLING_DB_HOST}
      instance: PROD
      database: Billing
      auth: windows          # as the service's account: on Windows, best a gMSA

What code can do with one, and what to watch for: one result set per call on SQL Server, a query that writes is a write a crash can repeat, write names out (db("patient_db"), not db(name), so ductus impact can see it) (databases and APIs, running twice).

SQL Server, worth knowing before the first connection:

  • encrypt: strict is TDS 8: TLS first, before anything else is said (nuance). SQL Server 2022 on Linux refuses it whatever it's given; 2025 accepts it (nuance).
  • Connecting by an alias, a listener or an address the certificate doesn't list: name_in_certificate checks it for the right name (nuance).
  • Its certificate is read through PRELOGIN, as the connection meets it, and watched when verified (nuance); verifying it costs nothing measurable (measured).
  • Windows logins (auth: windows) from the service's own account are the computer's account on the network: give that a login (nuance). Doctor and the start log say whether Kerberos or NTLM was used (nuance). A refusal is 18452 (Windows said no) or 18456 (no login on SQL Server's side) (nuance), and Extended Protection set to Required refuses NTLM (nuance).

SFTP servers

sftp entries are the servers sftp sources read from and sftp destinations write to (server: <name>).

# resources.yaml
sftp:
  quest:
    host: ${QUEST_SFTP_HOST}
    user: hospital
    private_key: ${secret:QUEST_SFTP_KEY}
    host_key: "SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8"
    application: Quest Lab Results
    vendor: Quest
  • Log in with a key where the server takes one: ssh-keygen -t ed25519 -f quest makes a pair; the private half goes in a secret (ductus secret set QUEST_SFTP_KEY --env prod <quest), the public half (quest.pub) to the server's owner. A password works too.
  • host_key is the server's key fingerprint, so a server pretending to be this one is refused before any password or key reaches it. Leave it out the first time: the connection fails, and the error gives the fingerprint the server presented. Check it with the server's administrator, then paste it in. When their key changes (a rebuilt server), connections fail until you update it: on purpose, since that's what an impostor looks like too.

HTTP APIs

apis are the HTTP APIs the code calls with api("<name>"), and that http destinations send through (api: <name>): a base URL, the headers every request carries (the token), a timeout, and TLS.

# resources.yaml
apis:
  empi:
    url: ${EMPI_URL}
    headers: { Authorization: "Bearer ${secret:EMPI_TOKEN}" }
    timeout: 10s
    application: EMPI
    owner: empi-team@hospital.org
  • Paths in code and destinations go under the URL: api("empi").get("/patients/1") calls ${EMPI_URL}/patients/1.
  • A header the code or a destination sets itself replaces the API's of the same name (nuance).
  • 5xx, 408 and 429 answers are retried by default (nuance).
  • ca: for an API with a private CA's certificate, certificate: for one that asks for a client certificate (mutual TLS): both certificates: entries (TLS).

Directories

ldap entries are the directories people sign in to the web UI with (Active Directory, OpenLDAP): the server, its TLS and the service account that looks people up. auth.ldap in ductus.yaml names the one to use and maps its groups to roles: People: users, tokens, the directory.

Certificates

certificates are the certificates the engine presents (on its listeners, the API, and as a client certificate) and the CAs it trusts or checks clients against, each by a name: TLS and certificates.

interface.yaml: an interface

An interface is a folder under interfaces/: its interface.yaml, its code, its tests and sample messages. The YAML says where messages come from, what they are, where they go, and how they're delivered, kept and watched; the code says what happens to each one on the way.

Only three things are required: a name, a source, and a destination (or several, destinations). Everything else has a default that's safe to start with:

# interfaces/adt-relay/interface.yaml
name: adt-relay
source: { mllp: { port: 6662 } }
destination: { mllp: { host: ${LAB_HOST}, port: ${LAB_PORT} } }

That relays HL7 from whoever connects on port 6662 to the lab, as received: no code, every message stored before its ACK, delivered in arrival order, retried for as long as the lab is down or answers AE (an AR, a refusal for good, is quarantined for a person), kept seven weeks.

A fuller one, with each part explained on the pages below:

# interfaces/adt-to-lab/interface.yaml
name: adt-to-lab
about:
  description: ADT from Epic to the Sunquest lab
  owner: lab-it@hospital.org
  from: { application: Epic, vendor: Epic Systems, hosts: [10.20.30.0/24] }
  to: { application: Sunquest Lab, vendor: Sunquest }
source:
  mllp:
    port: ${EPIC_PORT}
    allow: [10.20.30.0/24]
script: transform.ts
timeout: 500ms
ordering: { key: PID-3.1 }
destinations:
  lab:
    mllp: { host: ${LAB_HOST}, port: ${LAB_PORT}, tls: true }
  billing:
    http: { api: billing, path: /charges }
    transform: toBilling
    output: json
retention: 3w
monitor:
  active_within: 30m
  between: "06:00-22:00"
  behind: { messages: 500, age: 10m }
alert: { to: [lab-team] }
PartSettingsPage
Where messages come from, what they are, who may sendsource, format, output, allowSources and formats
Where they godestination, destinationsDestinations
How they're deliveredordering, retry, dedupe, rate_limit, durability, isolation, workers, dedicated_poolsDelivery
How long they're kept, what's expected, who hearsretention, quarantined, monitor, alertKeeping and watching
What it is and who owns itabout, customAbout and owners

The name

name is how the database, the web UI, logs, alerts and routes from other interfaces know it. Use the folder's name (as ductus new interface does), short and stable: lower-case words joined by -, saying what flows and between whom (adt-epic-to-lab). Don't rename an interface with messages waiting: messages are stored under the name, so a renamed interface is a new one; the old name's history stays under the old name, and anything still waiting there is sent by nobody.

The code

script is the TypeScript file, relative to the interface's folder, that exports transform(msg, meta) (and any function a destination names with transform:). ductus validate type-checks it, and the engine loads it at start.

  • Without a script, messages go to their destinations as they came. That's right for a relay; most interfaces have one.
  • A schedule source, and an HTTP source in respond mode, need one: there, the code makes the message or the answer.
  • What the code returns decides: a message to send, null to filter it (kept, with the reason), or a thrown error to quarantine it for a person (nuance).

timeout is the longest the code may run for one message without pausing (time waiting on db(), fetch() or api() doesn't count), from 10ms to 10m; default 5s. It's the guard against code that never ends, such as a loop an unusual message never lets finish: past it, the code is stopped, its message quarantined, and the interface carries on; three in a row stop the interface until the engine restarts (nuance). Set it close to what the code needs: 500ms is plenty for most HL7 transforms. Interfaces in the shared pool wait while code on their thread runs, so a generous limit lets one runaway hold them all up that long; code that computes for seconds on purpose belongs in isolation: dedicated (delivery).

Your own fields

custom holds your company's documentation fields for the interface (a BAA reference, a change ticket, a runbook link), declared in docs-schema.yaml: Documentation fields. The engine ignores them; ductus validate checks them, and the web UI shows them.

Sources and formats

An interface's source is where its messages come from: exactly one of these.

SourceMessages come fromUse it for
mllpSenders connecting over MLLPHL7 v2 feeds: ADT, orders, results from an EHR, a lab, a device gateway
httpRequests to a pathSystems that push JSON or XML; REST APIs other systems call and wait on
fileFiles dropped in a folder on this server or a shareBatch files, exports from systems that only write files
sftpFiles on an SFTP serverPartners who leave files on their server, or yours
scheduleThe code, on a timerPulling: a nightly query, an API polled every few minutes
internalOther interfaces' routesThe second half of a chain: messages one interface hands to another

Every source stores a message before it says it has it: an MLLP sender's ACK, an HTTP 202, a file's move or delete come only after the message is in the database. A sender that has its acknowledgement never needs to send again; a crash before it loses nothing the sender thinks was taken.

MLLP

# interfaces/adt-in/interface.yaml, in part
source:
  mllp:
    port: 6661
    host: 0.0.0.0            # the default: every IPv4 address of the server
    allow: [10.20.30.0/24]   # who may connect (below)

The engine listens on port; senders connect, send HL7, and get an ACK once each message is stored (AA), or an AE when it can't be (the database is unreachable: they send it again). Each MLLP source has a port of its own; ductus new interface picks a free one from ports.mllp in ductus.yaml, and ductus validate reports two on one port. A sender may open any number of connections. An MLLP source always carries HL7.

  • host: which of the server's addresses to listen on: 0.0.0.0 (default, every IPv4 address), :: (IPv6 too), one address, or 127.0.0.1 (this machine only, for tests).
  • tls: accept only TLS, presenting a certificate, and optionally take only senders with a client certificate from a CA you name: TLS on listeners. Senders on plain MLLP can't connect then, so agree on a date with them.

HTTP

# interfaces/orders-in/interface.yaml, in part
source:
  http:
    port: 8080
    path: /orders
    method: POST             # the default
    mode: queue              # the default
format: json
  • mode: queue (the default): the request body is stored as a message, answered 202 Accepted once stored, and delivered like any other. For systems that push data.

  • mode: respond: the code runs on the request and its result is the response, at once. Nothing is stored or queued, there's no destination, and if something fails the caller gets an error status and decides whether to try again (nuance). For REST APIs and lookups that other systems call and wait on:

    # interfaces/patient-lookup/interface.yaml
    name: patient-lookup
    source:
      http: { port: 8081, path: "/patients/{mrn}", method: GET, mode: respond }
    script: lookup.ts
    format: json
    

    {mrn} matches one path segment and is given to the code. In respond mode, calls the code makes one after another add up; await them together where you can (nuance).

  • Several HTTP interfaces can share a port, each with its own path (two can't have the same method and path). They're one server then, so they must have the same tls and allow (nuance).

Files and SFTP

# interfaces/census-in/interface.yaml, in part
source:
  file:
    dir: '/data/inbound/census'   # Windows: 'D:\inbound\census', single-quoted
    pattern: "CENSUS_*.csv"
    poll: 30s
    after: move                   # the default: into dir/archive
    split: records                # each row is a message
format: csv
# interfaces/quest-results/interface.yaml, in part
source:
  sftp: { server: quest, dir: outbound, pattern: "*.csv", after: delete, split: records }
format: csv

An sftp source takes the same settings as a file one, plus server: an sftp: entry in resources.yaml with the address and login (SFTP servers).

  • A file is read once it stops changing: its size and time the same at two looks in a row, poll apart (default 10s), so a file still being written is never read half done. Expect it one to two polls after its writer finishes (nuance). A writer that says when it's done (a .done file beside it) can use marker: .done instead.
  • Files keep their order, oldest first, one at a time: if one fails, later ones wait (nuance).
  • split: records makes each record a message (an HL7 batch at each MSH, CSV at each row with the header kept, fixed-length and text at each line), read as a stream, so a file of any size works; after a stop it carries on from the last record stored. Without it, a file is one message, up to 256 MB.
  • after: move (the default, to archive_dir, by default archive inside dir) or delete, once everything in the file is stored. The engine never deletes what it moved: clean the archive yourself.
  • The service must be allowed to move them: on Linux, add the folders to the unit (nuance); on Windows, grant the service's account (nuance).
  • A relative dir is relative to the interface's folder.

Schedules

# interfaces/nightly-census/interface.yaml
name: nightly-census
source: { schedule: { cron: "0 2 * * *" } }   # 02:00 every day, the server's local time
script: census.ts
format: csv
destination: { sftp: { server: quest, dir: census } }

No input: the code runs on the timer and what it returns becomes the message. every: 15m runs at start, then at that interval (for polling); cron runs at the times a five-field expression gives (for a time of day). A run is skipped while the previous one's message is still waiting, and runs missed while the engine was down are made up by one run, not one per missed time (nuance).

Internal: from another interface

# interfaces/adt-to-billing/interface.yaml
name: adt-to-billing
source: internal
script: billing.ts
destination: { http: { api: billing, path: /charges } }

An interface with source: internal receives what other interfaces route to it with destination: { interface: adt-to-billing }: stored in the same transaction that marks it sent there, so nothing is lost or sent twice between the two (routes).

Who may connect: allow

# interfaces/adt-in/interface.yaml, in part
source:
  mllp:
    port: 6661
    allow: [10.20.30.0/24, 10.20.31.15, epic-bridge01.hospital.org]

allow on an MLLP or HTTP source lists who may connect: addresses, subnets and host names. Anyone else is disconnected before a byte is read, before any TLS handshake, and logged once a minute per address. Without it, anyone who can reach the port may connect.

  • Why: it names the sending system, which "allow one connection only" (a habit from other engines) never did; a sender may open several connections, and its reconnect after a blip isn't refused (nuance).
  • Keep the firewall rules too: each catches the other's mistakes (nuance).
  • List the sender's addresses or subnet, its failover and DR hosts, and anything that relays for it. Host names are looked up at start and every 5 minutes, never per connection (nuance). Behind a proxy or load balancer, the address seen is the proxy's.
  • ductus validate warns when about.from.hosts names a sender the list would refuse.
  • HTTP interfaces on one port share one list: it's checked before the request is read, so before its path is known; validate refuses a port whose interfaces disagree (nuance).
  • A sender that vanished (a pulled cable, a paused virtual machine) is noticed in about 30 seconds: every accepted connection has TCP keepalive on, so its lane isn't held by a dead connection for the operating system's two hours (nuance).

Formats

format is what the messages are, so the code gets them parsed and keys (an ordering key, dedupe keys) can be read from them: hl7 (the default), json, xml, text, csv or fixed (fixed-length records).

FormatThe code getsKeys are written
hl7Segments and fields (msg.get("PID-3.1")), numbered from 1 as the standard doesPID-3.1, MSH-10
jsonThe documentorder.id
xmlThe documentOrder/@id
csvEach row an object by column name (with a header line) or a list (without)A column's name, or its number from 1
fixedEach record an object by field nameA field's name
textThe payload as a string
# interfaces/claims-in/interface.yaml, in part
format:
  fixed:
    fields:
      - { name: mrn, width: 10 }
      - { name: last_name, width: 20 }
      - { name: amount, width: 10, align: right, pad: "0" }

format: csv alone means commas and a header line; { csv: { delimiter: "|", header: false } } for others.

output is the format the code's results are written in when it isn't format: HL7 in, JSON out to an API. A destination can set its own output (destinations). Default: the same as format.

Destinations

Where an interface's messages go: one place with destination, or several, each by a name you choose, with destinations.

ConnectorSends each messageDelivered when
mllpOver MLLP to a receiver, one connection kept openThe receiver answers AA
httpAs an HTTP request to a URL, or a path under an API from resources.yamlA 2xx answer
fileAs a file in a folder of this server or a shareThe file is complete under its name
sftpAs a file on an SFTP serverThe same, on the server
interfaceInto another interface's queueIt's stored there, in the same transaction

One or several

# interfaces/adt-to-lab/interface.yaml, in part
destinations:
  lab:
    mllp: { host: ${LAB_HOST}, port: ${LAB_PORT} }
  billing:
    http: { api: billing, path: /charges }
    transform: toBilling
    output: json
  archive:
    file: { dir: ${OUTBOUND_DIR} }
  • Each destination gets every message and is delivered independently: its own queue, order, retries and pause. One that's down holds up only itself; the others carry on (nuance).
  • Each runs its own function on the original message, the one named by transform: (default transform), not on another destination's result: toLab and toBilling each shape it for their receiver (nuance). output: sets the format that destination's result is written in.
  • A message is finished once every destination is done with it: sent, filtered or quarantined. Filtering and quarantine are per destination too (nuance).
  • destination: (one) is the same as a single destination named default. Use one or the other, not both.
  • Adding a destination later gives it only messages that arrive from then on (and those still in flight), not older ones; removing one filters what was waiting for it (nuance).
  • Many destinations on one interface: past a handful, consider splitting (when).

Each destination can also set its own retry, rate_limit and monitor (in place of the interface's: delivery), its own application, vendor and owner when they differ from the interface's about.to (about), and custom: fields.

Keeping what was sent. By default only the original message is stored, and what a destination was sent is reproduced by running its code again when you look (nuance). Where you must be able to prove exactly what was sent, save_output: true on that destination keeps it with its delivery (about 1.15 GB per million deliveries, measured).

Each destination runs its own function, but code that runs away after an await can be blamed only on its interface: every call of that interface in flight on the thread is quarantined, whichever destination it was for (nuance).

MLLP

# interfaces/orders-to-lab/interface.yaml, in part
destination:
  mllp:
    host: ${LAB_HOST}
    port: ${LAB_PORT}
    timeout_ms: 10000       # the default: connect, send and wait for the ACK
    on_reject: quarantine   # the default: an AR is for a person to look at
    tls: true               # TLS, trusting this server's CAs

The engine sends a message and waits for the ACK before the next one, on one connection it keeps open. AA is delivered; AE is tried again; AR follows on_reject: quarantine (the default) or retry, for receivers that answer AR when they're busy. A send that takes longer than timeout_ms is dropped and tried again; the receiver may have it already, so receivers that dedupe on MSH-10 take care of the repeat (twice). On Windows keep timeout_ms above 2000 (why).

tls: true sends over TLS, trusting the certificates this server trusts; tls: { ca, certificate } also trusts a private CA and presents a client certificate: TLS to receivers. A refused handshake is a temporary error: the message waits, with the reason on the destination (nuance).

ack: none sends without reading an answer, for the rare receiver that never sends ACKs: nothing then tells the engine a message was refused or lost.

HTTP

# interfaces/results-to-portal/interface.yaml, in part
destination:
  http:
    api: portal             # an apis: entry: its URL, token, timeout and TLS
    path: /results
    method: POST            # the default
output: json

Each message is a request: to url, or to path under an apis: entry in resources.yaml (api:), which brings the base URL, headers (the token), timeout and TLS, set once for every interface and environment (APIs). Prefer api: for anything with a token. A 2xx answer is delivered; 408, 429, 5xx and network errors are tried again; any other 4xx is quarantined: the receiver refused this message, and sending it again won't change that.

With a url of its own instead, a token belongs in a secret: headers: { Authorization: "Bearer ${secret:PORTAL_TOKEN}" }.

Files and SFTP

# interfaces/adt-to-archive/interface.yaml, in part
destinations:
  local:
    file: { dir: ${OUTBOUND_DIR} }
  partner:
    sftp: { server: quest, dir: inbound/adt }

Each message is a file named by the message's id and the output format (1234.hl7), written under a hidden temporary name and renamed when complete, so a reader never sees half a file. A file destination's dir is relative to the interface's folder unless written whole, and is made if it's missing; the service must be allowed to write there (Linux: the sandbox; Windows: the service's folders). An sftp destination's server is an sftp: entry in resources.yaml.

To another interface

# interfaces/adt-in/interface.yaml, in part
destinations:
  lab:
    mllp: { host: ${LAB_HOST}, port: ${LAB_PORT} }
  billing:
    interface: adt-to-billing

interface: puts each message into the queue of an interface with source: internal, in the same database transaction that marks it sent here: no network hop, and nothing lost or sent twice between the two (nuance). Use it to split one feed into teams' interfaces, or to chain steps each with its own owner, code, retries and alerts. The receiving interface's format must be what this destination outputs, its own keys, dedupe and destinations apply (nuance), and routes can't form a loop: ductus validate checks both, and ductus impact follows routes (nuance).

A drain stops what comes in from outside; routes between interfaces keep moving, so what's already inside reaches its last interface before the engine stops (nuance).

Pausing one

A destination can be paused (from the web UI, the API or ductus pause adt-in lab), at once, with a reason now or later (--reason): its messages wait, in order, until it's resumed, and a pause never alerts. For a receiver's planned downtime (nuance).

Delivery: order, retries, limits

How an interface's messages are delivered: in what order, how failures are tried again, what counts as a repeat, how fast a receiver may be sent to, when a sender gets its ACK, and where the code runs. The defaults are safe: strict order, retried until delivered, acknowledged only once on disk, in the shared pool.

Order: ordering

ordering says which messages must be delivered in the order they arrived: those with the same key.

# interfaces/adt-to-lab/interface.yaml, in part
ordering:
  key: PID-3.1        # one patient's messages in order; different patients may overtake
  • Without a key (the default), the whole interface is one lane: delivered strictly in arrival order, one message at a time per destination. Correct for anything, and the right start for a feed whose receiver cares about order across patients.
  • With a key, messages with the same value (one patient's MRN) stay in order with each other, and different values move independently, in 64 lanes by default (partitions). One patient's failing message then holds up only its own lane, not everyone (nuance).
  • Choose the narrowest key correctness allows: by patient for ADT (PID-3.1), by order for results (ORC-2). A message whose field is empty goes to the empty value's lane.
  • More workers help only with a key: with one lane, one worker does everything (nuance).
  • Set partitions before the interface carries traffic. Lowering it with messages waiting is refused at start (they'd be in lanes no worker reads); raising it can reorder a patient's waiting and new messages until the backlog drains. ductus drain --wait first (nuance).

Failures: retry

retry is how a delivery that failed for a reason that may pass (a receiver down, a timeout, an AE, a database the code needs unreachable) is tried again: waits doubling from initial_backoff_ms (1 s) to max_backoff_ms (60 s), forever by default.

# interfaces/orders-to-lab/interface.yaml, in part
retry:
  max_attempts: 10              # then quarantine: "gave up after 10 attempts: …"
  max_backoff_ms: 300000        # at most 5 minutes between tries
  • Forever is the default because a receiver that comes back should get every message it missed, in order, with nobody resending by hand (nuance).
  • Set max_attempts where a person looking beats waiting: a receiver that refuses some messages for good with errors that look temporary. A message being retried holds up its lane.
  • max_backoff_ms is how soon after a receiver comes back its messages start moving, at worst.
  • A failure that can't pass (an AR, a 4xx, the code throwing) isn't retried: it's quarantined at once. The code's own temporary errors (a database down) are always retried.
  • A destination can set its own retry.

Repeats: dedupe

dedupe recognizes a message sent again within a window (a sender resending after a lost ACK, a file dropped twice) by the values of keys, and doesn't deliver it twice. Off by default.

# interfaces/adt-in/interface.yaml, in part
dedupe:
  keys: [MSH-10]      # the default: HL7's message control ID
  window: 24h         # the default

A repeat is stored with the status duplicate, so it can be found, but never queued. Use it when the receiver can't tolerate repeats and the sender is known to resend. Most HL7 receivers already ignore a repeated MSH-10, so it's often not needed. Choose keys a resend repeats and a new message never does; cover the longest a sender may take to resend (after a weekend, 7d). Dedupe stops repeats from the sender; delivery itself is at least once, so the receiver may still see one message twice after a crash (nuance).

dedupe protects the receiver of this interface. When your code writes to another system (db().execute, an API POST), a "done" marker in a table of yours can't make that write happen exactly once: the two writes can't share a transaction. Make the write itself idempotent (an upsert keyed on the message's control id, an idempotency key header) (nuance).

Speed: rate_limit

rate_limit is the most messages a minute each destination may be sent, for receivers that can't take bursts (a device, a vendor API with a quota). No limit by default.

# interfaces/results-to-portal/interface.yaml, in part
rate_limit:
  per_minute: 600       # 10 a second, on average
  burst: 20             # at most 20 at once after a quiet spell
  over_limit: wait      # the default: held, in order

mode: hard counts per calendar minute instead, for quotas counted that way. over_limit: drop filters what's over the limit, with the reason: only for what is worthless late (frequent status updates where the next replaces the last). Each destination has its own allowance, and can set its own limit.

When senders get their ACK: durability

durability decides whether a sender's ACK (or HTTP 202) waits for the message to be on the database's disk.

  • strict (the default): the ACK waits for the flush. Once a sender has it, the message survives a crash or power loss of any machine.
  • relaxed: the ACK goes out as soon as the database has the message, before the flush. One sender connection can then go much faster than one message per disk flush (about 400 a second at 2.4 ms a flush). The cost: if the database server crashes or loses power, the last fraction of a second of acknowledged messages is lost, and senders won't resend them. An engine crash loses nothing in either mode (nuance).

Use relaxed only where one connection's peak is more than the disk allows and a lost message can be recovered another way (a resend from the source, a nightly reconciliation). Try a faster disk for Postgres's log, or more sender connections (they share flushes), first.

Dedicated threads and connections

By default an interface's code runs in the shared pool: one thread per CPU core, each running many interfaces, with one JavaScript engine (an isolate) per thread. Cheap, and right for most. isolation dedicated gives an interface threads of its own instead.

# interfaces/results-bulk/interface.yaml, in part
ordering: { key: PID-3.1 }
isolation: dedicated
workers: 8
dedicated_pools: { patient_db: 4 }
  • workers (default 4, dedicated only): its threads, each with its own isolate, sharing the lanes. More help only with an ordering key, up to about the number of cores.
  • dedicated_pools (dedicated only): connections of its own to resources.yaml databases, so a busy interface can't take every connection of the shared pool from the others, nor theirs from it (nuance).
  • When: a busy interface that needs more than one worker (with a key, so there are lanes to share), code that computes for seconds on purpose, or code you don't trust yet: its load and its runaways then touch no other interface (shared pool).
  • The cost: each worker is a thread and up to isolates.heap_mb of memory (memory).

Most interfaces never need it: one destination of an interface in the shared pool drained about 1,500 messages a second in the measurements, against an average of 12 a second for a million messages a day (measured). When one does, in this order: an ordering key, then dedicated, then more workers, then dedicated_pools.

Keeping and watching

How long an interface's messages are kept, what operations expects of it, and who hears when it doesn't happen.

How long: retention and quarantined

retention on an interface overrides the project's (ductus.yaml, 7w by default) for that interface: 1w, 3w or 7w.

# interfaces/adt-in/interface.yaml, in part
retention: 1w          # a busy feed nobody looks back on
quarantined: keep      # the default: until a person resolves them

The rules are the project's: whole partitions dropped, kept at least the window, nothing waiting ever deleted, a shorter window waiting for ductus retention confirm adt-in (how long messages are kept). Change it per interface when its feed is far busier or far more looked-back-on than the rest. One change at a time per interface (nuance).

quarantined is the same choice as the project's, for this interface's quarantined messages past the window: keep them in the held area until someone resends, corrects or dismisses them, or purge them with the rest (nuance).

Neither applies to an HTTP source in respond mode: it keeps nothing.

What's expected: monitor

The engine can't know that a quiet feed is a problem: ADT from an EHR that stops for half an hour in the afternoon is an outage, a lab's nightly file that doesn't come by 07:00 is one too, and a test feed that's quiet all week is fine. So nothing is expected until you say so (nuance), in the terms the receiving team uses, with monitor:

# interfaces/adt-in/interface.yaml, in part
monitor:
  active_within: 30m              # something received every 30 minutes...
  between: "06:00-22:00"          # ...during these hours (the server's local time)
  behind: { messages: 500, age: 10m }
# interfaces/quest-results/interface.yaml, in part
monitor:
  active_by: "Mon-Fri 07:00"      # the nightly file, by 07:00 on weekdays
  from: "18:00"                   # counting what arrived since 18:00 the evening before
SettingBreaking it isFor
active_within (with between)silentFeeds that never stop in normal operation: set it a little above the longest normal gap.
active_by (with from)missedFeeds that come once a day or week: a nightly file, a Monday batch.
behind (messages, age, or both)behindQueues: when the receiving department would call it late. A count alone misses an overnight stall; an age alone misses a daytime pile-up that still moves; give both for busy feeds.

Only receiving counts for active_within and active_by. A destination can set its own behind. Besides these, some states need no setting: a destination with messages waiting that has sent nothing for a minute is stalled, one with quarantined messages is in error, an interface whose code was stopped is stopped, and a paused destination is paused (never an alert). The web UI, ductus status and Prometheus show them all (what each state means).

Who hears: alert

States become notifications only through the alert rules in ductus.yaml (Alerts and owners): a rule says which states alert, how badly, and to which channels. alert on an interface adds recipients on top of the rule's, or silences the interface:

# interfaces/adt-in/interface.yaml, in part
alert:
  to: [lab-team, owner]     # channels from ductus.yaml, and/or whoever owns it
# interfaces/new-feed/interface.yaml, in part
alert:
  mute: true                # half-built: alerts open and show, but nobody is told

to: [owner] reaches whoever the interface's (or the destination's) owner names (who "owner" is). mute sends nothing about this interface; its alerts still open and show in the web UI, so nothing is hidden from people who look.

About and owners

about says what an interface is for, who owns it, and the systems on each side. Nothing in it changes how messages flow: it's what people and tools read when something goes wrong.

# interfaces/adt-to-lab/interface.yaml, in part
about:
  description: ADT from Epic to the Sunquest lab
  owner: lab-it@hospital.org
  from:
    application: Epic
    vendor: Epic Systems
    hosts: [10.20.30.0/24, epic-bridge01.hospital.org]
  to:
    application: Sunquest Lab
    vendor: Sunquest

Why fill it in

  • ductus impact answers "what does this touch?": ductus impact --vendor "Epic Systems" for a vendor's outage or breach, ductus impact --host 10.20.30.0/24 for a network change, with how each interface is affected (it sends to them, receives from them, queries their database) and who owns it. It knows only what's named (nuance): the application and vendor on each side, the hosts senders connect from, and the same on resources.yaml entries.
  • On call, the web UI shows the description and owner on the interface's page, and alert rules can send to the owner: the person woken at 3 a.m. knows what broke and who to call.
  • hosts is the one thing the engine can't learn from the configuration (who connects to it): with it, a firewall change can be traced to the interfaces it affects, and ductus validate warns when the source's allow: list would refuse a host named here.

ductus validate warns about an interface that doesn't name the application or vendor on each side; require_about: true in ductus.yaml makes it an error, once your existing interfaces are documented.

Owners

about.owner is who to ask about the interface: a person or a team. Write it as an email address (lab-it@hospital.org): an address matches the person's account (by its email) and survives a change of name, where a name might match two people or nobody.

  • A person with an account shows as that person in the web UI, and their Interfaces page opens on what they own ("Mine"). With a directory, people who own something get their account before they first sign in, when they're in a Ductus group (nuance).

  • A team without an account is reached through owners: in ductus.yaml, by the name the interfaces give:

    # ductus.yaml, in part
    owners:
      Lab Systems:
        email: lab-systems@hospital.org
    
  • A destination with another owner (its code or its receiver is another team's) says so on the destination: owner: billing-it@hospital.org. Otherwise a destination's owner is its interface's (nuance).

  • resources.yaml entries have an owner too: who renews a certificate, who looks after a database.

Alert rules with to: owner mail whoever owns the interface or destination (Alerts and owners). ductus owners lists who owns what; with the database it marks which owners are people with an account, and --check fails on an owner that reaches nobody (run it in CI). When someone leaves, ductus owners reassign --from old@hospital.org --to new@hospital.org moves everything they owned, in interface.yaml and resources.yaml alike: a change to commit, like any other (nuance).

  • An owner is a person when the text names exactly one account (by username, email or display name); two accounts at one level name nobody, and the Owners page says so (nuance).
  • Operators who own something start on their own interfaces in the console ("Mine") (nuance).
  • Moving ownership in the console makes a preview of the change to commit to the project, not a change: the project in Git stays the truth (nuance).

Your own fields

custom, beside about, holds the documentation your company keeps that Ductus doesn't ask for (a BAA reference, the change ticket, the analyst, a runbook link): Documentation fields.

TLS and certificates

TLS encrypts a connection and proves who is at the other end. The engine uses it in three places, all set up the same way, with certificates named once in resources.yaml:

WhereSettingsThe engine
On what it listens on: MLLP and HTTP sources, the API and web UItls: on the source, api.tlsPresents its certificate; optionally asks senders for theirs
On what it connects to: MLLP and HTTP destinations, APIs, databases, the directory, the mail server, webhookstls: / ca: / certificate: on eachChecks the receiver's certificate; optionally presents its own
To its own databasesslmode in the URLPostgreSQL's TLS

Use TLS wherever messages cross a network you don't own, and for the API and web UI whenever they're reachable from another machine. It costs nothing you'll see on an MLLP feed: ingest measured the same with and without, about 1 ms per new connection, ~23 kB of memory per open one (measured). Listeners speak TLS 1.2 and 1.3 only, and every connection prefers a post-quantum key exchange where the other side offers one (nuance).

Certificates in resources.yaml

Every certificate and CA the engine uses is a certificates entry, by a name:

# resources.yaml
certificates:
  ductus-mllp:                              # a pair the engine presents
    cert_file: ${TLS_DIR}/mllp.pem          # the certificate, then its intermediates
    key_file: ${TLS_DIR}/mllp.key
    owner: infrastructure@hospital.org      # who renews it: expiry alerts reach them
  ductus-api:
    cert_file: ${TLS_DIR}/api.pem
    key_file: ${TLS_DIR}/api.key
  hospital-ca:                              # a CA alone: trusted, or checked against
    ca_file: ${TLS_DIR}/hospital-ca.pem
  partner-ca:
    ca: ${secret:PARTNER_CA}                # PEM text from a secret instead of a file
  • A pair (cert_file + key_file) is what the engine presents: on a listener, or as its client certificate. cert_file holds the certificate first, then the intermediate certificates that issued it (not the root): the "full chain" a CA sends. Java and .NET senders don't fetch a missing intermediate, and refuse the connection (nuance).
  • The key is unencrypted PEM, readable by the engine's account and nobody else (chmod 600, owned by ductus; on Windows, the service's account only).
  • A CA (ca_file, or ca as text) is trusted for a connection that names it, or checks senders' client certificates.
  • From a vault instead of files: cert and key as PEM text, usually ${secret:…}. Read at start only, so a renewal needs a restart, unlike files.
  • Paths: relative to the project folder unless written whole; on Windows, write the drive (nuance). Keep the files outside the project on a server (/etc/ductus/tls, readable by the ductus group), with the path in the environment (${TLS_DIR}), so keys never go near Git.
  • Checked before anything runs: every entry is loaded at start, before the database. A missing file, a key that isn't the certificate's, or an expired certificate stops the start with the entry and the reason (nuance). ductus validate checks the same, with each certificate's names and days left.

Getting a certificate from your CA

ductus cert request /etc/ductus/tls/mllp --dns mllp.hospital.org --subject "O=General Hospital, OU=Interfaces"

writes a new private key (mllp.key, readable only by you, never overwritten) and a request (mllp.csr), on the server that will use it, and says what to do next: send the .csr (never the .key) to the hospital's CA, put what comes back in mllp.pem (yours first, then the intermediates), and check it. The key is RSA 2048, which every CA takes (--key ecdsa for P-256); one certificate serves as both a server's and a client's (nuance).

ductus cert show /etc/ductus/tls/mllp.pem      # or the entry's name: ductus cert show ductus-mllp

says what the file holds and judges it the way the engine and senders will: the names, who issued it, days left (the chain's earliest), whether the chain is complete and in order, and whether the key matches. It exits 1 when the engine would refuse it, and says when a file is a CSR, a key or DER instead of PEM. Run it before naming a certificate in resources.yaml.

For a lab with no CA, ductus cert self-signed tls/lab --name localhost makes a pair to try TLS with; senders must be given its .pem to trust. Your first interface plays a CA with openssl for the whole path.

From a PFX (Windows, Active Directory Certificate Services)

Windows tools export a certificate with its key as a .pfx (PKCS#12) file under a password. The engine reads PEM: convert it with openssl, which is on every Linux server and comes with Git for Windows (C:\Program Files\Git\usr\bin\openssl.exe):

# the certificate first, then the CA certificates in the file
openssl pkcs12 -in mllp.pfx -clcerts -nokeys | openssl x509 -out mllp.pem
openssl pkcs12 -in mllp.pfx -cacerts -nokeys | sed -n '/BEGIN/,/END/p' >>mllp.pem
# the key, unencrypted (protect the file: chmod 600, or the service's account only)
openssl pkcs12 -in mllp.pfx -nocerts -nodes | openssl pkey -out mllp.key
ductus cert show mllp.pem

Each asks for the PFX's password. A PFX from an older Windows (RC2-encrypted) makes OpenSSL 3 say "unsupported … RC2-40-CBC": add -legacy to each openssl pkcs12. The PFX's CA certificates may include the root, which cert show notes as harmless to send; it checks the order too. In PowerShell without Git Bash, run the same with the full path to openssl.exe, and use Select-String or an editor in place of sed to keep only the BEGIN…END blocks.

On what the engine listens on

tls: on an MLLP or HTTP source makes its port TLS only, presenting the certificate named:

# interfaces/adt-in/interface.yaml, in part
source:
  mllp:
    port: 6661
    tls:
      certificate: ductus-mllp
      clients:
        ca: hospital-ca
        subjects: ["CN=epic-bridge*.hospital.org"]
  • certificate: the pair it presents, for the name senders connect to.
  • clients (optional): senders must present a certificate of their own, issued by ca and, with subjects, naming an allowed system: CN, O, OU… parts with * and ? wildcards, or DNS= for a certificate's DNS names. Keep subjects narrow: without it, every certificate that CA ever issued (often every machine in the hospital) is let in (nuance).
  • Senders on plain MLLP can't connect once it's on, so agree on a date with them. One that tries gets its connection closed, no ACK; the engine's log says why, once a minute per sender (nuance).
  • Senders on TLS 1.0 or 1.1 can't connect at all (nuance).
  • HTTP interfaces sharing a port share its tls.
  • allow: beside it says where senders may connect from; client certificates say which system they are. Use both where it matters (who may connect).

To see the chain a listener really serves, run the full checks (Connections → Run the checks): the engine connects to each of its TLS listeners and judges what it gets, a missing intermediate included. It can't know which name senders connect by, so a certificate without that name shows only at a sender (nuance).

The web UI and the API

# ductus.yaml, in part
api:
  listen: 0.0.0.0:7700
  tls: { certificate: ductus-api }
  allow: [127.0.0.1, 10.1.50.0/24]

Needed as soon as the API is reachable from another machine: the web UI's sign-in cookie is only sent over HTTPS (without tls, signing in from another computer doesn't work at all), and tokens and passwords would cross the network readable. The start and ductus validate warn about an API on the network without it (nuance). Browsers that have seen it over HTTPS won't try plain HTTP to it again for a year (HSTS). clients: works here too, for an API only certain systems may call.

On what the engine connects to

Every TLS connection the engine makes checks that the other side's certificate comes from a CA this server trusts and is for the host connected to. Add a private CA, or a client certificate, per connection:

# interfaces/orders-to-lab/interface.yaml, in part
destination:
  mllp:
    host: ${LAB_HOST}
    port: ${LAB_PORT}
    tls: { ca: hospital-ca, certificate: ductus-mllp }
  • tls: true on an MLLP destination: TLS, trusting this server's CAs. ca: also trusts a certificates: CA, for that connection only, so a partner's CA isn't trusted by everything on the server (nuance). Ask the receiver's team for the CA that issued their certificate, not their certificate itself (which changes at every renewal).
  • certificate: presents a pair as the engine's client certificate (mutual TLS). Tell the receiver's team its subject and the CA that issued it (nuance).
  • Connect by the name on their certificate: one for lab.hospital.org is refused at 10.1.2.3 unless it lists that address.
  • The same ca: and certificate: go on an apis: entry (which api() and http destinations through it share), an HTTP destination with its own https:// URL, a Postgres entry, the mail server, webhooks and the heartbeat; ca: alone on a SQL Server entry and the directory.
  • A refused handshake is a temporary error: the message waits and is tried again, with the reason on the destination ("TLS: the receiver wants a client certificate") (nuance).
  • ductus doctor --interface orders-to-lab makes the engine's own handshake, with the same settings, before you deploy (nuance).

An HTTP destination that goes through an apis: entry (http: { api: billing }) uses that entry's ca and certificate; on the destination itself they're only for one with its own https url, and validate refuses them elsewhere (nuance).

Renewing

Replace both files; no restart. The engine reads every cert_file and key_file again every minute (one task for the whole engine, comparing their contents). A new pair that loads is used for new connections from then on; connections already open keep theirs until they close. A new pair that doesn't load (half copied, a new certificate with the old key, expired) is logged once as an error, and the engine keeps presenting the pair it had: a bad renewal never takes a listener down (nuance). After writing them, look for certificate renewed in the log within a minute, or the "Ours" group on the web UI's Connections page.

You'll be told in time. Every certificate the engine presents or checks is watched: an alert at 30, 14 and 7 days before it expires, to its owner with to: owner, and critical once expired (alerts; nuance).

What's watched, and how the days are counted. Every TLS connection the project makes: HTTPS APIs and destinations, MLLP with tls:, the directory, Postgres and SQL Server when verified, mail and webhooks (the list). The countdown runs to the earliest expiry in the chain, intermediates included, in whole days (nuance). A check that can't connect shows the error and keeps the dates it last read (nuance); behind a load balancer that ends TLS, it's the balancer's certificate you see (nuance). The engine's own pairs count down from their files, with no connection made (nuance), and renewed files that can't be used show on the pair's row, not as an alert: the pair in use still counts down (nuance). A refused certificate is said once, in the check's result and the delivery's error, not logged as an error at every check (nuance).

By hand (a CA without automation, such as AD CS's web enrollment): a new request under another name, signed, checked, then copied over the files in use:

ductus cert request /etc/ductus/tls/mllp-2027 --dns mllp.hospital.org
# … the CA signs mllp-2027.csr; put the result in mllp-2027.pem …
ductus cert show /etc/ductus/tls/mllp-2027.pem
cp /etc/ductus/tls/mllp-2027.pem /etc/ductus/tls/mllp.pem
cp /etc/ductus/tls/mllp-2027.key /etc/ductus/tls/mllp.key

With a renewal tool: anything that writes new PEM files works, with no engine feature needed. Point it at the files the entry names (or copy into them from its own folder after each renewal), make sure the engine's account can read the result, and run ductus cert show on the first one it writes. Three common ones:

  • certbot (Linux, ACME: Let's Encrypt for public names, or your own ACME CA with --server): a deploy hook copies each renewal into place:

    certbot certonly --standalone -d mllp.hospital.org \
      --deploy-hook 'install -o ductus -g ductus -m 600 "$RENEWED_LINEAGE/privkey.pem" /etc/ductus/tls/mllp.key &&
                     install -o ductus -g ductus -m 644 "$RENEWED_LINEAGE/fullchain.pem" /etc/ductus/tls/mllp.pem'
    

    fullchain.pem is the certificate with its intermediates, as cert_file wants. Let's Encrypt only issues for names that resolve publicly (or through DNS validation): most interface engines' names are internal, so certbot is mostly for an internal ACME CA.

  • win-acme (Windows, ACME): its PEM files store writes the certificate and key into a folder (--store pemfiles --pemfilespath C:\ProgramData\Ductus\tls); name the certificate with its chain and the key in the entry (cert show says which file is which), and grant the service's account read access to the folder.

  • step (Smallstep's CLI, with step-ca or another ACME CA): step ca certificate mllp.hospital.org mllp.pem mllp.key once, then step ca renew --daemon mllp.pem mllp.key keeps renewing the same two files in place.

Certificates from secrets (cert/key) and CAs change with a deploy and a restart.

When it doesn't work

The engine (or the sender) saysIt meansDo
"the sender isn't speaking TLS"A plain MLLP or HTTP sender on a TLS portTurn TLS on at the sender, or point it at a plain port
"the receiver wants a client certificate"The receiver asks for mutual TLSName a pair in certificate:
"chain incomplete" (cert show, doctor)cert_file lacks the intermediatesAppend them after yours
"no TLS version or cipher in common"A sender on TLS 1.0/1.1Upgrade the sender, or a TLS proxy for it alone
A receiver's certificate "presents a CA's certificate (CA:TRUE) as its own"A lab receiver made with openssl req -x509Name that exact certificate in ca: (nuance)
"timed out waiting for ACK" right after turning tls: onThe receiver's port speaks plain MLLPAsk which port is TLS

To try a TLS listener or receiver before the real systems are ready, ductus gen --tls plays the sender and ductus sink --tls the receiver, checking certificates as the engine does and saying why a handshake fails (nuance).

ductus doctor checks every TLS connection the project makes, from the server, and names the step that fails.

People: users, tokens, the directory

Everyone who uses the web UI or the REST API is a person with an account (who signs in with a password, theirs or the directory's), or a token (for scripts and monitoring). Both have a role, and maybe extra permissions; everything either does that changes something is in the audit log, by name.

Roles and permissions

RoleCan
viewerSee interfaces, health, queues, messages' status and errors, alerts. Change nothing.
operatorWhat viewers can, and act: resend, replay, edit and dismiss messages, pause and resume, drain, run connection checks.
adminWhat operators can, and manage accounts and tokens; has every permission.

Two permissions go on top of a role, given only to the people who need them:

  • payloads: read message contents, which is usually patient data (and edit them, as an operator). Without it the message page shows status, destinations and errors, and says the payload is hidden. Every read is in the audit log, written before the payload is shown (nuance).
  • audit: read and export the audit log. For compliance and security staff, not operators (nuance).

Accounts

Local accounts are made on the server, with the command line (it works on the database directly, so it's also how the first admin is made, and how you get back in if locked out):

ductus user create ana --role admin --email ana@hospital.org --display-name "Ana Silva"
ductus user create ben --role operator --permission payloads --email ben@hospital.org --must-change
ductus user list

The password is asked for twice (--password-stdin for scripts); --must-change makes the person choose their own at the first sign-in. Admins also manage accounts on the web UI's Users page. Give everyone an --email: that's how owner texts (about.owner: ana@hospital.org) and alerts reach them.

  • Passwords are at least 12 characters (auth.password.min_length, never below 8), can't be the username, one character repeated or a common one; there's no rule for mixing kinds of characters (length matters more). Stored as argon2id hashes.
  • Lockout: five wrong passwords for one username within 15 minutes lock it for 15 minutes (auth.lockout); ductus user unlock ben or the Users page unlocks it at once (nuance).
  • Disable (ductus user disable ben, or the Users page) ends their sessions at once and stops every token of theirs. Accounts are never deleted: their history in the audit log stays theirs. The last admin can't be disabled or demoted.
  • Sessions in the browser last 12 hours unused and 7 days at most (auth.session).
# ductus.yaml, in part
auth:
  session: { idle: 8h, max: 1d }
  password: { min_length: 14 }
  lockout: { attempts: 5, window: 15m, for: 15m }
  token_sign_in: false
  user_tokens: true

All of it is under auth in ductus.yaml; the defaults suit most sites. Shorten session.idle where screens are shared or left unlocked.

Tokens

A token is for a script, a CI job or monitoring: Authorization: Bearer dux_… on every call.

ductus token create prometheus --role viewer            # printed once; stored only as a hash
ductus token create nightly-replay --role operator --permission payloads
ductus token list
ductus token revoke prometheus
  • People can make their own under Account in the web UI (auth.user_tokens, on by default): a person's token can do no more than they can, and stops when their account is disabled.
  • A token has no permissions unless it's given them: a monitoring token needs none.
  • auth.token_sign_in (on by default) lets the sign-in page take a token instead of a password: how the first people get in before accounts exist. Turn it off once everyone has an account, so a leaked script token can't also open the web UI.

Signing in with the directory

With a directory (Active Directory, OpenLDAP, FreeIPA), people sign in with their usual username and password, and the directory's groups decide their role. Two pieces: the directory itself in resources.yaml, and how it's used in ductus.yaml.

# resources.yaml, in part
ldap:
  corp-ad:
    url: "ldaps://hospital.org"          # the domain's name reaches any controller
    bind_dn: "CN=svc-ductus,OU=Service Accounts,DC=hospital,DC=org"
    bind_password: ${secret:LDAP_BIND_PASSWORD}
    ca: hospital-ca                      # the domain's CA, where this server doesn't trust it
# ductus.yaml, in part
auth:
  ldap:
    resource: corp-ad
    user_base: "OU=Staff,DC=hospital,DC=org"
    groups:
      admin: "CN=Ductus Admins,OU=Groups,DC=hospital,DC=org"
      operator:
        - "CN=Interface Team,OU=Groups,DC=hospital,DC=org"
        - "CN=Night Operators,OU=Groups,DC=hospital,DC=org"
      viewer: "CN=Help Desk,OU=Groups,DC=hospital,DC=org"
      permissions:
        payloads: "CN=Ductus PHI,OU=Groups,DC=hospital,DC=org"
        audit: "CN=Ductus Auditors,OU=Groups,DC=hospital,DC=org"
    refresh: 15m
  • The directory entry (ldap): ldaps:// (port 636), or ldap:// with starttls: true. Plain ldap:// is refused unless insecure: true, which is only for a test directory on the same machine: passwords would cross the network readable (nuance). The service account (bind_dn) needs read access to people and groups, nothing else; give it a password that doesn't expire, or note when it does: an expired one stops directory sign-ins.
  • Who may sign in: people under user_base, found by user_filter ((sAMAccountName={username}) by default; (uid={username}) for OpenLDAP and FreeIPA), and in at least one role group. The highest role their groups give wins; permissions groups add to it. Someone in no role group is told they have no access (nuance).
  • Write several groups one per line, as above, never [a, b]: DNs have commas (nuance).
  • Their account is made at their first sign-in, marked as a directory account; its role, permissions, name and email are the directory's, read-only here. An admin can still disable it.
  • Removal reaches Ductus within refresh (15 minutes by default): someone taken out of the groups, disabled or deleted in the directory loses their sessions and tokens at the next refresh. For someone who must lose access now, disable them on the Users page first (nuance).
  • A renamed person keeps their account (found by their directory id, objectGUID or entryUUID), and a sign-in name given to someone new gets a new account (nuance).
  • Nested groups: nested: true counts members of groups inside the configured ones. Prefer direct membership: one search per group, easy to audit (nuance).

Keep one local admin (ductus user create … --role admin), with a long password the on-call can reach: when the directory is down, directory sign-ins are refused ("the directory can't be reached right now") but local accounts and open sessions keep working (nuance). A name a local account holds is always checked locally, so the directory can't take it over.

Active Directory has not been tested against yet (the tests use OpenLDAP); its transitive-membership filter for nested groups is unit-tested, and the first pilot with a domain verifies it.

More on directory accounts:

  • A person deleted and made again in the directory is a new id there, so a new account here: it can't be linked to the old one, which is refused at the next refresh (nuance).
  • OpenLDAP follows a renamed person in their groups only with its refint overlay (nuance).
  • Wrong passwords count twice: towards Ductus's lockout and towards the directory's own (nuance).

Alerts and owners

The engine judges every interface and destination every 15 seconds. When one is in a state your rules name (stalled, behind, silent…), it tells people once, reminds them while nobody has acknowledged it, tells them again if it gets worse, and says when it's fine again. Nothing alerts until you write rules: until then, states show in the web UI only.

All of it is the alerts section of ductus.yaml, the one section read per environment (so test and production can mail different people, and webhook URLs can be secrets):

# ductus.yaml, in part
alerts:
  smtp:
    host: smtp.hospital.org
    from: ductus@hospital.org
    username: ductus@hospital.org
    password: ${secret:SMTP_PASSWORD}
  channels:
    oncall: { email: interfaces-oncall@hospital.org }
    chat: { webhook: { url: "${secret:TEAMS_WEBHOOK}", format: teams } }
  rules:
    - { when: [error, stalled, stopped], severity: critical, to: [oncall, chat] }
    - { when: [behind, silent, missed, certificate], severity: warning, to: [chat] }
  repeat: 4h
  quiet: { between: "22:00-06:00", severities: [warning] }
  heartbeat: { url: "${secret:HEARTBEAT_URL}", every: 5m }

Channels: where alerts go

A channel is a name rules send to: an email list, a chat webhook, or both.

  • Send to teams, not people: a team's mailing list or chat channel. People change jobs, go on leave and sleep; a team's list doesn't.
  • Email needs smtp: your organization's relay or submission server, verified by its certificate (tls: starttls on 587 by default, tls on 465, none only for a relay on the same network segment). Ask the mail team to let from through their filters.
  • Webhooks: format: teams (a Microsoft Teams Workflows webhook), slack (also Mattermost, Rocket.Chat), or json (every field, for your own incident tool). A webhook's URL is its own password, so keep it in a secret (nuance).
  • Try one without an engine: ductus alerts test oncall sends a test notification now.

Rules: what alerts, and how badly

Each rule names states (when), a severity, and channels (to). A state is matched by the first rule that names it; a state no rule names doesn't alert, and ductus validate says which.

StateMeansUsually
errorA destination has quarantined messages someone must look atcritical
stalledA destination has had messages waiting over a minute and sent none: its receiver is down, or a message keeps failingcritical
stoppedAn interface's code was stopped (it ran away, or ran out of memory)critical
behindA queue is past its monitor.behindwarning
silentNothing received within monitor.active_withinwarning, or critical for a core feed
missedNothing received by monitor.active_bywarning
certificateA certificate the engine presents or connects to nears expiry (30, 14, 7 days) or expired (always critical)warning

behind, silent and missed need an interface's monitor settings: nothing is expected until you say so. A paused destination never alerts (it's paused on purpose), and an alert that's open when someone pauses stays open, quietly, until it's resumed (nuance).

  • warning: worth knowing today; held during quiet hours. critical: act now; never held. A warning that becomes critical is told again. On Windows a critical alert is also in the Event Log (1020).
  • repeat (default 4h): while an alert is open and unacknowledged, its recipients are reminded this often; 0 never. Acknowledging it on the Alerts page stops the reminders until it gets worse or recovers. Acknowledging isn't fixing, and silencing isn't pausing (nuance).
  • settle (default 2m): how long a state must be fine before its alert is over, so a receiver flapping up and down is one alert, not dozens. recovery (default on) tells the recipients when it is.
  • quiet: hours when warnings wait until morning; one that comes and goes in the night is never sent, only kept in the history. Keep critical out of severities (nuance). The hours are the server's local time.
  • certificates.warn_days (default [30, 14, 7]): the steps before a certificate expires. Make the first long enough for your slowest renewal (a vendor's CA, a change board).

Owners as recipients

to: owner in a rule, or an interface's alert.to, also tells whoever owns the interface or destination: its owner, else about.owner (nuance). An owner who is an account is mailed at the account's email; one written as an address is mailed at it; a team without an account gets its address from owners:

# ductus.yaml, in part
owners:
  Lab Systems: { email: lab-systems@hospital.org }

Lead with team channels, and add owner where a person should hear directly (an interface one analyst built and looks after, a certificate one person renews). Owners change; a rule that sends only to owners goes quiet when one leaves, which is why ductus owners --check fails on an owner that reaches nobody (owners).

The heartbeat: when the engine itself is down

An engine that is down, or can't reach its database, can't send alerts. heartbeat calls a URL every every (default 5 minutes) while the engine runs, for a service elsewhere that raises its own alarm when the calls stop: healthchecks.io, Better Stack, Uptime Kuma, or a monitoring tool's push check. Set its grace period to about twice every. Set it in production: it's the only way anyone hears that the engine is gone (nuance).

What to know

  • Notifications are sent at least once: through an outbox in the database, retried until the channel takes them. A failing channel shows on the Alerts page, not as another alert (nuance).
  • After a start, states the engine hasn't watched for a minute yet don't alert, so a restart doesn't page everyone (nuance).
  • It costs little: 35 ms a round at 1,001 interfaces, measured (nuance).
  • History (what fired, who was told, who acknowledged) is kept a year (alerts.keep).
  • An interface can add recipients (alert: { to: [...] }) or be muted (alert: { mute: true }): keeping and watching.

Documentation fields

Every company keeps things about its interfaces that Ductus doesn't ask for: the business associate agreement, the change ticket that approved it, the analyst, a runbook, the go-live date. Documentation fields keep them beside the interface, in Git, checked like the rest, instead of in a spreadsheet nobody updates.

What you get: ductus validate reports a missing or wrong value; the web UI shows the fields on each interface, destination and resource's page and filters the interface list by them; ductus docs lists them (--missing for what's not filled in, --json for a report). What you don't: the engine ignores them entirely; a wrong value never stops anything running.

Declare them: docs-schema.yaml

At the top of the project, fields, by the key values will use:

# docs-schema.yaml
fields:
  baa_signed:
    type: enum
    values: ["yes", "no", n/a]
    required: true
    label: BAA signed
    help: Business associate agreement, from the contracts office
  change_ticket:
    type: text
    label: Change ticket
  analyst:
    type: person
    applies_to: [interface, destination]
  runbook:
    type: url
  go_live:
    type: date
  contract:
    type: text
    applies_to: [resource]
typeA value isExample
textAny text (quote numbers and yes/no, or YAML reads them otherwise)"CHG-1234"
enumOne of values, exactly"yes"
dateA date2026-10-30
urlAn http(s):// address, shown as a linkhttps://wiki.hospital.org/adt
personWho, best as an email addressana@hospital.org
listSeveral texts (each one of values, if given)[lab, billing]
  • required: true: every interface (or destination, or resource) the field applies to must have a value; ductus validate reports each missing one. The engine starts anyway.
  • applies_to: interface (the default), destination, resource (resources.yaml entries), or several.
  • label and help: what the web UI shows instead of the key, and a hint under it.

Fill them in: custom:

Values go in a flat custom: beside each one:

# interfaces/adt-to-lab/interface.yaml, in part
custom:
  baa_signed: "yes"
  change_ticket: CHG-1234
  analyst: ana@hospital.org
  runbook: https://wiki.hospital.org/interfaces/adt-to-lab
  go_live: 2026-11-15
destinations:
  lab:
    mllp: { host: ${LAB_HOST}, port: ${LAB_PORT} }
    custom: { analyst: ben@hospital.org }
# resources.yaml, in part
sftp:
  quest:
    host: ${QUEST_SFTP_HOST}
    user: hospital
    private_key: ${secret:QUEST_SFTP_KEY}
    custom: { contract: QUEST-2025 }

(custom on interfaces; the same key on destinations and on every resources.yaml entry.)

What to know

  • Checked by ductus validate, not by the engine: a wrong value is a validate error and nothing else (nuance). Run validate in CI and the fields stay filled.
  • The web UI shows them as of the engine's last start: it keeps a snapshot from the project it started with (nuance).
  • Read-only in the web UI: a change is a commit, reviewed like any other.
  • Keys are yours to choose, except names Ductus already uses beside custom (name, owner, vendor, …).

How interface code works

An interface's code is a TypeScript file in its folder, named by script in its interface.yaml. The engine calls one of its functions for each message and each destination, and sends on what the function returns. This page is how that works; the pages after it are the tasks: routing and filtering, mapping one message to another, formats, lookups and code tables and testing.

Say it in YAML first. Much of what interface code does in other engines is a setting here, checked by ductus validate and visible to ductus impact without reading any code. Write code for what's left:

You want toWithout codePage
Send each message to several places, each with its own queue and retriesdestinationsDestinations
Pass a feed into another team's interfacedestination.interface and source: internalRoutes and filters
Keep one patient's messages in orderordering.key: PID-3.1Delivery
Ignore a sender's repeat after a lost ACKdedupe.keys: [MSH-10]Delivery
Read CSV, fixed-width, JSON or XML; write another of themformat and outputFormats
Split a batch file into one message per recordsplit: records on a file sourceSources
Relay messages as they cameno script at allbelow

What needs code: deciding which messages a destination gets, changing fields, building a message in another shape, and looking things up. There is no field-mapping screen or mapping file: the mapping is code, and a mapping can still be written as a table in that code (transforms), reviewed in Git and tested like the rest.

What the engine does with each message

  1. A sender's message arrives and is stored in the database; only then does the sender get its acknowledgement. Nothing about your code delays or decides the ACK (except an HTTP source in respond mode, below).
  2. For each destination, in its own queue and order, a worker calls that destination's function with the message as it was received, parsed by the interface's format.
  3. What the function returns decides what happens, for that destination only:
The functionThe delivery isShown as
returns a message, an object or a stringsent: written in the destination's output format and deliveredsent
returns null (or nothing)filtered: never sent, kept with the message for auditfiltered
returns kill("reason")filtered, with the reason shown on the messagefiltered: reason
throws quarantine("reason")quarantined: held for a person to look at, resend, correct or dismissquarantined: reason
throws any other errorquarantined, with the error's textquarantined: error
meets a temporary failure (a database or API that's down, an HTTP 5xx, 408 or 429)waiting: tried again later, in order, with nothing sent meanwhile in its lanepending, with the error
  1. A message is finished when every destination is done with it: sent, filtered or quarantined. Its overall status is quarantined if any destination quarantined it, else sent if any sent it, else filtered.

So a function's job is to say what this destination should get, or that it gets nothing, or that a person must look. Never swallow a problem with return null: a message nobody can see again is the failure people find months later. Quarantine it, with a reason a person can act on (nuance).

The functions

// interfaces/adt-in/route.ts, in part
export function toLab(msg: Message) {
  if (!LAB_EVENTS.has(msg.MSH[9][2])) return null; // not for the lab: filtered, no reason needed
  if (msg.PID[3][1].startsWith("TEST")) return kill("test patient");
  • transform(msg, meta) is the function a destination calls unless it names another with transform:. With several destinations, export one function per destination (toLab, toBilling) and name each; one file can hold them all.
  • The first argument is the message parsed by format: a Message for HL7, an object or array for JSON, an XmlElement for XML, a record (or a list of them) for CSV and fixed-width, a string for text (formats).
  • The second, meta, is about this message: id (the stored message's id, the same on every retry and for every destination: give it to receivers that can ignore a repeat), receivedAt, destination (which destination this call is for) and origin (for a message routed from another interface, its id there).
  • A function may be async and await lookups (lookups). While one waits, the thread runs other messages.
  • What it returns is written in the destination's output format: a Message or an XmlElement as itself, an object as JSON, records as CSV or fixed-width lines, and a string as it is, whatever the format.
  • Without a script, every destination gets the message exactly as received.

What code may and may not do

  • It may only import project files and @ductus/sdk. No npm packages, no network or files of its own: databases, APIs, variables and secrets come through the SDK (db(), api(), fetch(), env(), secret()), so the engine can time them, retry them, mock them in tests and list them in ductus impact.

  • It can run more than once for one message: after a temporary failure, a restart in the middle, a resend or a replay (nuance). So it must give the same answer for the same message, and anything it writes elsewhere (db().execute, an API POST) must be safe to repeat: use meta.id as the key.

  • It shouldn't run long. A function that runs past timeout (5 s by default) without pausing is stopped, its message quarantined, and three in a row stop the interface (runaway scripts). Waiting on a lookup doesn't count.

  • Missing fields read as "" and never throw, so a typo in a field number gives "" quietly: test with real messages and check the fields you map (nuance).

  • timeout counts time the code runs, not time it waits on an await: a function waiting on a slow lookup isn't stopped for it (nuance). Code that runs away after an await interrupts every async call on its thread (nuance).

  • An import used only for what it does when it loads is removed by TypeScript: import something from it you use, or call a function it exports (nuance).

Code shared between interfaces

A file outside any interface folder, such as lib/codes.ts, can be imported by several interfaces (import { translate } from "../../lib/codes.ts"). Use it for code tables, mappings and helpers that more than one interface needs. Two rules (shared pool):

  • Keep it read-only: tables as const, functions that only use their arguments. A top-level let is one copy shared by every interface on that thread, and ductus validate warns about it (DX3).
  • A heavy loop belongs in the interface's own file: a runaway in a shared file after an await can't be blamed on one interface (nuance).

All interfaces in an engine share one global scope per thread, so a global set by one is seen by others (nuance), and a shared library file is one copy per thread, not one per project (nuance).

A new interface, and the editor

ductus new interface orders-in (with --source mllp|http|file|sftp|schedule and --format) writes a working interface: its interface.yaml, a transform.ts, a test and a sample message, passing ductus test and ductus validate as written. Start from there.

ductus test and ductus validate write the SDK's typings into .ductus/types and a tsconfig.json, so VS Code (or any editor that speaks TypeScript) completes msg.PID[ with the fields' names and underlines mistakes as you type. After an upgrade, run ductus types so the editor knows the new version (nuance).

The examples on these pages

The code on these pages is from a small project in the repository, examples/guides: ADT routed to a lab and to billing, results to a FHIR server, an X12 835 to CSV, a census file to HL7. A code block that starts with a file's path, such as // interfaces/adt-in/route.ts, is a piece of that file, and the docs' tests check that the page and the file agree and that the project's own tests pass. Copy it, run ductus test in it, and change it.

Routes and filters

Which messages go where. In Ductus a route isn't a separate rule table: every destination gets every message, and its own function decides whether to send it, change it, or drop it. One interface with three destinations is three independent decisions on the same message.

Filtering: null and kill

// interfaces/adt-in/route.ts, in part
/** The visit events the lab system takes. */
const LAB_EVENTS = new Set(["A01", "A03", "A04", "A08"]);

export function toLab(msg: Message) {
  if (!LAB_EVENTS.has(msg.MSH[9][2])) return null; // not for the lab: filtered, no reason needed
  if (msg.PID[3][1].startsWith("TEST")) return kill("test patient");
  • return null filters: this destination never gets the message, and nothing is said about why. Use it for the ordinary "not for this receiver" cases, the ones nobody will ask about: event types a receiver doesn't take, another facility's patients.
  • return kill("reason") filters with a reason, shown on the message in the web UI and kept in the database: "filtered: test patient". Use it for anything someone might later ask about ("why didn't the lab get this patient?"): test patients, a duplicate you recognized, a message outside a date range.
  • Filtering isn't an error. A filtered message is finished, raises no alert and is never retried. It's kept for the interface's retention, so the answer to "did we get it, and what did we do with it?" is always there.
  • When not to filter: when the message is wrong. A missing MRN, an unknown code, a timestamp you can't read: those are for a person, so throw quarantine("…") (quarantine). Filtering a broken message hides it.

A filter on a field's value is written in code, not configuration, so it can say exactly what it means (startsWith, a set of codes, a date compared with today) and be tested with real messages (testing).

Routing by content: one destination per receiver

# interfaces/adt-in/interface.yaml, in part
destinations:
  lab:
    mllp: { host: ${LAB_HOST}, port: ${LAB_PORT} }
    transform: toLab
  billing:
    interface: adt-to-billing       # a route: into that interface's queue
    transform: toBilling
// interfaces/adt-in/route.ts, in part
/** Billing wants admissions and discharges, as received. */
export function toBilling(msg: Message) {
  return ["A01", "A03"].includes(msg.MSH[9][2]) ? msg : null;
}

Each destination names its function with transform:. Both get the message as it was received: toLab changing the patient class and removing Z segments changes nothing for toBilling (nuance). So "route ADT to the lab, and admissions and discharges to billing" is two functions of a few lines, each tested on its own, and a message can go to both, either or neither.

What each destination has of its own, and why that's the point of routing this way:

  • Its own queue and order. The lab down for an upgrade holds up only the lab; billing keeps receiving (nuance).
  • Its own outcome. The lab can quarantine a message billing sent; the message shows both (nuance).
  • Its own pause, retries, rate limit and alerts (delivery).

When to split instead. Past a handful of destinations, or when destinations belong to different teams who change them on different days, give each receiver an interface of its own and route to it (below): each then has its own code, owner, tests and deploys (when to split).

To another interface

# interfaces/adt-to-billing/interface.yaml
name: adt-to-billing
about:
  description: Admissions and discharges from adt-in, as JSON encounters for the billing system
  owner: revenue-cycle-team
  to: { application: Billing, vendor: Experian }
source: internal                    # messages come only from another interface's route
script: billing.ts
ordering: { key: PID-3.1 }
output: json
destination:
  http: { api: billing, path: /encounters }

A destination with interface: adt-to-billing puts the message into that interface's queue, in the same database transaction that marks it sent from here. Nothing crosses a network, and nothing is lost or sent twice between the two (nuance). The receiving interface has source: internal and is an interface like any other: its own code, ordering key, dedupe, destinations, retries, owner and alerts (nuance). Its code sees meta.origin: the message's id in the interface it came from.

Use a route to:

  • Hand a feed to another team. The integration team owns adt-in (the connection to the EHR, the ACKs, the dedupe); revenue cycle owns adt-to-billing, its mapping and its alerts.
  • Chain steps that change at different speeds: a normalizing interface every downstream interface routes from, so a change of the EHR's quirks is made once.
  • Fan one feed out to many receivers without one interface carrying a dozen destinations.

What to know:

  • The route is fixed in interface.yaml. Code can't pick an interface at run time; it decides only whether this destination sends (null or the message). That's what lets ductus validate refuse a loop and check that the receiving interface reads the format this one writes, and ductus impact follow a vendor's messages through every hop (nuance).
  • Order holds across a route when the sending interface keeps it (an ordering key), and the receiving one has a key too (nuance).
  • A routed message waits up to 20 ms before the next interface sees it: nothing a feed notices (nuance).

Quarantine: for a person to look at

// lib/codes.ts, in part
export function translate(table: Readonly<Record<string, string>>, code: string, what: string): string {
  const value = table[code];
  if (value === undefined) throw quarantine(`unknown ${what} "${code}"`);
  return value;
}

throw quarantine("reason") holds this destination's delivery of the message for a person: it shows on the Quarantine page and in the interface's error count, alerts when your rules name error, and waits until someone resends it (after fixing the code or the table), corrects and resends it, or dismisses it (resend and correct). Write the reason for the person who will read it at 3 a.m.: what's wrong and with which value ("unknown patient class "X""), not "error".

Any other error your code throws (a TypeError from a field that isn't there, a failed JSON.parse) quarantines the message too, with the error's text. Prefer quarantine() for every case you can foresee: its reason is yours, not a stack trace.

Quarantine doesn't stop the queue. The next message for that destination goes on, so one bad message doesn't hold up the feed. Messages that wait instead, in order, are the ones that hit a temporary failure: a receiver or database that's down. Those are tried again until they go (nuance).

Routing by sender or event without code

Two cases often handled by routing rules elsewhere need none here:

  • Different senders on different ports: give each sender its own interface (its own source), so each has its own about.from, allow: list and alerts. Two senders on one port can't be told apart reliably by content.
  • A repeat of a message already received: dedupe on MSH-10 recognizes it before any code runs.

Transforms: mappings first, then code

A transform turns the message a sender sent into the one a receiver wants: a field moved, a code translated, a segment removed, or a whole new message in another format. This page goes from the smallest change to a full mapping, in the order you should reach for them.

Change the message in place

For most HL7 receivers, the message you send is the one you received with a few fields changed. Change it and return it:

// interfaces/adt-in/route.ts, in part
  msg.PV1[2] = translate(PATIENT_CLASS, msg.PV1[2], "patient class");
  for (const name of new Set(msg.segmentNames)) {
    if (name.startsWith("Z")) msg.remove(name); // the EHR's own segments mean nothing to the lab
  }
  msg.set("MSH-5", "LAB");
  return msg;
  • Only what you touch changes. Segments your code doesn't change are written out exactly as they arrived, character for character, so a receiver's validation sees what the sender sent.
  • Two ways to name a field: by position, msg.PV1[2] (with completion in the editor), or by path, msg.get("PV1-2") / msg.set("PV1-2", "IN"). Paths are handy when the field is data (a table, below); positions read better in code.
  • No copy needed. Each destination's function already gets its own message as received, so changing it can't affect another destination (nuance).
  • The reference for every way to read and write HL7 (components, repetitions, adding and removing segments, escaping) is Formats: HL7 v2.

A mapping as a table

When the receiver wants a different shape (JSON for an API, a flat record for a file), most of the work is "this field goes there". Write that part as a table: one row per field, which an analyst can read, review in a pull request and check against the receiver's spec line by line.

// interfaces/adt-to-billing/billing.ts, in part
/** The encounter the billing API takes, field by field. */
const ENCOUNTER: FieldMap = [
  ["PID-3.1", "patient.mrn"],
  ["PID-5.1", "patient.family"],
  ["PID-5.2", "patient.given"],
  ["PID-7", "patient.birthDate"],
  ["PV1-19", "visit.number"],
  ["PV1-3.1", "visit.unit"],
  ["PV1-44", "visit.admitted"],
  ["PV1-45", "visit.discharged"],
];

export async function transform(msg: Message, meta: Meta) {
  const encounter = mapFields(msg, ENCOUNTER);
  encounter.event = msg.MSH[9][2] === "A03" ? "discharge" : "admit";

The table is plain data; the few lines that apply it are a helper in the project's lib/, shared by every interface that maps this way:

// lib/mapping.ts, in part
export function mapFields(msg: Message, map: FieldMap): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const [from, to] of map) {
    const value = msg.get(from);
    if (value !== "") place(out, to, value);
  }
  return out;
}
  • The table does the predictable part; code does the rest: the event that depends on the trigger, the NPI looked up in a database (lookups), the id that makes a repeat harmless. Nothing forces every field through the table.
  • Empty fields are left out (mapFields skips ""), so a receiver sees a missing discharge time as missing, not as an empty string. If your receiver wants null or "" instead, change the helper once.
  • HL7 to HL7 works the same way: copyFields(from, to, [["PID-3.1", "PID-2.1"]]), also in lib/mapping.ts, copies between two messages by path.
  • Test the table with a whole message and compare the whole result (testing): a wrong row is then a failing test, not a receiver's complaint.

Ductus has no mapping file or mapping screen of its own: the table is code, kept with the interface in Git. That's deliberate for now (one place to look, one way to test, type-checked by ductus validate); typed message definitions that underline a wrong field in the editor are on the roadmap (v1.5).

Codes from one system to another

A sending system's codes are rarely the receiver's: patient classes, sexes, result statuses, units, locations. Keep each translation as a code table and translate through one function that quarantines a code it doesn't know instead of passing it on or guessing:

// lib/codes.ts, in part
/** PV1-2 patient class, from the EHR's codes to the lab system's. */
export const PATIENT_CLASS: Readonly<Record<string, string>> = {
  I: "IN",
  O: "OUT",
  E: "ER",
  P: "PRE",
  R: "OUT", // recurring patients are outpatients to the lab
};

A new code at the sender then shows up as quarantined messages with "unknown patient class "B"", the day it starts, instead of as wrong data in the receiver weeks later. Add the row, deploy, and resend the quarantined messages. Where tables live (in code, or in a database the business maintains) and how to keep a database lookup fast: Lookups and code tables.

A new message from scratch

When the receiver's message has little in common with the sender's (a CSV row becoming HL7, an HL7 result becoming FHIR), build the new one and return it:

// interfaces/census-in/census.ts, in part
export function transform(row: CsvRecord, meta: Meta) {
  if (!row.mrn) return kill("a row without an MRN");
  const now = hl7Time(new Date(meta.receivedAt));
  const msg = parseHl7(`MSH|^~\\&|BEDBOARD|HOSP|EHR|HOSP|${now}||ADT^A08^ADT_A01|BB${meta.id}|P|2.5`);
  msg.add("EVN", ["A08", now]);
  msg.add("PID");
  msg.set("PID-3.1", row.mrn);
  msg.set("PID-5.1", row.last);
  msg.set("PID-5.2", row.first);
  • Start an HL7 message from its MSH with parseHl7 (in a TypeScript string, the escape character \ is written \\), then add segments and set fields; a field set past the end of a segment pads it with empty fields.
  • Times and ids from meta, not the clock: meta.receivedAt and meta.id are the same every time this message is processed, so a retry, a resend or a replay produces the same message (and MSH-10 BB501 the second time lets a receiver that dedupes drop the repeat). new Date() would give a different message each time.
  • An object returned to a JSON destination is the request body as it is; there's an example in Formats: FHIR.

When it's more than a mapping

Everything above is ordinary TypeScript, so anything a mapping table can't say is a few lines more: a loop over every OBX, a value computed from two fields, a lookup, a decision on today's date. Keep these rules:

  • Same message, same answer. No Math.random, no new Date() in what you send, nothing kept from one message to the next except a bounded cache (nuance).
  • Say why a message can't be handled: throw quarantine("…") with what's wrong and which value, never return null for a broken message (routes and filters).
  • Ask once for many values: a lookup per OBX in a loop is a round trip per OBX (nuance).
  • Big messages: build objects with every property in the same order, and let per-message objects go at the end (nuance).
  • Keep it fast: a function running past timeout (5 s by default) without waiting on anything is stopped and its message quarantined. For most HL7 transforms 500ms is plenty.

Formats: HL7 v2, FHIR, X12, CSV, JSON and XML

An interface's format says what its messages are, so your code gets them parsed; output (on the interface or on one destination) says what your code's results are written as. Both default to hl7.

FormatYour code getsReturnWritten as
hl7a Messagethe Message, or a stringHL7, segments ending in \r
jsonthe parsed value (object, array…)an object, array or stringJSON
xmlan XmlElement, the document's rootan XmlElement, or a stringXML
csvrecords, objects by column name (or one record with split: records)a record or a list of themCSV with a header row
fixedrecords by the layout's field names (or one)a record or a listfixed-width lines
textthe payload as a stringa stringas it is

A string returned is always sent as it is, whatever the output format: the way out when a receiver wants something no format writes. An MLLP source always carries HL7.

Converting is the two settings together: an HL7 feed to an API is format: hl7 (the default) with output: json; a CSV file to HL7 is format: csv with output: hl7. Each destination can set its own output, so one HL7 feed can go out as HL7 to the lab and JSON to billing.

FHIR and X12 have no format of their own yet: FHIR is JSON, and X12 is read as text with a few lines of code. Both are below, with what's planned.

HL7 v2

// interfaces/adt-in/route.ts, in part
export function toLab(msg: Message) {
  if (!LAB_EVENTS.has(msg.MSH[9][2])) return null; // not for the lab: filtered, no reason needed
  if (msg.PID[3][1].startsWith("TEST")) return kill("test patient");

Numbering is HL7's, 1-based everywhere: msg.PID[3][1] is PID-3.1, msg.MSH[9][2] is MSH-9.2 (the trigger event). MSH is counted as the standard counts it: MSH-1 is the field separator itself, so MSH-9 is the message type, as in every spec.

ToWriteGives
Read a fieldmsg.PV1[2], msg.get("PV1-2")"I" (text)
Read a componentmsg.PID[5][1], msg.get("PID-5.1")"SMITH"
A composite field's whole textString(msg.PID[5]), msg.PID[5].value"SMITH^ANNA^M"
Every occurrence of a segmentfor (const obx of msg.OBX)each OBX, in order
The n-th occurrencemsg.OBX.rep(2), msg.OBX.countthe second OBX; how many
Repetitions of a fieldfor (const id of msg.PID[3]), msg.PID[3].rep(2), .counteach PID-3 identifier
Whether a segment is theremsg.PV1.existstrue / false
Segment names in ordermsg.segmentNames["MSH", "EVN", "PID", "PV1"]
Set a field or componentmsg.PV1[2] = "IN", msg.set("PID-5.1", "SMITH")
Add a segmentmsg.add("ZDU", ["value"]), msg.insertAfter("PID", "PD1")the new segment
Remove segmentsmsg.remove("ZPV"), msg.NTE.remove()every segment with that name
The whole messagemsg.toString()the HL7 text
  • Indexing goes deeper, iterating goes across: msg.OBX[5] is OBX-5 of the first OBX; for (const obx of msg.OBX) visits each one. Most "only the first result was mapped" bugs are this.
  • Missing reads as "" and never throws: a segment, field or component that isn't there is "", so code needs no null checks, but a wrong position is a quiet "". Test the fields you map (nuance).
  • Writing into a segment the message doesn't have throws ("message has no PV1 segment; add it first with msg.add("PV1")"): a missing segment is usually a mistake worth seeing.
  • Simple fields read as text, composite ones as fields. PV1-2 (a code) is a string; PID-5 (a name, with components) is a field you index into. The editor knows which is which for the common segments of HL7 2.5 (MSH, EVN, PID, NK1, PV1, ORC, OBR, OBX, NTE, AL1, DG1, MRG, MSA, ERR) and shows each field's name; any other segment, Z segments included, works the same way with numbers.
  • Escaping is done for you. Reading decodes \F\, \S\, \T\, \R\ and \E\; writing a component or simple field encodes the delimiters in it. A composite field set as a whole (msg.PID[5] = "SMITH^ANNA") is taken as HL7, separators and all.
  • MSH-1 and MSH-2 are read-only: the delimiters are the sender's, and the message is written with them.
  • Untouched segments go out exactly as they came, so a message relayed with one field changed differs from the original in that field only.
  • The ACK isn't your code's job. The engine answers the sender (AA once the message is stored) and reads the receiver's ACK (AA delivered, AE retried, AR quarantined by default: destinations).
  • Batch files (many messages in one file) are split at each MSH into one message each by a file source with split: records (sources).

FHIR

FHIR R4 resources are JSON, so a FHIR interface is a JSON one: format: json for resources coming in, output: json for resources going out, and an HTTP destination (or api()) to the FHIR server. Here, each lab result (an HL7 ORU) becomes a FHIR transaction Bundle with one Observation per OBX:

# interfaces/results-to-fhir/interface.yaml
name: results-to-fhir
about:
  description: Lab results as FHIR R4 Observations, posted to the FHIR server as one transaction each
  owner: integration-team
  from: { application: Lab system }
  to: { application: FHIR server }
source: { mllp: { port: 6662 } }
script: to-fhir.ts
ordering: { key: PID-3.1 }
output: json
destination:
  http: { api: fhir, path: / }      # a transaction Bundle is posted to the server's base URL
// interfaces/results-to-fhir/to-fhir.ts, in part
/** An ORU^R01 as a FHIR R4 transaction Bundle: one Observation per OBX, for the patient by MRN. */
export function transform(msg: Message) {
  const mrn = msg.get("PID-3.1");
  if (!mrn) throw quarantine("a result without an MRN (PID-3.1)");
  const order = msg.get("OBR-3.1"); // the lab's own order number
  const subject = { reference: `Patient?identifier=${MRN_SYSTEM}|${mrn}` };

  const entry = [];
  for (const obx of msg.OBX) {
    const id = `${order}-${obx[1]}`; // the same on every resend, so the server can tell
    entry.push({
      resource: observation(obx, id, subject),
      request: { method: "POST", url: "Observation", ifNoneExist: `identifier=${RESULT_ID_SYSTEM}|${id}` },
    });
  }
  if (entry.length === 0) throw quarantine("a result with no OBX segments");
  return { resourceType: "Bundle", type: "transaction", entry };
}

What it shows, and why each part is there:

  • A delivery can happen twice (a lost answer, a resend), so each Observation carries an identifier made from the result itself and is created with ifNoneExist: the server creates it once and answers the repeat with the one it has (nuance). Every FHIR create should have such a key.
  • The patient is found by MRN with a conditional reference (Patient?identifier=…|123456), which the server resolves inside the transaction: no lookup from your code first.
  • A transaction is all or nothing on the server: if one Observation is refused, none is stored, and the server's 4xx quarantines the message with its answer.
  • FHIR's times need an offset whenever they have a time of day. HL7 v2 times often have none; guessing the server's offset is wrong half the year, so the example quarantines a time without one ("ask the sender to send offsets" is the fix). If your sender can't, convert with the site's time zone rules deliberately, in one helper, and test both sides of a daylight-saving change.
  • The answer: a 2xx is delivered; 408, 429 and 5xx are tried again; other 4xx quarantine the message with the server's answer (an OperationOutcome saying which element was wrong) for a person to read (destinations).
  • Content type: JSON goes out as application/json, which FHIR servers accept. One that insists on application/fhir+json gets it from the destination's headers, which win over the default: http: { api: fhir, path: /, headers: { Content-Type: application/fhir+json } }.
  • Tokens: the FHIR server's base URL and token live on its apis: entry in resources.yaml, never in code (resources). Servers that want OAuth2 client credentials or SMART backend services (Epic, Cerner): the engine doesn't fetch those tokens yet (planned with OAuth2 for outbound APIs); until then, a token your organization issues and renews into the secret.

Reading FHIR from a server is api("fhir").get("/Patient?identifier=…") in any interface's code (lookups); receiving FHIR (a subscription's notifications, an app's POST) is an HTTP source with format: json. There are no FHIR typings or resource validation in the SDK yet: your code works with plain objects, and the server's validation is the check. FHIR typings and helpers are planned for v1.5, a FHIR client and server connector for v1.6.

X12 (EDI)

X12 files (835 remittances, 837 claims, 270/271 eligibility) are read as format: text, and a few lines in lib/ split them into segments and elements:

// lib/x12.ts, in part
/** Split an X12 interchange into its segments, with the separators its ISA segment declares. */
export function parseX12(text: string): X12Segment[] {
  const data = text.replace(/^/, "").trimStart();
  if (!data.startsWith("ISA") || data.length < 106) throw quarantine("not an X12 interchange: it must start with ISA");
  const element = data[3]; // the character right after "ISA"
  const segment = data[105]; // ISA is fixed-length: its terminator is the 106th character
// interfaces/remits-in/remits.ts, in part
  for (const seg of parseX12(text)) {
    if (seg.id === "TRN") trace = seg.e(2);
    else if (seg.id === "N1" && seg.e(1) === "PR") payer = seg.e(2);
    else if (seg.id === "CLP") {
  • Read the separators from ISA, never assume * and ~: trading partners use |, ^ and others, and ISA is fixed-length precisely so its characters can be found by position.
  • Elements are numbered as X12 numbers them: seg.e(1) is CLP01.
  • Loops are code: X12's structure (the 2100 claim loop, its NM1 patient) is found by walking the segments in order, as the example does for each claim's patient.
  • What isn't there yet: acknowledgements (TA1, 999) aren't produced for you; an X12 format with loops and typed segments is planned (v1.6). Until then, send a 999 back as its own destination if your trading partner needs one.

The whole interface turns an 835 into one CSV row per claim, for the billing office: examples/guides/interfaces/remits-in (CSV below for the output).

CSV and fixed-width

# interfaces/census-in/interface.yaml
name: census-in
about:
  description: The bed board's nightly census file, each row an ADT A08 for the EHR
  owner: integration-team
  from: { application: Bed board }
  to: { application: EHR, vendor: Epic }
source:
  file: { dir: inbound, pattern: "census_*.csv", split: records }   # each row is a message
format: csv
script: census.ts
output: hl7
destination: { file: { dir: out } }
  • Records are objects by column name (row.mrn), from the header line. Without one, format: { csv: { header: false } } gives arrays of strings; another delimiter is format: { csv: { delimiter: "|" } }. Quoted fields with delimiters, quotes and line breaks inside are read as RFC 4180 says.
  • One message per file, or per row: without split, the whole file is one message and transform gets every record (an array); with split: records, each row is a message of its own (the header kept with each), with its own status, retries and quarantine, and transform gets one record. Split files whose rows are separate things (patients, orders); keep whole files that only mean something together (a batch the receiver must get complete).
  • Writing CSV: return a record or a list of records with output: csv; columns are written in the order first seen, with a header row. toCsv and parseCsv from the SDK do the same in your own code.
  • Fixed-width layouts name each field's start column (0-based) and width (format: { fixed: { fields: [{ name: mrn, start: 0, width: 10 }, …] } }); reading trims the padding unless trim: false, writing pads (align: right, pad: "0" for numbers) and cuts values that are too long. Cutting is silent: check lengths in code when a cut value would be wrong (a name is fine shortened, an account number isn't).

JSON

format: json gives your code the parsed value; return an object (or array) and it's sent as JSON. A file of JSON Lines (one document per line) with split: records is one message per line. Numbers are JavaScript's: integers past 2^53 (some systems' ids) lose digits when parsed, so ask for them as strings or read them with format: text and parse yourself.

XML

// interfaces/orders-xml/orders.ts
import { quarantine, type XmlElement } from "@ductus/sdk";

/** A home-care equipment order for the supplier: the patient's MRN checked, the SSN taken out. */
export function transform(doc: XmlElement) {
  const mrn = doc.get("Patient/Identifiers/MRN"); // text at a path, "" if missing
  if (!mrn) throw quarantine("an order without Patient/Identifiers/MRN");
  doc.find("Patient")?.remove("SSN"); // remove child elements by name
  doc.set("Patient/Status", "verified"); // creates missing elements
  doc.set("@source", "ductus"); // an attribute, on the root
  const second = doc.get("Items/Item[2]/@sku"); // the second Item's attribute, 1-based
  if (second) doc.add("Note", `more than one item; the second is ${second}`);
  return doc;
}
  • Paths are relative to the element, with / between names, [n] for the n-th of a name (1-based) and @name for an attribute as the last step. find gives the element, elements(name) all children with a name, add(name, text) a new child.
  • Namespaces are part of the name: an element written <hl7:patient> is hl7:patient in a path, prefix and all. The prefix is the document's, so if a sender can change its prefixes, compare local names (el.name.split(":").pop()).
  • The <?xml …?> declaration is kept; comments and processing instructions are dropped, and whitespace between elements is formatting, not content.
  • An XML file is always one message: split: records doesn't split XML.

Text

format: text gives the payload as a string and sends a string back as it is: for formats Ductus doesn't parse (X12 above, a vendor's own layout), and for relaying anything unchanged.

Lookups and code tables

Interface code often needs something the message doesn't carry: a provider's NPI, a patient's identifier in another system, the code a receiver uses for a location. This page is how to look those up, where code tables should live, and what happens when the thing you ask is down.

Interface code has no network of its own. Everything goes through five SDK functions, so the engine can time each call, retry what's temporary, mock it in tests and list it in ductus impact:

FunctionForDefined in
db("name")A Postgres or SQL Server databasedatabases: in resources.yaml
api("name")An HTTP API with its URL, token and TLS set onceapis: in resources.yaml
fetch(url)Any other URLthe code (URL from env())
env("NAME")A value that differs per environment (a URL, a facility code)environments/<env>.yaml
secret("NAME")A password or token your code must hold itselfductus secret set NAME

Write the name out: db("provider_db"), not db(name). ductus validate then checks the database exists, and ductus impact knows this interface uses it; a name in a variable gets a warning (DX4) because neither can see it (nuance).

A database lookup

// interfaces/adt-to-billing/billing.ts, in part
// Providers' NPIs by the EHR's id, kept for an hour: they rarely change, and messages come often.
const npis = new Map<string, { npi: string; until: number }>();

async function npiOf(ehrId: string): Promise<string> {
  const known = npis.get(ehrId);
  if (known && known.until > Date.now()) return known.npi;
  const row = await db("provider_db").one<{ npi: string }>("SELECT npi FROM providers WHERE ehr_id = $1", [ehrId]);
  if (!row) throw quarantine(`provider ${ehrId} has no NPI in provider_db`);
  npis.set(ehrId, { npi: row.npi, until: Date.now() + 60 * 60 * 1000 });
  return row.npi;
}
  • query(sql, params) gives rows as objects, one the first row or null, execute the number of rows a statement changed.
  • Always parameters, never text pasted into SQL: $1, $2… on Postgres and SQL Server alike. Values from a message are whatever the sender sent.
  • SQL Server sends text parameters as nvarchar: compared with an indexed varchar column, that can turn an index seek into a scan. Cast it: WHERE ehr_id = CAST($1 AS varchar(20)). One statement and one result set per call (nuance).
  • Not found is a decision. Here an unknown provider is quarantined, because billing can't bill without the NPI. For an optional field, leave it out instead. Never send a guess.
  • Many values, one query: looking up every OBX's code one at a time is a round trip each. Collect them and ask once (an array parameter is jsonb in Postgres) (nuance).
  • Writes must be safe to repeat. A message's code can run again (a retry, a resend), so an execute that inserts uses the message's meta.id as a key, or an upsert.
  • The login, host and TLS of the database are in resources.yaml, with the password in a secret; the code never sees them.

Caching

The lookup above keeps each answer for an hour in a Map at the top of the file. Do that for anything that changes rarely and is asked often (providers, locations, a code list); a feed of 100,000 messages a day then makes a few hundred queries, not 100,000.

  • A cache lives in one isolate: each worker thread has its own copy, and it's empty after a restart (nuance). Fine for a cache; never use it for anything that must be counted or remembered.
  • Bound it: keyed by something with a known number of values (providers), with a time limit. A cache keyed by patient grows with every patient until the engine restarts and counts toward the isolate's memory (nuance).
  • Choose the time by how wrong a stale answer is: an NPI an hour old is fine; a patient's current insurance probably isn't.

An API lookup

// lib/empi.ts, in part
/** The patient's enterprise id from the EMPI, by MRN. */
export async function enterpriseId(mrn: string): Promise<string> {
  const r = await api("empi").get(`/patients/${encodeURIComponent(mrn)}`);
  if (r.status === 404) throw quarantine(`the EMPI doesn't know MRN ${mrn}`);
  return r.json().enterpriseId;
}
  • api("name") sends to a path under the API's URL from resources.yaml, with its headers (the token) and TLS added by the engine: credentials never pass through your code. Prefer it to fetch() for anything with a token (nuance).
  • fetch(url, options) for a URL of its own: method, headers, body (an object is sent as JSON), query, timeout (ms, default 30 s).
  • Temporary failures wait and retry by default: a network error, a timeout, 408, 429 or a 5xx makes the message wait (in order) and try again later, so an API that's down for ten minutes costs ten minutes, not a quarantine (nuance). Every other status comes back to your code to decide: check r.status or r.ok.
  • retry: false gives you every status and turns network errors into ordinary errors (which quarantine unless you catch them): for a call whose failure shouldn't hold the message up, such as an optional enrichment.
  • r.json() and r.text() need no await: the body is already read.

Variables and secrets in code

env("FACILITY") reads a variable from the environment's file, and secret("NAME") a secret; both throw if it isn't defined (so a missing value is a quarantine with its name, not an empty string sent on). Prefer settings to code: a URL, a host or a token used by a connection belongs in resources.yaml or interface.yaml (${NAME}, ${secret:NAME}), where ductus validate checks each environment has it. Use env() for values only code uses (a facility code to stamp, a cut-off date).

Code tables: where they should live

A code table translates one system's codes into another's. There are three good places for one, and the right one depends on who changes it and how often:

WhereBest whenHow
In code, a const in lib/It changes with the interface, a few times a year, by the integration team; it's small (up to a few thousand rows)lib/codes.ts, reviewed in Git, tested with the interface, deployed with it
In a database the business maintainsAnother team changes it often (locations, chargemaster codes), or it's largedb("ref_db") with a cache, as above
Behind an API (a terminology server)The organization has oneapi("terminology") with a cache
// lib/codes.ts, in part
/**
 * The code's translation in `table`, or quarantine the message: an unknown code is for a person
 * to look at (add it to the table, or fix it at the source), never something to guess.
 *
 *   msg.PV1[2] = translate(PATIENT_CLASS, msg.PV1[2], "patient class");
 */
export function translate(table: Readonly<Record<string, string>>, code: string, what: string): string {
  const value = table[code];
  if (value === undefined) throw quarantine(`unknown ${what} "${code}"`);
  return value;
}

Whichever you choose:

  • An unknown code quarantines. It's the first sign a sender added a code, and a person fixes it in minutes: add the row, then resend the quarantined messages. Passing the code on unchanged, or a default, puts wrong data in the receiver without anyone knowing.
  • A table in code is immutable: Readonly<…> and const, in a shared file every interface imports (shared code).
  • A table in a database needs the cache's time to match how fast a change must show: an hour, or a restart, after someone edits it. Say so to the team that maintains it.
  • Test the table: one test per interesting code, and one for an unknown one (testing).

There's no code-table screen or upload in Ductus today; a shared reference-data cache across interfaces is planned (v1.4). Until then the table in Git, or a database table the business already maintains, is the way.

Lookups in respond mode

An HTTP source in respond mode answers the caller from your code, so lookups there are on the caller's clock. Calls that don't depend on each other should run together: const [a, b] = await Promise.all([db("x").one(…), api("y").get(…)]), not one after the other (nuance).

Testing with fixtures

ductus test runs every interface's *.test.ts files inside the real engine's JavaScript runtime, with the SDK the engine uses, and no database, network or running engine. Tests are how you know a mapping does what the receiver's spec says before a message reaches them, and how the next person changing it knows they didn't break it.

$ ductus test
adt-in
  ✓ the lab gets visits, with its own patient class and no Z segments (1ms)
  ✓ the lab doesn't get patient updates (A31) (0ms)
  ✓ test patients are dropped with a reason (0ms)
  ✓ an unknown patient class is quarantined, not guessed (1ms)
  ✓ billing gets admissions and discharges only (0ms)
adt-to-billing
  ✓ an admission becomes an encounter, with the attending's NPI (1ms)
  ✓ the NPI is looked up once an hour, not once a message (0ms)
  ✓ a provider the database doesn't know is quarantined (1ms)
…
17 passed, 0 failed

It exits 1 when a test fails, so it belongs in the checks every change goes through (a pull request's CI, a script before deploying), next to ductus validate --env prod, which type-checks the code and checks the configuration against that environment. --interface adt-in runs one interface's tests.

A test

// interfaces/adt-in/route.test.ts, in part
test("the lab gets visits, with its own patient class and no Z segments", () => {
  const out = toLab(hl7("a01.hl7")) as Message;
  expect(out.get("PV1-2")).toBe("IN");
  expect(out.get("MSH-5")).toBe("LAB");
  expect(out.segmentNames).toEqual(["MSH", "EVN", "PID", "PV1"]);
});
  • A test file sits next to the code (route.test.ts beside route.ts), imports the functions it tests, and registers tests with test(name, fn). The name is a sentence saying what should happen: it's what a failing run prints.
  • Tests run one at a time, in the order written; an async test is awaited.
  • Name what the receiver cares about: a test per rule in the spec ("the lab doesn't get A31s"), not one per function.

Fixtures: real messages, without the patient

A test's messages are files in the interface's fixtures/ folder:

Read withGives
fixture("a01.hl7")the file's text
hl7("a01.hl7")the file parsed as an HL7 message
input("order.xml")the file parsed the way the engine parses this interface's input (its format), ready to pass to transform
  • Start from real messages. Take a few of each kind the sender sends (each event type, the odd ones: a merge, a result with 40 OBX, a name with an apostrophe) from the sender's test system or a message the engine has stored.
  • Never commit a real patient. Fixtures live in Git, read by everyone with the repository. Replace names, MRNs, dates of birth, addresses and identifiers with made-up ones before saving the file; keep the structure (how many repetitions, which fields are empty, the sender's quirks), which is what the test needs.
  • Fixtures can be read only inside a test, not when the test file loads: put hl7("a01.hl7") inside test(...), or in a function the test calls (nuance).
  • With split: records, input() gives the fixture's first record, as the engine gives transform one record per message. For a file of several rows, parse it yourself and pick: parseCsv(fixture("census_20261031.csv"))[1] (nuance).
  • Change a fixture in the test for a variation instead of keeping a file per case:
// interfaces/adt-in/route.test.ts, in part
test("an unknown patient class is quarantined, not guessed", () => {
  const msg = hl7("a01.hl7");
  msg.set("PV1-2", "X");
  expect(() => toLab(msg)).toThrow('unknown patient class "X"');
});

Checking results

AssertionPasses when
expect(x).toBe(y)x is y (the same value: strings, numbers, true)
expect(x).toEqual(y)x and y have the same contents (objects and arrays, deeply)
expect(x).toBeNull()x is null: the destination filters the message
expect(x).toBeTruthy()x is anything but null, undefined, false, 0 or ""
expect(x).toContain(y)a string contains y, or an array has it
expect(() => f()).toThrow("text")calling it throws an error whose message contains the text: a quarantine
await expect(promise).toReject("text")the same for async code
expect(x).not.toBe(y)any of the above, reversed

An HL7 field passed to expect is compared by its text, so expect(msg.PID[5]).toBe("SMITH^ANNA^M") works.

Compare the whole result

For a mapping, compare the whole output with toEqual, not a field or two: a row added to the mapping, or a field that's suddenly empty, then fails the test.

// interfaces/adt-to-billing/billing.test.ts, in part
test("an admission becomes an encounter, with the attending's NPI", async () => {
  const calls = mock.db("provider_db", () => [{ npi: "1093817465" }]);
  const out = await transform(hl7("a01.hl7"), meta);
  expect(out).toEqual({
    patient: { mrn: "123456", family: "SMITH", given: "ANNA", birthDate: "19800214" },
    visit: { number: "V9001", unit: "4W", admitted: "20261031083000-0500" },
    event: "admit",
    attendingNpi: "1093817465",
    sourceMessage: 41,
  });
  expect(calls[0].params).toEqual(["1234"]);
});

Filters, kills and quarantines

  • Filtered: expect(toLab(hl7("a31.hl7"))).toBeNull().
  • Killed with a reason: the result is a Kill; check its reason:
// interfaces/adt-in/route.test.ts, in part
test("test patients are dropped with a reason", () => {
  const out = toLab(hl7("test-patient.hl7"));
  expect(out instanceof Kill && out.reason).toBe("test patient");
});
  • Quarantined: toThrow (or toReject for async code) with part of the reason, as above. Test every quarantine you wrote: it's the behaviour someone relies on at 3 a.m.

meta

A function that reads meta gets one from the test: a plain object with the fields it uses.

// interfaces/adt-to-billing/billing.test.ts, in part
const meta: Meta = { id: 41, receivedAt: "2026-10-31T13:30:00Z", destination: "default", origin: 7 };

Mocks: databases, APIs, variables and secrets

Tests never reach a real database or API. A call your test didn't mock fails with what to add ("db("provider_db") is not mocked in this test: add mock.db("provider_db", (sql, params) => [/* rows */])"), so a test can't quietly depend on a system being up.

MockAnswersReturns
mock.db("name", rows) or mock.db("name", (sql, params) => rows)db("name").query/one/execute (execute takes a number, or rows' count)the calls made: { sql, params }
mock.api("name", { status, body }) or a function of the requestapi("name") calls; the request's url is the paththe requests made
mock.fetch({ status, body }) or a functionfetch() callsthe requests made
mock.env({ NAME: "value" })env("NAME")
mock.secret({ NAME: "value" })secret("NAME")
  • Mocks last one test: each test starts with none, so tests don't depend on each other's. What your own code keeps (a module-level cache) does last, as it would in the engine: the caching test above uses a provider id no other test uses.
  • Check what was asked, not only what came back: calls[0].params is the MRN your code sent; calls.length shows a lookup made once, not per message.
  • Answer like the real system, including its failures: a 404, an empty result, the odd shape it sends for a merged patient.
// interfaces/adt-to-billing/empi.test.ts, in part
test("an MRN the EMPI doesn't know is quarantined", async () => {
  mock.api("empi", { status: 404 });
  await expect(enterpriseId("999")).toReject("the EMPI doesn't know MRN 999");
});

A mocked temporary failure (a 503) makes your code's call throw the same temporary error the engine would wait on and retry; tests usually check the answers your code decides on instead.

Beyond unit tests

  • ductus validate type-checks every interface and checks its configuration and resources for one environment: run it for each environment you deploy to.
  • ductus replay runs a real period's stored messages through a changed version of the code and compares every destination's output with what the installed version sent, before you deploy: the test against real traffic that fixtures can't be (replay).
  • ductus gen and ductus sink send synthetic ADT and receive it, to try a whole project on your laptop with ductus dev (first interface).

Running Ductus day to day

These pages are for whoever keeps the interfaces running: the on-call analyst, the integration team, the operations center. They assume the engine is installed (install) and the project deployed; what each setting means is in Configure.

Where to look

ForPage
The web UI (the console)Everything a person does: health at a glance, messages, quarantine, resend, pause, alertsThe console
The ductus commandThe same from a terminal or a script, and what needs the server (doctor, the audit export, users and tokens)The CLI day to day
The REST APIYour own tools and runbooks; everything the console doesREST API reference
PrometheusGraphs and your monitoring system's own alertsMonitoring and metrics
LogsWhat the engine did and why, line by lineLogs
AlertsBeing told, so nobody has to watchAlerts in practice

The console, the CLI and the API are three ways into the same engine: a pause made in one shows in the others, and every change, whoever makes it and however, is in the audit log with who made it.

What a normal day looks like

  • The Overview is green, or shows exactly what isn't: a destination behind, a quarantined message, a certificate expiring. Start every shift there, or let alerts tell you.
  • Quarantined messages get looked at the same day. Each one is a message a receiver didn't get. Read the reason, fix what caused it (the sender, the code, a code table), then resend, correct or dismiss it (resend and correct).
  • A receiver's planned downtime is a pause, made before it starts, with the ticket as the reason, and a resume after (pause).
  • Backlogs drain on their own. A receiver that was down gets everything it missed, in order, when it's back; nothing needs resending.
  • Nothing to clean up. Old messages go by themselves, partition by partition, at the retention you set; there's no purge job and no vacuum to schedule (retention and storage).

When something's wrong

You seeLook at
A destination stalledIts receiver is down, or one message keeps failing: hotspots says which
A destination behindIt's sending, but slower than messages arrive: the receiver is slow, or a rate limit
A destination in errorQuarantined messages: the quarantine
An interface silent or missedNothing received when it should have been: the sender, or the network between
An interface stoppedIts code ran away or ran out of memory three times: the logs and the quarantined messages say which message (nuance)
A sender says connection refusedIs the engine running, draining, or refusing it by allow:? Logs, ductus doctor
A TLS handshake failsThe engine logs the reason once a minute per sender; ductus cert show and ductus doctor (TLS)

What each state means exactly, and when it shows: the states.

Before you change anything

  • Upgrades: ductus upgrade --check with the new version first, then ductus replay for the evidence a change board asks for (resend and replay).
  • A project change (new code, a new destination): ductus test and ductus validate --env prod in CI, then deploy. On Linux, systemctl reload ductus deploys without refusing a single connection (high availability).
  • A shorter retention deletes messages, so it waits for a confirmation (retention).

The console (web UI)

The engine serves its web UI on the same address as its REST API: api.listen, http://127.0.0.1:7700/ by default. Nothing is installed on anyone's desktop, and there's no second port: whoever can reach the API can open the console in a browser.

Getting to it

  • On the server itself it works as installed: http://127.0.0.1:7700/.
  • From people's desks it needs the API on the network, which means TLS: api.listen: 0.0.0.0:7700 with api.tls (the browser's sign-in cookie is only sent over https, or to localhost) and an allow: list of the networks your team works from (nuance, the API on the network).
  • Signing in is with a username and password (local accounts, or the directory with auth.ldap), or with an API token (people). What you see and may do follows your role: viewer reads; operator also pauses, resends, edits, dismisses, drains and runs connection checks; admin also manages users and tokens. Reading message payloads is a permission of its own (payloads), and so is the audit log (audit): a role alone doesn't give them (nuance).
  • Live: the header shows "Live" while the console gets the engine's updates as they happen; "Offline" means it lost the engine (stopped, or the network), and it reconnects by itself.

The pages

The menu has three groups: the day to day first, then reports, then the engine itself.

Overview

The engine at a glance: what needs attention (the worst first), open alerts, certificates expiring, and other engines sharing the database. Start a shift here. "Cut off from the database" across the top means the engine lost its database connection: it's still receiving, and sending waits until it's back (nuance).

Interfaces

Every interface with its health, source, how many messages wait and are quarantined, when it last received, and its owner; worst first. Mine shows the interfaces you own (an operator who owns something starts there). Filter by name, owner, host or port, tick Only those needing attention, or pick a documentation field's value.

An interface

Overview: who sends and who receives (about:), each destination's card (health, waiting, quarantined, last sent, its pause and reason, and Pause / Resume), the interface's open alerts, counts and timings since the engine started, and its documentation fields.

Messages: the interface's messages, newest first, filtered by status. Each row opens the message. Finding a message by its content (an MRN, a name) isn't there yet: the message store and search.

A message

What was received and what each destination did with it:

  • Payload: the message as received, with each HL7 field's path shown as you point at it (PID-3.1). Needs the payloads permission, and opening it is logged before it's shown (nuance).
  • Destinations: per destination, its status, attempts, error and when it was sent, and what it was sent (recomputed by running its code again, unless it has save_output: true). Resend (a quarantined delivery), Replay (a sent or filtered one), Dismiss.
  • Edit and resend: a corrected copy, sent to the destinations you choose.
  • Who viewed the payload and History: every read, resend, edit and dismissal, with who, when, and the reason.

What each action does exactly: resend, replay and correct.

Quarantine

Every quarantined delivery across the interfaces you can see, filtered by interface and destination, with the reason. Select many and resend or dismiss them in one go, after fixing what caused them.

Alerts

What's open (acknowledge it to stop the reminders; silence it for a while), what's silenced, the history, and each channel: where it sends, how many were sent or failed, the last error, and Test (alerts in practice).

Hotspots

Where each destination's backlog sits: the lanes messages wait in, each lane's first message (its key, age, attempts and last error), and what it means ("blocked by message 1234: look at this message", "every worker blocked: the receiver is down") (hotspots).

Reports: Impact, Owners, Documentation

  • Impact: which interfaces a vendor, application or host touches, and who owns them: the first question when a vendor reports a breach or a VPN changes.
  • Owners: who owns what, and whether each owner reaches a person; Update previews moving ownership from one person to another (a patch for the project, not a change in place).
  • Documentation: the company's own documentation fields for every interface, destination and resource, and which are missing.

Engine: Retention, Drain, Connections, Users, Tokens, Audit

  • Retention: each interface's retention, changes in progress, shorter values waiting for a confirmation (type the interface's name to confirm), and what the held area keeps (retention and storage).
  • Drain: stop taking new messages and deliver what's stored, before a server move or a change that needs empty queues; Stop when drained (drain).
  • Connections: ductus doctor from the engine's own server (Check now), the last scheduled checks, and every certificate the engine presents or connects to, with its expiry.
  • Users and Tokens (admins): accounts, roles, permissions, lockouts; API tokens.
  • Audit (the audit permission): who did what, filtered by person, action, interface or message.
  • API reference: every REST operation, with who may call it.

Your account

Your role and permissions, your password (local accounts), My activity (your own audit rows), and the appearance: light, dark, high contrast, or your own colors.

The theme and colors are saved with your account, so they follow you to any browser (nuance); a palette of your own may be hard to read, and the check warns but never stops you (nuance). A session acts as whoever signed in, ends after 12 hours unused or 7 days, and at once when the account is disabled (nuance).

What the console can't do

Change the project: interfaces, code and settings live in Git and are deployed (the project). The console operates what's deployed; it doesn't edit it. That keeps every change reviewed and versioned, and is why ownership moves are previews.

The CLI day to day

Everything the console does, the ductus command does from a terminal, for runbooks, scripts and the moments you're already on the server. Every command's options are in the command-line reference; this page is which one to reach for, and what it tells you. The outputs below are from a real engine.

Which engine, as whom

Commands that act on the running engine talk to its REST API (--api, DUCTUS_API_URL, http://127.0.0.1:7700 by default) with a token (--token, DUCTUS_API_TOKEN). Commands that read the database directly need its URL (DUCTUS_DATABASE_URL). On the server, the service's environment file already has the URL; give the token yourself:

$ ductus token create ops-probe --role operator --permission payloads
dux_12b318581a5c9072d1603a927b0fd7c869aea43730050f668b48a94597568401
token `ops-probe` (operator) created. This is the only time it's shown: keep it in a secret store.
Use it as `Authorization: Bearer <token>`, or DUCTUS_API_TOKEN for `ductus drain`.
  • One token per person or tool, named after it (ops-desk, monitoring, ci): every action made with it is in the audit log under that name, and revoking one (ductus token revoke ops-desk) stops only it.
  • The smallest role that does the job: viewer for status, hotspots and metrics; operator to pause, resend, drain. A token reads payloads only with --permission payloads.
  • ductus token create and ductus user work on the database directly (that's how the first admin is made), so they need DUCTUS_DATABASE_URL, not a token.
CommandDoesNeeds
ductus statusCounts by interface and status; with a token, healthdatabase; token for health
ductus pause / resumePause or resume one destinationoperator token
ductus hotspotsWhere a backlog sits, and whyviewer token
ductus drain / resumeStop taking messages, deliver what's storedoperator token
ductus alertsWhat's open; alerts test sends a test notificationviewer token; the project for test
ductus retentionRetention per interface, changes, confirmationsviewer token; operator to confirm
ductus doctorEvery connection the project needs, checked from herethe project
ductus audit exportThe audit log, as JSON linesdatabase
ductus user, tokenAccounts and tokensdatabase

How things are: ductus status

$ ductus status
adt-in               sent         300

by destination:
adt-in → archive     sent         300
adt-in → lab         sent         300

health:
adt-in               ok, received 3s ago
  → archive          ok, sent 3s ago
  → lab              ok, sent 3s ago

The counts come from the database, so they work while the engine is down (on the server, after a crash, to see what's waiting). The health lines come from the running engine and need a token: each interface and destination in the words the console uses. When something's wrong it says what and how much:

health:
adt-in               ok, received 1 min ago
  → archive          ok, sent 1 min ago
  → lab              stalled: nothing sent for 1 min, 30 waiting; retrying: Connection refused (os error 111)

Pause and resume a destination

For a receiver's planned downtime (an upgrade, a weekend cut-over), pause its destination before it goes down: nothing is sent or retried, messages keep arriving and wait in order, and a paused destination never alerts (nuance).

$ ductus pause adt-in lab
`adt-in` destination `lab` paused by ops-probe at 2026-10-31 06:34: nothing is sent until `ductus resume adt-in lab` (0 waiting)
no reason yet: add one when you can with `ductus pause adt-in lab --reason "..."`
$ ductus pause adt-in lab --reason "lab system upgrade until 14:00, ticket CHG0042"
`adt-in` destination `lab` paused by ops-probe at 2026-10-31 06:34: nothing is sent until `ductus resume adt-in lab` (0 waiting)
reason: lab system upgrade until 14:00, ticket CHG0042
  • Act first, explain after: in an incident, pause without a reason and add it when you can. The reason shows on the destination's card, in status and in the audit log, so the next shift knows why and until when.
  • The interface keeps receiving: senders get their ACKs as usual. Only this destination waits; the interface's other destinations carry on.
  • Resume sends the backlog in order:
$ ductus resume adt-in lab
`adt-in` destination `lab` resumed (paused since 2026-10-31 06:34): sending 80 waiting, in order

When not to pause: for an unplanned outage you haven't seen yet, nothing is needed: the destination retries by itself and sends everything when the receiver is back. A pause is for when you know it's down and want it quiet.

Where a backlog sits: ductus hotspots

$ ductus hotspots adt-in --lanes 3
adt-in → lab: 30 waiting in 8 of 8 lanes; blocked by message 381 (key 102001, lane 1, 7 attempts, waiting 1 min): Connection refused (os error 111); all 29 behind it wait (one worker: order)
  lane   worker    waiting  first msg    key                     age attempts  last error
  1      0               6  381          102001                1 min        7  Connection refused (os error 111)
  6      0               6  388          102008                1 min        0
  0      0               3  382          102002                1 min        0
  (5 more lanes waiting: --lanes N lists more)

The first line is the diagnosis. Here every message waits behind message 381, which can't reach the lab: the receiver is down, and nothing is to be done on this side. When instead the others were sent and one lane is stuck, it says "look at this message": one message the receiver keeps refusing with a temporary error, holding up its patient's lane (monitoring).

Drain before a change

ductus drain stops taking new messages (MLLP listeners close, queued HTTP answers 503, file pickup and schedules pause) and delivers everything stored. Use it before moving the engine to another server, or a change that needs empty queues (fewer ordering.partitions).

$ ductus drain --wait
not taking new messages; delivering what's stored
draining: 31 pending (adt-in)
drained
$ ductus resume
accepting: taking new messages
  • --stop exits the engine once drained (the service manager then sees a clean stop).
  • Senders are refused while it drains, on purpose: they hold their messages and resend once the engine takes them again (nuance).
  • A drain isn't needed for an upgrade or a project deploy on Linux: systemctl reload ductus hands over without refusing anything (high availability).
  • A drain finishes only when every destination has caught up, so a receiver that's down keeps it draining (nuance). Drain mode isn't remembered across a restart.

What a drain does to each kind of interface: MLLP listeners close (a message half received when it started is the sender's to send again: no AE is answered, nuance); respond-mode HTTP keeps answering, since nothing waits in a queue for it (nuance); and a drain isn't remembered across a restart: a restarted engine takes messages again (nuance).

Alerts

$ ductus alerts
CRITICAL  adt-in → lab: stalled since 2026-10-31 06:35  [alert 3]
          stalled: nothing sent for 1 min, 30 waiting; retrying: Connection refused (os error 111)

What's open now. ductus alerts test chat sends a test notification to the chat channel straight from ductus.yaml, without an engine: try every channel before going live (alerts in practice).

Check every connection: ductus doctor

Run on the engine's server (where the firewall rules apply), it checks every connection the project needs: DNS, TCP from which address, TLS and the certificate, then each system's own login. It never sends a message to a destination.

$ ductus doctor
ductus doctor · environment dev · 2026-10-31 06:35:42 -04:00
on DESKTOP-GCT6E6V · ductus 0.6.0-dev
checked 5 connection(s) in 0.0 s: 4 ok, 1 warning(s), 0 failed

✓ engine database  127.0.0.1:41293 Postgres
    used by  the engine (DUCTUS_DATABASE_URL)
    address  127.0.0.1 (an address, nothing to look up)
    connect  127.0.0.1:41293 from 127.0.0.1 in 0.2 ms
    TLS      none: the server doesn't offer TLS (sslmode=prefer), the connection is plain
    login    as `postgres`, database `ductus`: PostgreSQL 18.6; plain, no TLS: the server doesn't offer it (sslmode=prefer)
    commits  0.1 ms median of 20 (each waits for the disk): strict ingest ≈ 10000 msg/s per sender connection, one commit per ACK; more connections share commits. A 1M/day ADT feed averages ~12 msg/s.

✓ MLLP destination  127.0.0.1:6670 MLLP
    used by  adt-in → lab
…
⚠ listening port  0.0.0.0:6661 MLLP
    used by  adt-in
  ⚠ port     in use: by a running engine, or another program
  → If the engine isn't running on this server, another program holds port 6661: find it with `ss -ltnp` (Linux) or `netstat -ano` (Windows).
  • Run it after any network change, a firewall request, a certificate renewal, and when a destination is stalled and you want to know whether the receiver is reachable at all.

  • A pass means "can connect and log in", not "will accept your messages" (nuance).

  • The commits line is the store's disk: on a laptop (here) it's quick; on a server, what it says per sender connection is the ceiling for strict senders. A port "in use" while the engine runs is expected, as here.

  • The engine runs the certificate part of it on its own every 6 hours (doctor in ductus.yaml); the console's Connections page shows the last runs and has Check now.

  • It checks from where it runs. DNS, routes, firewalls and the source address in a firewall request are this machine's: a laptop on the VPN and the engine's server can get opposite answers. Run it on the server (nuance).

  • A name with several addresses (IPv4 and IPv6, a cluster) passes when one of them works, with a warning: the engine may try a failing one first and be slow (nuance).

Who did what: ductus audit export

$ ductus audit export --since 2026-10-31
5 entries
{"id":1,"at":"2026-10-31T10:34:05.714979Z","actor_kind":"system","actor":"todd","via":"cli","ip":null,"action":"token.create","subject":"ops-probe","interface":null,"message":null,"outcome":"ok","detail":{"role":"operator","permissions":["payloads"]}}
{"id":2,"at":"2026-10-31T10:34:06.045144Z","actor_kind":"system","actor":"engine","via":"engine","ip":null,"action":"engine.start","subject":null,"interface":null,"message":null,"outcome":"ok","detail":{"host":"DESKTOP-GCT6E6V","version":"0.6.0-dev","instance":"18e396c42b8b8823-620","previous":"first","environment":"dev","previous_start":null}}
{"id":3,"at":"2026-10-31T10:34:10.284626Z","actor_kind":"token","actor":"ops-probe","via":"token","ip":"127.0.0.1","action":"destination.pause","subject":"adt-in/lab","interface":"adt-in","message":null,"outcome":"ok","detail":{"reason":null}}
…

One JSON object per line, oldest first: for a SIEM, or an auditor's question ("who resent messages for this patient last month?" is --action message --interface adt-in --since …). Filters: --since, --until, --actor, --action (an action or a group: message, user, session), --interface, --message. The export is itself logged. Every action is listed in audit actions.

Retention

$ ductus retention
adt-in                       7w

Each interface's retention, and, when there are any, changes in progress, shorter values waiting for ductus retention confirm <interface>, and expired partitions that had to stay because they held messages someone still needs (retention and storage).

On the server: the service

LinuxWindows
Start, stopsystemctl start|stop ductusStart-Service Ductus, Stop-Service Ductus
Deploy a project changesystemctl reload ductus (no gap)Restart-Service Ductus
Is it runningsystemctl status ductusGet-Service Ductus
Its logsjournalctl -u ductusC:\ProgramData\Ductus\logs, the Event Log

Linux, Windows, logs.

The message store and search

Every message the engine accepts is stored in its Postgres database before the sender gets its ACK, as it was received, and kept for the interface's retention. That record is how you answer "did we get it, when, and what did each receiver get?", and it's where resend, replay and correction start from.

What's kept

KeptFor
The messageas received, byte for byte, with when it arrived, its ordering key (the MRN) and MSH-10the retention window (retention)
Each destination's deliveryits status, attempts, last error, when it changedthe same window
What was sentonly for destinations with save_output: true; otherwise it's made again by running the code when you lookthe same window
What people didevery resend, replay, edit, dismissal and payload read, with who and whyaudit.keep, six years by default

Only the original is stored by default. "What did the lab get?" is answered by running the lab's code on the stored original again, which gives what was sent as long as the code hasn't changed since. Where you must be able to prove what went out (a receiver that disputes it, a regulator), turn on save_output for that destination; it costs about 1.15 GB per million deliveries (nuance).

An HTTP source in respond mode stores nothing: its requests are answered from code, not queued (nuance).

Statuses

StatusA delivery isA message is
pendingwaiting to be sent, or being retriedwaiting for at least one destination
sentdelivered: AA, a 2xx, a complete file, stored in the next interfacefinished; at least one destination sent it, none quarantined it
filterednot sent on purpose: null, kill(), dismissed, replaced by a corrected copy, or a destination removedfinished; every destination filtered it
quarantinedheld for a person: refused (AR, a 4xx), the code threw, or max_attempts ran outfinished, but needs someone

A message's status is the worst of its destinations' (quarantined if any is), so "quarantined" on a message means some receiver didn't get it; the message's page says which.

Finding a message

In the console: an interface's Messages tab lists its messages newest first, filtered by status; a message's page shows the payload (with the payloads permission), each destination's outcome and the history. The Quarantine page lists every quarantined delivery across interfaces.

From the API: the same list, a page at a time:

$ curl -s -H "Authorization: Bearer $DUCTUS_API_TOKEN" \
    "http://127.0.0.1:7700/api/v1/interfaces/adt-in/messages?status=quarantined&limit=2"
{"messages":[{"id":2,"received_at":"2026-10-31T10:49:28.372890Z","interface":"adt-in","status":"quarantined","ordering_key":"QUARANTINE-ME2","control_id":"QQUARANTINE-ME2","size":117,"last_error":"lab: rejected (AR): rejected by the receiver","updated_at":"2026-10-31T10:49:28.382056Z","origin":null},{"id":1,…}],"next_before":1}

next_before is the next page's before. One message, with its payload and every destination's outcome, is GET /api/v1/messages/{id} (messages API).

Searching by content: not yet

There's no search by MRN, name or field in the console or the API yet. The message list filters by status and pages by time; a search across messages by patient and date is planned (v1.3, with long-term storage). Until then:

  • By the ordering key: each message keeps its ordering key's value (ordering_key, the MRN for ordering.key: PID-3.1) and MSH-10 (control_id) in their own columns. A DBA, or you with a read-only login, can find them in the database:
-- psql, with a read-only login (pg_read_all_data); one interface, one day
SELECT id, received_at, status, control_id
FROM messages
WHERE interface = 'adt-in'
  AND received_at >= '2026-10-30' AND received_at < '2026-10-31'
  AND ordering_key = '123456'
ORDER BY id;

The time range matters: messages are partitioned by time, and with it Postgres reads only that day's partitions (checked with EXPLAIN); without one it reads every partition. Then open the message in the console by its id (/messages/<id>). The tables are described in the database schema.

  • Know what that bypasses: a payload read through Ductus is recorded in its audit log before it's shown; a query in psql isn't. Give such logins only to people your privacy officer would let read every message, and prefer selecting ids and keys (as above) over payload.

Why not yet, and what's planned: nuance.

Who can see what

  • Everyone signed in sees messages' metadata (ids, statuses, errors, keys).
  • Payloads need the payloads permission, and every read is logged before it's shown, once per reader per message per 10 minutes (nuance). The message's page lists who viewed the payload.
  • Errors can hold data: a receiver's AR text or a quarantine reason may quote a field. Write quarantine reasons that name the field and the problem, not the patient.

Resend, replay and correct

Four things can be done with a message that's already been handled, from the message's page in the console, the Quarantine page (many at once), or the API. Each is an operator's action, recorded in the audit log with who, when and the reason. A fifth, ductus replay, is different: it runs a period's messages through code to compare, and sends nothing.

ActionOnDoesUse it when
Resenda quarantined deliverysends the same message to that destination againyou fixed the cause: the receiver, the code, a code table
Replaya sent or filtered deliverysends the same message to that destination againa receiver lost what it got, or a destination was filtering wrongly
Edit and resenda quarantined messagesends a corrected copy; the original's quarantined deliveries are marked replacedthe message itself is wrong and the sender can't send it again
Dismissa quarantined deliverytakes it out of the quarantine without sending, with a reasonnobody should get it: a test message, a duplicate already handled by hand

Resend

After fixing what made a destination quarantine a message, send it again:

$ curl -s -X POST -H "Authorization: Bearer $DUCTUS_API_TOKEN" -H "X-Ductus: 1" \
    -H "content-type: application/json" -d '{"reason":"lab says fixed"}' \
    http://127.0.0.1:7700/api/v1/messages/2/resend
{"id":2,"interface":"adt-in","destinations":[{"destination":"lab","previous_status":"quarantined","previous_attempts":0,"previous_error":"rejected (AR): rejected by the receiver"}]}
  • The same message goes again, in place: first in its lane, not behind everything that came since, and with the same id, so a receiver that dedupes still recognizes it (nuance). It runs the destination's code as deployed now: a fixed mapping or code table applies.
  • Without destinations it resends to every destination that quarantined it; the others aren't touched.
  • Fix first, then resend. Resending a message the receiver still refuses quarantines it again (the probe above did: the lab still answered AR). The history shows each try.
  • Many at once: select them on the Quarantine page, or POST /api/v1/messages/resend with their ids, in one transaction.

Replay

The same call naming a destination whose delivery was sent (or filtered) sends it again:

$ curl -s -X POST -H "Authorization: Bearer $DUCTUS_API_TOKEN" -H "X-Ductus: 1" \
    -H "content-type: application/json" \
    -d '{"destinations":["lab"],"reason":"lab lost it in their upgrade"}' \
    http://127.0.0.1:7700/api/v1/messages/3/resend
{"id":3,"interface":"adt-in","destinations":[{"destination":"lab","previous_status":"sent","previous_attempts":0,"previous_error":null}]}

The receiver gets a message it already had: tell them, or be sure they dedupe on MSH-10 (or the id your code sends). A replay is the same message, not a new one (nuance).

Edit and resend

When the message is wrong (an MRN mistyped at registration, a segment the receiver can't take) and the sender can't send a corrected one, correct a copy:

$ curl -s -X POST -H "Authorization: Bearer $DUCTUS_API_TOKEN" -H "X-Ductus: 1" \
    -H "content-type: application/json" \
    -d '{"payload":"MSH|^~\\&|EHR|HOSP|LAB|HOSP|20261031120000||ADT^A08|QFIXED|P|2.5\rPID|1||777001^^^HOSP^MR||SMITH^ANNA\r","reason":"MRN corrected from the EHR"}' \
    http://127.0.0.1:7700/api/v1/messages/2/edit
{"id":3,"original":2,"interface":"adt-in","destinations":["lab"],"replaced":["lab"]}
  • The copy is a new message (id 3 here) going to the destinations that quarantined the original (or the ones you name), through their code, as if it had just arrived. The original stays exactly as received, and its quarantined deliveries become "replaced" (filtered), each pointing at the copy.
  • In the console, Edit and resend opens the payload in an editor, with the destinations to send to.
  • A message still pending can't be edited (it's being retried): wait until it's quarantined, or set retry.max_attempts on that destination so it gets there (nuance).
  • Fix the source too: tell the sending system's team, or the same mistake comes back tomorrow.

Dismiss

$ curl -s -X POST -H "Authorization: Bearer $DUCTUS_API_TOKEN" -H "X-Ductus: 1" \
    -H "content-type: application/json" -d '{"reason":"test message sent by mistake"}' \
    http://127.0.0.1:7700/api/v1/messages/1/dismiss
{"dismissed":[{"id":1,"interface":"adt-in","destinations":[{"destination":"lab","previous_status":"quarantined","previous_attempts":0,"previous_error":"rejected (AR): rejected by the receiver"}]}],"skipped":[]}

A dismissed delivery leaves the quarantine (the destination's error state clears) and becomes filtered, "dismissed by NAME: reason". Nothing is deleted: the message and its history stay for the retention window (nuance). A reason is required. Dismiss what nobody should get; don't dismiss what you just haven't had time for: an error that stays visible is the reminder.

What each leaves in the audit log

$ ductus audit export --since 2026-10-31 --action message
5 entries
{"id":3,…,"actor":"ops-probe2","via":"token","ip":"127.0.0.1","action":"message.payload.read","subject":null,"interface":"adt-in","message":2,"outcome":"ok","detail":null}
{"id":4,…,"action":"message.release","interface":"adt-in","message":2,"outcome":"ok","detail":{"reason":"lab says fixed","messages":[2],"deliveries":1,"destinations":["adt-in/lab"]}}
{"id":5,…,"action":"message.edit","interface":"adt-in","message":2,"outcome":"ok","detail":{"copy":3,"reason":"MRN corrected from the EHR","messages":[2],"destinations":["adt-in/lab"]}}
{"id":6,…,"action":"message.dismiss","interface":"adt-in","message":1,"outcome":"ok","detail":{"reason":"test message sent by mistake","messages":[1],"deliveries":1,"destinations":["adt-in/lab"]}}
{"id":7,…,"action":"message.replay","interface":"adt-in","message":3,"outcome":"ok","detail":{"reason":"lab lost it in their upgrade","messages":[3],"deliveries":1,"destinations":["adt-in/lab"]}}

A resend of quarantined deliveries is message.release, a replay of sent ones message.replay. Each message's page shows the same as its History.

Replay a period before a change

ductus replay answers a different question: what would this change do to real traffic? It runs a period's stored messages through the code in front of it (a new version, a changed mapping) and compares what each destination's code decides (sent and what, filtered, quarantined) with what was recorded, with kept outputs, and with the installed version run beside it. It sends nothing and writes nothing: a read-only login, or a replica, is enough.

$ ductus replay --since 1h
adt-in: 300 message(s) replayed in 0.1 s
ductus replay: version 0.6.0-dev against the record and kept outputs, 2026-10-31 15:25 to 2026-10-31 16:25 UTC, environment dev (nothing was sent)
note: no installed version found (/usr/bin/ductus): outputs compared with kept outputs only; name a binary with --against

adt-in: 300 message(s) in 0.1 s
  ✓ archive: 300 compared, 300 same decision (outputs not compared)
  ✓ lab: 300 compared, 300 same decision (outputs not compared)

300 message(s), 600 comparison(s): 0 identical, 600 same decision (outputs not compared), 0 different, 0 drift, 0 not comparable, 0 waiting (0.1 s)
⚠ outputs not compared: 600 comparison(s) matched by decision only (both sent), not by what was sent, because no version ran beside this one and no destination keeps its outputs; to compare outputs, run it on the server, where the installed version runs beside it, or name the installed binary with --against PATH

That lab had no installed version and kept no outputs, so it could only say each message was still sent; identical is claimed only where an output was compared (or a filter or quarantine, which have none). What each count means.

  • Before an upgrade, run the new version's binary on the server: ductus replay --since 24h compares against the installed version and exits 1 on any difference. With --report replay.html it writes the evidence a change board asks for (--hide-values to leave patient data out of it) (nuance).
  • Before a code change, --baseline <git revision> compares the project as it is with an earlier one: every message whose output the change alters is listed, field by field.
  • It can't compare what code writes (db().execute, an API POST): those calls are refused in a replay, and the message is marked not comparable (nuance).
  • The clock is each message's arrival and Math.random repeats, so outputs that use them still compare (nuance).
  • What it costs: measured at 100,000 messages × 3 destinations in 7.5 s.

ductus replay re-runs code to compare; resend and replay in the console send. Don't confuse the two when talking to a change board.

Monitoring and metrics

The engine judges its own health: every interface and destination has a state, worked out from what it expects (monitor) and what it sees. The console, ductus status, the API and Prometheus all show the same states, and alerts are made from them. This page is what each state means, how to look deeper, and what to graph.

Health: what each state means

StateOfMeansWhat to do
okbothnothing to report
behinddestinationpast its monitor.behind (a count of waiting messages, the oldest one's age, or both), but still sendingthe receiver is slower than the feed, or a rate limit: usually wait; call the vendor if it doesn't catch up
errordestinationit has quarantined messageslook at them: resend, correct or dismiss
stalleddestinationmessages waiting over a minute and nothing finished for a minutethe urgent one: the receiver is down, or one message keeps failing. Hotspots says which
pauseddestinationpaused on purpose, with who and whyresume it when the receiver is back
silentinterfacenothing received within monitor.active_withinthe sender, or the network between
missedinterfacenothing received by monitor.active_bythe nightly file or batch didn't come
stoppedinterfaceits code ran away or ran out of memory three times in a row: it receives, but sends nothing until the engine restartsthe quarantined messages and the log name the message; fix the code

A destination shows the worst state that applies, worded with the numbers ("behind 1,240 (oldest 12 min), catching up ~6 min"), and once a few weeks of history are in, why: a burst of arrivals, or a receiver slower than usual for this hour of the week (nuance). Nothing is expected until you say so: without monitor:, a quiet interface is ok, and a destination is behind only past a limit you set (nuance).

Set behind with both a count and an age on every feed someone would phone about: a count alone misses an overnight stall when little arrives, an age alone misses a daytime pile-up that still moves. Set active_within on steady feeds and active_by on scheduled ones.

Where a backlog sits: hotspots

A count says that a queue is stuck; hotspots says which message or patient. Messages with an ordering key wait in lanes (the key's hash), each read by one worker, so a backlog always sits behind one message per worker.

$ ductus hotspots adt-in --lanes 3
adt-in → lab: 30 waiting in 8 of 8 lanes; blocked by message 381 (key 102001, lane 1, 7 attempts, waiting 1 min): Connection refused (os error 111); all 29 behind it wait (one worker: order)
  lane   worker    waiting  first msg    key                     age attempts  last error
  1      0               6  381          102001                1 min        7  Connection refused (os error 111)
  6      0               6  388          102008                1 min        0
  0      0               3  382          102002                1 min        0
  (5 more lanes waiting: --lanes N lists more)

How to read the first line (nuance):

  • "blocked by message M" while others were sent since it arrived: the receiver is up and takes other messages; this one is the trouble (its content, or the receiver's data for that patient). Look at the message and its last error; set retry.max_attempts on that destination if such messages should be quarantined instead of holding their lane.
  • "every worker is retrying its first message", or blocked with a connection error as above: the receiver, or something the code calls, is down. Nobody's message is to blame; call the vendor, and ductus pause stops the retries meanwhile.
  • "lane L has 73% of the backlog" or one worker with most of it: one patient (or a few) arriving faster than one worker sends. Order keeps a key on one worker, so more workers don't help that key. A hot key that clears by itself is a burst; one that's hot every day is a feed to discuss with the sender.
  • "a lane no worker reads": ordering.partitions was lowered while messages waited (nuance).

After a receiver comes back, the first message is tried at its next retry: the wait between tries doubles up to retry.max_backoff_ms (60 s by default), so a destination can show stalled for up to a minute after the receiver is back, then drains in order (nuance).

Prometheus

GET /api/v1/metrics is in Prometheus's format, for Prometheus, Grafana Agent, Datadog or anything that scrapes. It needs a token, a viewer one:

# prometheus.yml, in part
scrape_configs:
  - job_name: ductus
    metrics_path: /api/v1/metrics
    scheme: https                      # with api.tls; http on the engine's own server
    authorization: { credentials_file: /etc/prometheus/ductus-token }
    static_configs:
      - targets: ["ductus1.hospital.org:7700"]

(ductus token create prometheus --role viewer makes the token. List Prometheus's address in api.allow.) What one destination's lines look like, from a real engine:

ductus_messages_received_total{interface="adt-in"} 411
ductus_deliveries_total{interface="adt-in",destination="lab",outcome="sent"} 380
ductus_deliveries_total{interface="adt-in",destination="lab",outcome="quarantined"} 0
ductus_delivery_retries_total{interface="adt-in",destination="lab"} 7
ductus_delivery_latency_seconds_bucket{interface="adt-in",destination="lab",le="0.01"} 165
…

Every metric, with its labels: Prometheus metrics.

What to graph

QuestionQuery
Messages in, per secondrate(ductus_messages_received_total[5m])
Sent, filtered, quarantined, per secondrate(ductus_deliveries_total[5m]) by outcome
How long from receipt to sent (95% faster than)histogram_quantile(0.95, rate(ductus_delivery_latency_seconds_bucket[5m]))
The receiver's time per sendhistogram_quantile(0.95, rate(ductus_send_seconds_bucket[5m]))
Your code's time per messagehistogram_quantile(0.95, rate(ductus_transform_seconds_bucket[5m]))
What's waiting, and for how longductus_queue_pending, ductus_queue_oldest_seconds
Retries (a receiver flapping)rate(ductus_delivery_retries_total[5m])
Database and API calls from codeductus_resource_call_seconds, ductus_resource_call_errors_total
  • Counters start at zero when the engine starts; rate() and increase() handle it. Graph rates, not totals (nuance). Queue sizes come from the database at each scrape and are always current.
  • Timings are histogram buckets: a p95 is good to about a bucket's width (nuance).
  • About 65 series per destination: on a hosted service that bills per series, drop what you don't graph with metric_relabel_configs (nuance).

Alerting from Prometheus

The engine alerts by itself (alerts), and that's the simpler place. If your operations team alerts from Prometheus instead (one pager for everything), use the states the engine has already judged rather than re-deriving them:

# ductus-rules.yml, in part (a Prometheus rule file)
groups:
  - name: ductus
    rules:
      - alert: DuctusDestinationStalled
        expr: ductus_destination_state == 3
        for: 2m
      - alert: DuctusDown
        expr: up{job="ductus"} == 0
        for: 2m

ductus_destination_state is 0 ok, 1 behind, 2 error, 3 stalled, 4 paused; ductus_interface_state is 0 ok, 1 silent, 2 missed, 3 stopped. Watch the engine itself too: an engine that's down reports nothing, so up == 0 (or the engine's heartbeat) is the alert that covers it. And ductus_alert_send_failures_total going up means the engine's own alerts aren't getting through.

In the API

  • GET /api/v1/interfaces: every interface and destination with its health, counts since the start, and timings (what the console's lists show).
  • GET /api/v1/engine: the engine itself: version, start, database, other engines on the database, and each database's and API's call times (resources), to see which lookup is slow.
  • GET /api/v1/events: the same changes as they happen (what keeps the console live).

REST API reference.

Logs

The engine writes one line per thing that happened worth knowing: a start, a pause, a delivery that failed and when it will be tried again, a refused connection. States and counts are in the console and Prometheus; the log is the why, in order, with times.

Where they are

LinuxWindows
The logthe journal: journalctl -u ductus -fC:\ProgramData\Ductus\logs\ductus.<date>.log, one file a day (named by the UTC date), the last 14 kept
What operations watchthe same journal (forward it to your SIEM as you do for other services)also the Event Log, Application, source Ductus: starts, stops, failed starts, a stopped interface, critical alerts, and other errors at most once a minute per kind (IDs)
How muchRUST_LOG in /etc/ductus/ductus.envRUST_LOG in C:\ProgramData\Ductus\ductus.env
Keptas the journal is configured (journalctl --disk-usage)14 days of files

RUST_LOG is info by default: every line on this page. info,ductus_runtime=debug adds detail for the engine (each start phase and its time, each claim), for a support case; don't leave it on, it's many lines per message. A restart applies a change (Linux: systemctl restart ductus).

Lines are plain text with fields (interface=adt-in destination=lab), so grep finds them: the message comes first, then the fields (grep 'delivery failed.*interface=adt-in'). Under ductus run in a terminal they're colored; elsewhere plain.

What the lines mean

From a real engine, a start, a pause and resume, then a receiver that went down and a drain:

INFO ductus_runtime: the engine's database database=plain, no TLS: the server doesn't offer it (sslmode=prefer)
INFO ductus_runtime::alerts: alerts on channels=1 rules=2 heartbeat=false
INFO ductus_runtime::certificates: no connection has a certificate: scheduled checks have nothing to do
INFO ductus_runtime::api: REST API listening (/api/v1) host=127.0.0.1 port=7700 https=false
INFO ductus_runtime: started interfaces=1 mllp=1 http=0 files=0 schedules=0 internal=0 shared=1 pool_threads=8 dedicated_threads=0 took=299.820201ms
WARN ductus_runtime::api::status: destination paused interface=adt-in destination=lab by="ops-probe" reason="lab system upgrade until 14:00, ticket CHG0042"
INFO ductus_runtime::worker: paused: waiting to be resumed interface=adt-in destination=lab slot=0
WARN ductus_runtime::api::status: destination resumed interface=adt-in destination=lab by="ops-probe"
WARN ductus_runtime::worker: delivery failed; retrying in 1s interface=adt-in destination=lab id=381 attempts=1 error=connection closed before ACK
WARN ductus_runtime::worker: delivery failed; retrying in 2s interface=adt-in destination=lab id=381 attempts=2 error=Connection refused (os error 111)
…
WARN ductus_runtime::worker: delivery failed; retrying in 60s interface=adt-in destination=lab id=381 attempts=7 error=Connection refused (os error 111)
INFO ductus_runtime::alerts: alert alert=dest:adt-in/lab kind="fired" state="stalled" severity="critical" notifications=1
WARN ductus_runtime::control: draining: not taking new messages; delivering what's stored stop=false
WARN ductus_runtime::worker: destination rejected; quarantined interface=adt-in destination=lab id=411 error=rejected (AR): rejected by the receiver
WARN ductus_runtime::control: drained: nothing left to deliver stop=false
INFO ductus_runtime: stopping: taking no new messages, finishing the ones in hand why="Ctrl-C" limit=30s
INFO ductus_runtime: stopped took=2.217222ms

(Each line starts with its time in UTC, 2026-10-31T10:34:06.069632Z, left out here.)

LineMeansAct when
started interfaces=N … took=every interface is up and taking messages; how long the start tooknever: it's the "ready" line (systemd's status says the same)
the engine's database database=…how the database connection is protected (TLS, verified or not)it says plain or unverified in production: TLS to the database
delivery failed; retrying in Ns … attempts=N error=…a temporary failure; the wait doubles to max_backoff_msthe same id keeps failing: hotspots
destination rejected; quarantined … error=the receiver refused this message (an AR, a 4xx)look at it the same day: resend
destination paused … by= reason= / resumedsomeone paused or resumed, with their name
alert … kind="fired" / "recovered"an alert opened or ended, and how many were toldnotifications=0 when you expected some: the rules or the channel
draining / draineda drain started and finished
stopping … why= / stopped took=a clean stop, and why it stoppeda stop you didn't make

A start logs one line, not one per listener (nuance); problems with one interface are their own lines. Things that could repeat many times a second are said once a minute per sender or kind instead: a refused TLS handshake (nuance), a connection refused by allow: (nuance), so a misconfigured sender can't flood the log.

When a sender says "connection refused"

The engine logs every refusal it makes, with the reason. If there's no line for the sender's address, the engine never saw the connection: a firewall between, the wrong port, or the engine not running (a stopped service, a drain, and a failed start close the ports on purpose: nuance). ductus ports lists what the project listens on, and ductus doctor checks the ports from the server.

What not to look for in the logs

  • Message content. The log names messages by id (id=381) and never prints a payload. Error texts are the receiver's answer and your code's quarantine reasons, which can quote a value: write reasons that name the field, not the patient. Look a message up by its id in the console.
  • Who read what. That's the audit log, kept six years, not the service log, kept days.

Alerts in practice

How to set alerts up is Alerts and owners; this page is living with them: what arrives, what to do with it, and how to keep alerts trusted, which is the only way they keep working. An alert people learn to ignore is worse than none.

What an alert is

The engine judges every interface and destination every 15 seconds. When one enters a state a rule in ductus.yaml names, an alert opens and its channels are told once. While it stays open and nobody has acknowledged it, they're reminded every repeat (4 h by default); if it gets worse (a warning becomes critical) they're told again; once the state has been fine for settle (2 min), it recovers and they're told that too (nuance).

What a webhook receives, from a real engine whose lab receiver went down (format: json; Teams and Slack get the same as a card or message):

{"kind":"fired","alert":3,"key":"dest:adt-in/lab","interface":"adt-in","destination":"lab","state":"stalled","severity":"critical","since":"2026-10-31T10:35:21.087109+00:00","at":"2026-10-31T10:35:21.093252+00:00","summary":"stalled: nothing sent for 1 min, 30 waiting; retrying: Connection refused (os error 111)","environment":"dev","title":"[Ductus dev] CRITICAL adt-in → lab is stalled"}

The title says the environment, so a test engine's alert can't be taken for production's.

When one arrives

  1. Open it in the console's Alerts page (or the interface it's about): the summary says what and how much ("30 waiting; retrying: Connection refused").
  2. Acknowledge it once someone is on it: the reminders stop, and the others on the channel see who took it. Acknowledging isn't fixing: the alert stays open until the state is fine (nuance).
  3. Find the cause: stalled → hotspots (the receiver, or one message); error → the quarantine; behind → the receiver's speed and the catch-up time; silent/missed → the sender; stopped → the code (the log and the quarantined messages say which message); certificate → renew it (renewing).
  4. If it's planned downtime you forgot to announce, pause the destination with the reason: a paused destination doesn't alert, and its open alert waits quietly until it's resumed (nuance).
  5. It recovers by itself when the state is fine, and the channel is told. Nothing to close.

Silence, pause, mute: which one

DoesFor
Acknowledgestops the reminders for this alert, until it worsens or recovers"I'm on it"
Silence (for a time)nobody is told about this alert until the time is up; it stays open and showsa known problem someone else owns, while they fix it
Pause the destinationnothing is sent or retried; never alertsplanned downtime of the receiver
alert: { mute: true } on the interfaceits alerts open and show, but nobody is tolda half-built interface, until it goes live

Silencing hides; pausing stops. Pause what's down on purpose, silence only what someone is already fixing, and never mute a live interface to stop noise: fix the rule instead.

Keeping alerts trusted

  • Every alert should need a person. If people get alerts they do nothing about, change the rule: a state that isn't worth a call at 3 a.m. is a warning (held during quiet hours), and a feed nobody watches doesn't need monitor:.
  • Tune monitor: from what happened. A silent that fires every Sunday morning means active_within doesn't fit the feed's quiet hours: add between:. A behind that fires during every nightly batch means the limit is below a normal night.
  • Send to teams, not people: a team's list or chat channel survives holidays and job changes (nuance).
  • Test every channel before you rely on it, and after the mail team changes anything:
$ ductus alerts test chat
sent a test notification to `chat`

It sends a test notification straight from ductus.yaml, without an engine (the webhook got "kind":"test", "title":"[Ductus dev] test notification"). The Alerts page's channel table also has Test, and shows each channel's sent, failed and last error: a channel that's failing can't tell you so itself (nuance). Prometheus's ductus_alert_send_failures_total is the other way to hear about it.

  • Quiet hours hold warnings, never critical alerts (nuance).
  • After a restart, states the engine hasn't watched for a minute yet don't alert, so a restart doesn't raise a wave of alerts for things that were already known (nuance).

An engine that's down tells nobody: the heartbeat

Alerts come from the engine, so an engine that's stopped, crashed or cut off from its database can't send one. Set alerts.heartbeat in production: the engine calls a URL every every (say 5m) while it runs, and a service that expects the calls (healthchecks.io, Better Stack, Uptime Kuma, your monitoring's own push check), running somewhere else, raises the alarm when they stop. Give that service a grace period of about twice every (nuance).

High availability and lanes

What keeps interfaces running through a restart, an upgrade, a dead server or a database failover, and how the work inside an interface is spread so one slow patient or one bad message doesn't hold up the rest. Read the first section before you plan a second server: what Ductus does today is one engine per interface, a standby you start, and no automatic failover yet.

What protects what

EventWhat happensSenders see
A restart (Linux)systemd keeps the listening sockets across itnothing refused; ~0.2–0.3 s wait for an ACK (measured)
A crash (Linux)systemd restarts it after 5 s, with the same socketsnothing refused; ~5 s wait
A project deploy or an upgrade (Linux)systemctl reload: the new engine starts beside the old one and takes overnothing refused; sending paused ~35 ms (1 interface) to ~0.6 s (1,001) (measured)
A restart (Windows)the service stops and startsa short gap: measured 0 refused, a few resets, ~1.1 s longest wait
An upgrade (Windows)the MSI stops, replaces and starts the servicedown 3.1–3.5 s: a few refused, nothing lost (nuance)
The database restarts or fails oversending, file pickup and schedules pause; receiving goes on; the engine reconnects and catches upnothing, as long as the database takes writes (nuance)
A receiver is downits destination retries, in order, until it's back; other destinations carry onnothing
The engine's server diesnothing runs until an engine is started on another serverrefused until then

Nothing acknowledged is lost in any of these: a message is stored in the database before its sender gets the ACK, and stays until every destination has finished with it. What a sender experiences is the column on the right; senders resend by themselves when refused, as MLLP senders do.

One engine per interface

Several engines can share one database, each running different interfaces: that's how you spread a large project over servers (feeds on one, APIs on another). An interface runs on one engine at a time: an engine started with an interface another live engine is running refuses to start, exit 3, naming the other engine's host and pid (nuance). Two engines on one interface would both send each message, and one patient's order would break.

The Overview's other engines on this database card, and GET /api/v1/engine others, list them. A refused start is in the audit log (engine.refused).

The exception is an HTTP source in respond mode: it has no queue, so several engines can answer it behind your load balancer.

A standby server (disaster recovery)

Keep a second server installed and configured with the same project and database, with the service stopped. When the first server is gone:

  1. Make sure the first engine is really down (or its server is): if it's alive and cut off from you but not from the database, two engines would fight, and the second is refused anyway.
  2. Start the standby's service. It claims the interfaces as soon as the first engine's database session is gone: at once if the process ended (a crash, kill -9: measured 0.3 s), within about 25 s if the whole host vanished (the database's keepalive probes notice) (nuance).
  3. Point senders at it: a DNS name or a virtual IP that moves, so nobody reconfigures an EHR during an outage. Its allow: lists and certificates must cover the names senders use.
  4. It sends what was waiting, in order, then everything new.

Practice it: start the standby against a test database with the same project, and time the switch.

Not yet: a standby that takes over by itself. An automatic failover, where a waiting engine claims a dead one's interfaces with nobody starting it, is planned with clustering (v1.2). Until then the switch is a person's (or your cluster manager's) decision, on purpose: a split network that started a second engine automatically could make the duplicates the one-owner rule prevents.

The database is the single point to protect

Everything the engine knows is in Postgres: the messages, the queues, the audit log, the people. Its availability is the engine's, so it needs what your DBA gives any critical database:

  • Backups with point-in-time recovery (pgBackRest or WAL archiving). The engine needs no cron job of its own (backups).
  • A replica to fail over to. The engine reconnects after a failover by itself. With asynchronous replication, messages acknowledged in the last moments before the failover can be missing on the replica: if no acknowledged message may be lost, the DBA's answer is synchronous replication to the standby.
  • A direct connection, or a pooler in session mode: PgBouncer in transaction mode can't hold the engine's lock (nuance).

Deploying and upgrading without a gap

  • Linux: systemctl reload ductus for a project change, and the package's upgrade for a new version, both hand over: the new engine starts on the same listening sockets, takes over once it's ready, and the old one stops. Receiving never stops. A new version or project that can't start leaves the running one serving, and the reload says why (nuance). No drain, no maintenance window for the senders.
  • Windows: a restart or the MSI is a short gap (above): pick a quiet moment. Zero-downtime Windows upgrades are planned for v1.0.
  • Before every upgrade: ductus upgrade --check with the new version, and ductus replay for what it does to real traffic (replay).

Lanes: how an interface spreads its work

Inside an interface, each destination is its own queue. With an ordering.key (the MRN), the queue is split into lanes by the key's hash (64 by default, ordering.partitions); each lane is read by one worker at a time, so one patient's messages stay in order while different patients go in parallel. Without a key, the whole destination is one lane: strict arrival order, one message at a time (order).

What that means when you run it:

  • A message that keeps failing holds up its lane, and the lanes its worker reads; the other workers' lanes go on (nuance). ductus hotspots shows which message. For receivers that refuse a message for good with errors that look temporary, retry.max_attempts quarantines it so its lane moves.
  • More workers help only with a key, and only up to about the number of cores; they need isolation: dedicated (dedicated threads).
  • One hot patient (a batch load sent as one patient's updates) goes as fast as one worker, whatever the number of workers: that's what order per patient costs.
  • Change partitions only with an empty queue: lowering it with messages waiting is refused at start, raising it can reorder a patient's waiting and new messages (nuance). ductus drain --wait, change, deploy, ductus resume.
  • Several destinations are independent: one receiver down holds up only its own queue (nuance).

Retention and storage

How long messages stay, where the space goes, and what an operator does about it, which is mostly nothing: old messages go by themselves, a partition at a time, with no purge job, no vacuum to schedule and no slow delete in the middle of the day. The settings are in ductus.yaml (the project's default) and interface.yaml (one interface's).

How it works

  • Three windows: 1w, 3w or 7w (the default). Each is a set of partitions of the message tables, a day each for 1w and a week each for 3w and 7w (nuance).
  • Old messages go by dropping a whole partition once all of it is older than the window, checked hourly. Dropping a partition costs the same whether it held ten messages or ten million, and blocks nothing: no row-by-row delete, no table bloat, nothing to vacuum.
  • So a message is kept at least its window, and gone within a day (1w) or a week (3w, 7w) after: a 7w message lives 49 to 56 days (nuance). Treat the window as "at least".
  • Nothing anyone still needs is deleted. When a partition expires, its messages still pending for a destination, and its quarantined ones (with quarantined: keep, the default), move to the held area first, with their deliveries, and stay in their queue and in the console (nuance). A month of the held area is dropped once it's over and nothing in it is waiting.
  • The audit log is kept apart, six years by default (audit.keep), and trimmed hourly.

Looking at it

$ ductus retention
adt-in                       7w

Each interface's retention; when there are any, also changes in progress (with what's left to move), shorter values waiting for a confirmation (with how many messages they'd delete), and what the held area keeps, by month and interface. The console's Retention page shows the same. In Prometheus, ductus_held_messages (by interface and status) and ductus_held_bytes are counted at each hourly run.

When to act: held quarantined messages growing is a question nobody has answered: look at them (resend, correct or dismiss). Held pending messages are a destination that's been down past the window: they'll go when it's back, but the receiver's team should know.

Changing retention

Change retention: in interface.yaml (or ductus.yaml) and deploy.

  • Longer applies at once: everything not yet dropped is kept to the new window.
  • Shorter deletes messages, so it waits for a confirmation. The engine logs a warning, keeps the old value, and ductus retention shows how many messages the new one would delete. ductus retention confirm <interface> (an operator token), or typing the interface's name on the Retention page, applies it: finished messages past the new window are deleted in the background, the rest move into the new window's partitions (nuance). Confirm on each database the project runs on.
  • One change at a time per interface: a change works in batches, at most half the time busy (8,000 messages took 1 to 1.5 s on a dev machine), and a different value deployed meanwhile waits (nuance). Don't flip it back and forth.
  • Shortening frees disk later, as the old partitions are dropped; to free it now, a DBA can VACUUM FULL the affected old partitions off-hours (nuance).

Choose the window your team looks back over when a problem surfaces: 1w for busy feeds nobody looks back on, 7w where problems show up weeks later (billing, a monthly reconciliation). Longer history belongs in the source system or an archive; the engine isn't a data warehouse, and a long-term store with search is planned (v1.3).

How much space

Roughly: messages a day × their average size × the days kept, plus a small row per destination per message, the indexes, outputs kept with save_output (about 1.15 GB per million deliveries, measured), and whatever is held. A 7w window keeps up to 56 days, so size for that.

Measure it on your own traffic after a week rather than estimating for long:

-- psql: the whole database, then the message tables by window (t7 = 1w, t21 = 3w, t49 = 7w)
SELECT pg_size_pretty(pg_database_size(current_database()));
SELECT substring(relname from '^(?:messages|deliveries)_(t\d+|held)_') AS tier,
       pg_size_pretty(sum(pg_total_relation_size(oid))) AS size
FROM pg_class
WHERE relkind = 'r' AND relname ~ '^(messages|deliveries)_(t\d+|held)_\d+$'
GROUP BY 1 ORDER BY 1;

On a development database it printed:

 tier |  size
------+---------
 held | 8888 kB
 t21  | 54 MB
 t49  | 19 MB
 t7   | 832 kB

Space comes back as partitions are dropped, a day or a week at a time; between drops the database grows by about a day's worth. Leave the disk room for the largest window's worth plus a held backlog (a receiver down for a week keeps a week of its messages longer).

Sizing the database has the rest: the commit time that decides the speed, and keeping long queries off the message tables.

What's not there

  • No archive or export of old messages yet: past the window they're gone (planned, with long-term storage, v1.3).
  • No per-day windows (30 days, 90 days): only the three, for the reason above.
  • No legal hold on a single patient's messages: if one is needed, copy them out (the schema describes the tables) before their partition goes.

Upgrade

How to move a running Ductus to a new version: what the version numbers promise, how to find out what the new version will do before you install it, the steps for each way of installing, what happens to the database, and how to go back. For deploying a change to your own project (new interface code, a new interface), the same tools apply: see Deploy a change.

The short version, for a server installed from the Linux package:

  1. Unpack the new version's portable archive anywhere on the server.
  2. ./ductus upgrade --check --replay 1d --env-file /etc/ductus/ductus.env --root /var/lib/ductus/project: it changes nothing and exits 1 when something would go wrong.
  3. Install the new package. It hands the running engine over to the new version: receiving never stops.
  4. Look: systemctl status ductus, then the console's Overview ("took over from …").

Versions and what's supported

What: a version is MAJOR.MINOR.PATCH (semver): 0.6.0, 0.6.1, 1.2.0. ductus --version prints it; the console's Overview, GET /api/v1/engine and ductus upgrade --check show the version of every engine on the database.

What each kind of version promises:

Before 1.0 (now)From 1.0
Configuration keys, SDK functions, CLI optionsMay change in any minor version; the release notes say whatA minor may deprecate one: it keeps working, ductus validate warns and names the replacement, VS Code strikes it through. Only a major removes it
The databaseEvery version only adds (new tables and columns); older versions run on what newer ones addedThe same in every minor. A major may also remove what an earlier minor stopped using
Upgrading across versionsFrom any version to any later oneWithin a major, from any version to any later one. A major only from the latest minor of the one before (5.0 from 4.latest): the engine will refuse to skip, and upgrade --check will print the path (built with the first major that needs it)
Going backTo the version you came fromTo the previous minor; never back across a major

Why: a hospital upgrades rarely and needs to know an upgrade won't strand a project or a database. Deprecating in minors and removing only at majors means a project that validates without warnings on 4.9 starts on 5.0. A database that only grows within a major is what makes going back a matter of installing the older program.

What's supported today: Ductus is pre-release. There are no released versions yet, only development builds of the latest commit, and only that is supported (SECURITY.md). Long-term-support versions, for sites that stay on one version for a year or more, are planned once clustering (v1.2) is stable; until then, follow the latest version.

Several engines on one database may run different versions while you upgrade them one at a time: each runs its own interfaces, and the older ones keep working on what the newer one added. Don't leave them on different versions for longer than the upgrade: a fix you upgraded for isn't on the ones you haven't.

Before every upgrade: check, then replay

ductus upgrade --check

What: the new version's program reads the database and the project and says what its first start would do. It changes nothing: not the database, not the project folder.

Why: otherwise the first place you learn that a database change works through a 50 GB table, or that your tests fail under the new SDK, is the restart itself, in the change window.

When: before every upgrade, from the new version's portable archive (no install needed), on the server or any machine that reaches the database, with the service's settings. A read-only login is enough (GRANT pg_read_all_data TO ductus_check). Run it again on the day: the database and the project may have changed since.

Default: it checks everything below; --replay 1d adds a replay of a day's traffic; --json gives one document (for a change ticket). Exit 0: nothing in the way. Exit 1: something would refuse the start or fail the project.

From a lab engine (DESKTOP-GCT6E6V is the machine it ran on):

$ ./ductus upgrade --check
ductus upgrade --check: what version 0.6.0-dev would do here (nothing is changed)

The database
✓ migrations: nothing to apply

Engines on this database
✓ 18e39e5085ef2289-3ee on DESKTOP-GCT6E6V (pid 1006): version 0.6.0-dev, environment dev, since 2026-10-31 12:52 UTC, runs 1 interface(s)

The project (., environment dev)
✓ validate: valid
✓ test: 1 passed, 0 failed

Between versions
✓ required stops: none (versions before 1.0 upgrade to any later one)
✓ deprecated settings: none used

ready to upgrade to 0.6.0-dev (checked in 0.6 s)

What each section means, and what to do when it isn't ✓:

SectionIt saysWhen it isn't ✓
The databaseThe changes (migrations) the first start applies, each with what it does; for one that works through an existing table, the table and its size now (⚠)⚠ heavy: plan the window by the table's size, and time it on a restored copy first (below). ✗ failed or edited: every start would refuse; don't upgrade, ask for help
Engines on this databaseEvery engine running on it: host, process, version, environment, how many interfacesEngines you didn't expect: find out whose they are before you go on. Engines before 0.6.2 aren't recorded: look on each server
The projectductus validate and ductus test, run with the new version's SDK and type checkerFix the project (or the new version broke it: then don't upgrade yet). The full output is under the line
Between versionsVersions you must install on the way (none before 1.0), and settings the new version deprecatesReplace each deprecated setting with the one named; it still works until the next major
The last … replayedWith --replay: the period's traffic through the new version, compared (below)✗: read the differences before going on. ⚠ "outputs not compared": only decisions were compared, so a changed field can't show; run it where the installed version is, or name that binary with --against

A failing check, here a test the new version fails, ends with the count and exits 1:

$ ./ductus upgrade --check --replay 1h
…
The project (., environment dev)
✓ validate: valid
✗ test: 0 passed, 1 failed
    adt-in
      ✗ sets the receiving application and keeps the patient
          expected "RECEIVER", got "LAB"
…
The last 1h replayed through 0.6.0-dev (nothing is sent)
✓ replay against the record and kept outputs: 300 message(s), 600 comparison(s), 0 identical, 600 same decision (outputs not compared), 0 different
    note: no installed version found (/usr/bin/ductus): outputs compared with kept outputs only; name a binary with --against
⚠ replay: outputs not compared: 600 comparison(s) matched by decision only (both sent), not by what was sent, because no version ran beside this one and no destination keeps its outputs; to compare outputs, run it on the server, where the installed version runs beside it, or name the installed binary with --against PATH

1 problem(s): fix them before upgrading (checked in 0.7 s)

The replay there passed although the code now sets MSH-5 to "LAB" in every message: this lab has no installed version and keeps no outputs, so all it could compare was that each message was still sent. That's what the ⚠ is for. "Identical" is only ever claimed for an output that was compared.

On a database no version has run on yet, the first section says so instead: "a new database: the first start creates every table (25 migrations)".

--json gives the same as one document: ok and problems, every check with its section, level and text, and the details (migrations.pending with each one's tables, engines, the project's validate and test output):

$ ./ductus upgrade --check --json
{
    "version": "0.6.0-dev",
    "ok": true,
    "problems": 0,
    "seconds": 0.61,
    "checks": [
        {
            "section": "database",
            "level": "ok",
            "text": "migrations: nothing to apply",
            "more": []
        },
…
    "migrations": {
        "new_database": false,
        "pending": [],
        "newer": [],
        "edited": [],
        "failed": []
    },
…

ductus replay: what it does to real traffic

What: ductus replay --since 24h runs a period's stored messages through the new version's engine and your project's code, sends nothing and writes nothing, and compares what each destination's code decides (sent and exactly what, filtered and why, quarantined and why) with the installed version run beside it on the same messages, with what was recorded, and with kept outputs. ductus upgrade --check --replay 1d runs it as part of the check.

Why: "what will change?" is the question a change board asks, and the usual evidence is weeks of testing by hand on a test system. A replay of production's own traffic answers it in minutes, message by message, field by field.

When not: it isn't a load test (one thread, one message at a time: measured), and it can't compare code that writes (db().execute, a POST): those messages are reported as not comparable (why).

What it compares with, by default: the version installed on the server (/usr/bin/ductus, C:\Program Files\Ductus\ductus.exe), run beside it; that needs the installed one to be 0.6.6.4 or later (why). Without an installed version, outputs are compared only where they were kept (save_output: true). Every other destination is compared by its decision alone, and the report counts what it could compare separately:

CountMeans
identicalCompared whole and the same: the output, with the installed version's or a kept one; or a filter or quarantine with its reason (those have no output)
same decision (outputs not compared)Both sent, but there was nothing to compare the output with: a changed field wouldn't show
differentThe output, the decision or its reason changed: the replay fails (exit 1)

When no output was compared at all, the report ends with ⚠ outputs not compared and the fix, in the terminal, the --json document (outputs_not_compared) and the HTML page alike; upgrade --check --replay shows it as a warning (exit 0: nothing differed, but little was checked). On a machine without the installed version, name it: --against /path/to/old/ductus.

A difference the installed version shares with the new one isn't the upgrade's: the data it looks up changed since the message was sent. The report calls it drift and doesn't fail for it (nuance).

For a change board, --report replay.html writes a page with the versions, the period, the counts and a SHA-256 checksum. Examples in it quote field values from real messages: keep it as you keep patient data, or make it with --hide-values (nuance). The same tool compares a code change with --baseline <git revision>:

$ ductus replay --since 1h --baseline HEAD --report replay.html
adt-in: 300 message(s) replayed in 0.0 s
ductus replay: version 0.6.0-dev against the project at HEAD, 2026-10-31 15:25 to 2026-10-31 16:25 UTC, environment dev (nothing was sent)

adt-in: 300 message(s) in 0.0 s
  ✗ archive: 300 compared, 300 different
      vs the project at HEAD: MSH-5 differs in 300 message(s) (e.g. message 406: "RECEIVER" → "LAB")
  ✗ lab: 300 compared, 300 different
      vs the project at HEAD: MSH-5 differs in 300 message(s) (e.g. message 406: "RECEIVER" → "LAB")

300 message(s), 600 comparison(s): 0 identical, 600 different, 0 drift, 0 not comparable, 0 waiting (0.1 s)
report: …/replay.html (sha256:794fea6a30fe050d12f3253ba1240f39f40f5219f780783cb192b06c3d20489f)

Here the earlier project ran beside this one, so every output was compared: the change shows field by field, on the same messages that passed by decision above.

More on replay, with what the clock and Math.random do in it: Replay a period before a change.

Upgrading, by how you installed

Linux packages: the handover

sudo apt install ./ductus_0.6.1_amd64.deb          # Ubuntu, Debian
sudo dnf install ./ductus-0.6.1-1.x86_64.rpm       # RHEL, Rocky, Alma

What happens: the package replaces the program and runs systemctl reload ductus. The new version starts beside the running engine, on the same listening sockets: it reads the project, applies the database changes and loads every interface's code while the old one carries on. Once it's ready, the old engine stops cleanly, lets go of its interfaces, and the new one takes them and sends. Receiving never stops and sending pauses for a moment (measured under real systemd, 4 senders at 200 messages/s: 0 refused, longest wait for an ACK 0.17–0.22 s, nothing lost: nuance).

The handover's own lines, from an engine handing over to the program now on disk (ductus handover is what systemctl reload runs, with the service's control socket; these are the lines journalctl -u ductus shows):

$ ductus handover --control …/control
started …/ductus (pid 1312): it reads the project, migrates the database and loads every interface's code while this one runs
ductus 0.6.0-dev (pid 1312) is ready after 104 ms and receives beside this one: stopping this one
this engine stopped in 1 ms and let go of its interfaces: the new one takes them
handed over to ductus 0.6.0-dev (pid 1312): receiving never stopped, sending paused 41 ms
  • A new version that can't start (a broken project, a failed database change, another engine owning an interface) leaves the old one serving: the install says so, journalctl -u ductus says why, and senders notice nothing. Fix what it names and sudo systemctl reload ductus (nuance).
  • A start longer than 5 minutes (a large database change) is given up by the handover: sudo ductus handover --timeout 30m instead of the reload.
  • For a few seconds two engines run, so the server needs memory for both.
  • From a development build before 0.6.6.3 (no released version is), the package restarts instead of handing over; from one before 0.6.6.2 that restart refuses connections for a moment (nuance).
  • A drain refuses a handover: resume first, or stop and start.

Windows installer

msiexec /i ductus-0.6.1-x64.msi /qn          # as an administrator; no need to uninstall first

What happens: the installer stops the service cleanly, replaces the program and starts it again; C:\ProgramData\Ductus (settings, project, logs) is never touched. The engine is down for a few seconds (an MSI upgrade measured 3.1–3.5 s: a few connections refused or reset, nothing lost), so choose a quiet moment (nuance). A handover like Linux's is planned for v1.0. Details (a service stopped on purpose, a password account, Restart Manager): Windows installer.

The portable archive

Replace the program and hand over, as the package would:

sudo install -m 0755 ductus-0.6.1/ductus /usr/bin/ductus
sudo systemctl reload ductus

Without the package's service (ductus run under your own supervisor), stop and start it with the new program; the senders see a short gap. To hand over there too, start the engine with DUCTUS_CONTROL=/some/dir/control and run ductus handover --control /some/dir/control after replacing the program (nuance).

Docker

Build the new version's image (docker build -t ductus:dev . in the new version's source, as in Docker), then docker compose -f packaging/docker/compose.yaml up -d: Compose sees the new image and replaces the container. Its ports close while the new one starts, so senders are refused for a moment. Docker is for trying and developing until v1.0.

ductus dev on a laptop

Replace the program and start ductus dev again. Its Postgres (in .ductus/postgres) is kept and updated by the new version like any other database; ductus dev --reset starts from an empty one.

Database changes (migrations)

What: a new version may change the database: a new table, a new column, a new index. Each change is a numbered migration, applied once, in order, by the first start of the version that carries it, and recorded in the database (_sqlx_migrations). ductus upgrade --check lists the ones still to apply, each with a sentence saying what it does, for example 0025: "keeps the names accounts had before, and the directory's id for each directory account".

They only add. Within a major version, a migration never renames or removes anything the version before uses: the old program keeps working on the new tables. That's what makes going back safe, and what lets several engines on one database be upgraded one at a time. A store test, on every push, starts the engine on a database that has a migration it doesn't know, as an older program meets a newer one's database.

Heavy ones. Most take milliseconds. One that works through an existing table row by row (an index built, a column filled) is marked ⚠ with the table and its size now: while it runs, the start waits, and storing or sending waits as it says (nuance). Under the Linux package's service senders wait in the queue rather than being refused, for as long as it takes. Plan the window by the size shown, and time it on a restored backup first. There have been none since v0.5 (0011, the queue index, was the last).

When one fails, the start refuses with the database's reason, and so does every later start of that version until the cause is fixed. Each migration runs in one transaction, so a failure leaves nothing half done: the database is as it was before it, and the version before still runs on it (going back keeps you running meanwhile). A migration recorded as failed (upgrade --check shows it ✗ with its number) is one that couldn't run in a transaction; none so far does. Then every version refuses to start until it's repaired: don't edit _sqlx_migrations by hand, ask for help.

Backups are the DBA's. The engine needs no backup of its own before an upgrade, but your usual point-in-time recovery (pgBackRest, WAL archiving) is what undoes anything at all (PostgreSQL).

Going back (rollback)

What: installing the previous version again. The database stays as the new version left it; the previous version runs on it, ignoring what it doesn't know.

When: the new version misbehaves in a way upgrade --check and replay didn't show. Decide by what you see: a project problem is fixed in the project and deployed (systemctl reload); a problem in Ductus itself is a rollback, and an issue.

Installed withGoing back
Linux packagesudo apt install -y --allow-downgrades ./ductus_0.6.0_amd64.deb (apt asks before a downgrade; -y answers it), or sudo dnf downgrade ./ductus-0.6.0-1.x86_64.rpm: the package hands over to the older program as an upgrade does (when the older one is 0.6.6.3 or later; before that, it restarts). On a server shows one
Windows installerThe installer refuses to install an older version over a newer one ("A newer version of Ductus is already installed. Uninstall it first to go back."). Uninstall (msiexec /x), then install the older MSI: C:\ProgramData\Ductus and the database stay. Give SERVICEACCOUNT= again if the service ran as another account (a gMSA): the install sets the account afresh
Portable archivePut the older program back and sudo systemctl reload ductus
DockerRun the older image

What going back doesn't undo: whatever the new version wrote stays in the database: tables and columns the older one doesn't read. Messages received meanwhile are kept and sent as usual; features only the new version had stop until you upgrade again. Going back across a major version isn't possible (from 1.0 on: the major may have removed what the older one needs).

What going back does to a database the new version migrated: nothing. The older program doesn't undo the new version's migrations, can't, and needn't: they only added. At its start it sees migrations it doesn't know in _sqlx_migrations, leaves them as they are, and runs on the tables it knows; what the new version added (a table, a column) stays, unused, until you upgrade again, when the new version finds its migrations already applied and applies nothing twice. ductus upgrade --check lists them as "applied by a newer version than this one" (✓, not a problem). Two things to know:

  • Data written only into what the new version added isn't read by the older one while it runs. Messages themselves are in tables every version reads, so none is lost or left waiting.
  • A migration that failed left nothing behind: each runs in one transaction, so a failure undoes all of it. The new version refuses to start (each time, with the reason) and the older one runs on the database as it was (Database changes).

Both are shown on a server, under traffic and tested on every push: a store test starts the engine on a database with a migration it doesn't know, and the v0.6 demo goes back with a package while a sender sends.

On a server: the v0.6 demo

What: an upgrade and a downgrade of the Linux package, each while a sender sends, on the server Your first interface set up (Ubuntu 24.04, MLLP over TLS with client certificates, Postgres on another machine). The commands and outputs are from scripts/demo-v06.sh, which runs them under real systemd on every push.

Why: "receiving never stops" is a claim; this is what a sender saw while it happened, and what the database looks like after going back.

The demo's two packages (0.6.0~dev.1 and ~dev.2) hold the same program, so they show the mechanics of an upgrade, not a version's changes; a release's files are named 0.6.0, 0.6.1.

Check first, from the new version's archive

The new version's portable archive, unpacked anywhere, checks the running server with the service's settings, and replays the last day's traffic through the new version beside the installed one:

$ cd /tmp
$ tar -xzf ductus-0.6.0-dev.2-linux-x64.tar.gz
$ sudo -u ductus ./ductus-0.6.0-dev.2/ductus upgrade --check --replay 1d --env-file /etc/ductus/ductus.env --root /var/lib/ductus/project
ductus upgrade --check: what version 0.6.0-dev would do here (nothing is changed)

The database
✓ migrations: nothing to apply

Engines on this database
✓ 18e3c2043934cad1-14e on ductus1 (pid 334): version 0.6.0-dev, environment prod, since 2026-10-31 23:46 UTC, runs 1 interface(s)

The project (/var/lib/ductus/project, environment prod)
✓ validate: valid
    ⚠ database: plain, no TLS: the server doesn't offer it (sslmode=prefer); whoever answers at its address is believed: add sslmode=verify-full to DUCTUS_DATABASE_URL (sslrootcert=/path/ca.pem for a private CA)
✓ test: 1 passed, 0 failed

Between versions
✓ required stops: none (versions before 1.0 upgrade to any later one)
✓ deprecated settings: none used

The last 1d replayed through 0.6.0-dev (nothing is sent)
✓ replay against version 0.6.0-dev (installed): 100 message(s), 100 comparison(s), 100 identical, 0 different

ready to upgrade to 0.6.0-dev (checked in 0.9 s)

On the server, the replay found the installed version (/usr/bin/ductus) and ran it beside the new one, so every output was compared whole: "100 identical", where the lab example above could only compare decisions. (The database warning is the demo's database server offering no TLS; a real one should.)

The upgrade, under traffic

A sender keeps 4 connections open and sends 1,000 messages at 200 a second, over TLS with its client certificate; 1.5 s in, the package is installed:

$ sudo apt install ./ductus_0.6.0~dev.2_amd64.deb
…
Unpacking ductus (0.6.0~dev.2) over (0.6.0~dev.1) ...
Setting up ductus (0.6.0~dev.2) ...
$ journalctl -u ductus -o cat --no-pager | grep "^handed over"
handed over to ductus 0.6.0-dev (pid 637): receiving never stopped, sending paused 180 ms

What the sender saw (ductus gen's last line), and what the receiver got:

retries 4, rejected 0, refused 0, dropped 4, longest wait 504 ms
unique 1000 | duplicates 0 | out of order 0 | patients 100
  • Refused 0: no connection was turned away at any moment.
  • Dropped 4, retries 4: each of the 4 open connections was closed once, by the old engine as it let go, with no message lost on it, and opened again at once on the new one. A sender's MLLP client does this by itself; to its operators it looks like nothing.
  • Longest wait 504 ms: the slowest ACK, from first try to ACK, for a message that waited out the close, the reconnect and the TLS handshake.
  • Every message once and in order at the receiver; sending paused 180 ms while the new engine took the interfaces.

Over four runs of the demo on a laptop (an upgrade and a downgrade each), the longest wait was 0.34–0.52 s and sending paused 26–180 ms; on CI's runner 143 ms (upgrade) and 157 ms (downgrade), paused 14 and 18 ms. Refused was 0 and the receiver complete, with no duplicate and nothing out of order, every time. Without TLS, in the same kind of container, demo-packages.sh measured a reload at 75–81 ms longest wait and a package upgrade at 0.30–0.45 s.

Going back, under traffic

To see what an older version does on a database a newer one migrated, the demo first records one migration the older program doesn't know (number 9999, with a table of its own), as if the newer version had added it; both packages hold one program, so this stands in for a real one. Then the same sender sends while the older package goes in:

$ sudo apt install -y --allow-downgrades ./ductus_0.6.0~dev.1_amd64.deb
…
The following packages will be DOWNGRADED:
  ductus
…
dpkg: warning: downgrading ductus from 0.6.0~dev.2 to 0.6.0~dev.1
…
Unpacking ductus (0.6.0~dev.1) over (0.6.0~dev.2) ...
Setting up ductus (0.6.0~dev.1) ...
$ journalctl -u ductus -o cat --no-pager | grep "^handed over" | tail -1
handed over to ductus 0.6.0-dev (pid 832): receiving never stopped, sending paused 26 ms
$ cd /var/lib/ductus/project
$ sudo -u ductus ductus upgrade --check --env-file /etc/ductus/ductus.env
ductus upgrade --check: what version 0.6.0-dev would do here (nothing is changed)

The database
✓ migrations: nothing to apply
✓ applied by a newer version than this one: 9999 (fine: migrations only add, so this version runs on them)
…
ready to upgrade to 0.6.0-dev (checked in 0.5 s)
retries 4, rejected 0, refused 0, dropped 4, longest wait 349 ms
unique 1000 | duplicates 0 | out of order 0 | patients 100

A downgrade is a handover like an upgrade: refused 0, every message once and in order. The newer version's migration and its table are still there, and the older engine runs beside them. (upgrade --check with the installed program describes this version's view: nothing to apply, and one migration it doesn't know.)

On Windows: the same, with a gap

demo-windows.ps1 (every push, on CI's Windows runner) upgrades the MSI while a sender sends over TLS with its client certificate: 4 connections, 200 messages a second for a minute, the installer started 3 s in and done 3.4 s later. What the sender and the receiver saw:

sent 12000 messages over 4 connection(s) in 60.0s (200/s), retries 12, rejected 0, refused 8, dropped 4, longest wait 3291 ms
received 12200 | unique 12200 | duplicates 0 | out of order 0 | patients 300

(the receiver's count includes the 200 sent before). While the installer stopped, replaced and started the engine, the sender's connections were closed (dropped 4) and its new ones refused for a moment (refused 8); its slowest message waited 3.3 s for its ACK. Nothing was lost, sent twice or out of order: the sender resent on its own, as MLLP senders do. That's the Windows gap until the handover comes to Windows (v1.0): pick a quiet moment.

Going back on Windows is uninstall then install: the demo upgrades to a build numbered as the next minor version, tries the older MSI over it (refused, exit 1603: "A newer version of Ductus is already installed. Uninstall it first to go back."), uninstalls, installs the older one, and starts it on the same settings, project and database (Windows installer).

After the upgrade: confirm

  • systemctl status ductus shows "running N interfaces" (Windows: Get-Service Ductus is Running, and the Event Log has the start, ID 1000).
  • The console's Overview says the version, and "took over from v…" after a handover; the audit log has an engine.handover entry (audit actions).
  • ductus status --token … shows every interface ok within a minute; ductus doctor checks every connection from the server.
  • If you kept the replay's report, file it with the change: it's the record of what the upgrade was expected to change.

Troubleshooting by symptom

Find what you're seeing below. Each section shows the command that tells you what's going on, what its output means, and what fixes it. Every output on this page is from a real engine, in a lab with a lab system played by ductus sink and a sender by ductus gen.

Four commands answer most questions, in this order:

CommandAnswersNeeds
ductus statusWhat's waiting, sent, quarantined, per interface and destination; with a token, each one's health in wordsthe database; a token for health
ductus hotspotsWhere a backlog sits and why: the message holding it up, the receiver's answera viewer token
ductus doctorEvery connection the project needs, checked from this server, step by step, with the fix for the step that failsthe project
The logThe why, in order, with times: journalctl -u ductus -f (Linux), C:\ProgramData\Ductus\logs\ and the Event Log (Windows)access to the server (Logs)

The same answers are in the console: the Overview and the interface pages show health, the Hotspots page the backlog, Connections the doctor's checks.

By workload: a feed (MLLP or HTTP in, stored, sent on) is what most of this page is about. An API in respond mode stores nothing, so nothing waits: its caller gets the error instead, and the log says why. A batch (files, SFTP, schedules) shows its problems at pickup, in the log, and its messages afterwards behave like a feed's.

Messages are stuck

You see: a receiving system says it isn't getting messages; the console shows a destination behind or stalled; an alert about a backlog.

$ ductus status
adt-in               pending      100
adt-in               sent         300

by destination:
adt-in → archive     sent         400
adt-in → lab         pending      100
adt-in → lab         sent         300

health:
adt-in               ok, received 8s ago
  → archive          ok, sent 8s ago
  → lab              ok, sent 1 min ago

The lab destination has 100 waiting and sent nothing for a minute. Health says stalled only after a minute without progress (what each state means), so early on, the counts and hotspots say more:

$ ductus hotspots
adt-in → lab: 100 waiting in 20 of 64 lanes; blocked by message 301 (key 100001, lane 23, 4 attempts, waiting 8s): Connection refused (os error 111); all 99 behind it wait (one worker: order)
  lane   worker    waiting  first msg    key                     age attempts  last error
  7      0               5  312          100012                   8s        0  
  8      0               5  317          100017                   8s        0  
…
  23     0               5  301          100001                   8s        4  Connection refused (os error 111)
…

And the log, one line per failed try, each with when it tries again:

WARN ductus_runtime::worker: delivery failed; retrying in 4s interface=adt-in destination=lab id=301 attempts=3 error=Connection refused (os error 111)
WARN ductus_runtime::worker: delivery failed; retrying in 8s interface=adt-in destination=lab id=301 attempts=4 error=Connection refused (os error 111)

What it means, by what hotspots says:

Hotspots saysMeaningDo
Connection refused, timed out, no answer on the first message, every lane waitingThe receiver is down or unreachableAsk its owner; ductus doctor --interface <name> checks the way from this server. Nothing is lost: everything goes out, in order, when it's back
AE: …The receiver answered application error: try again laterSee AE or AR
A TLS reasonThe handshake failsSee TLS
"blocked by message M" while other lanes were sentOne message keeps failing; the others of its worker wait behind it, to keep orderLook at message M: what the receiver says about it. Fix and let it go through, or make that destination give up after some tries (retry.max_attempts) so it's quarantined (one stuck message)
paused, by whom and whySomeone paused itductus resume (or Resume on the console) once whatever they paused it for is done
Nothing waiting, but nothing arrivesThe sender isn't sendingSee A sender says "connection refused"; monitor: { active_within: 30m } makes the silence an alert next time

Once the receiver is back, the destination waits out its current retry (doubling up to retry.max_backoff_ms, 60 s by default), then sends everything, in order. In the lab, the 100 waiting were sent 8 s after the receiver came back (up to a minute). Temporary failures are retried forever unless you say otherwise (nuance).

When the engine itself can't reach its database, receiving goes on and sending pauses until it's back (nuance): see Postgres connection errors.

A partner answers AE or AR

You see: a destination in error with quarantined messages, or messages waiting with a receiver's text as their reason.

The two answers mean different things, and the engine treats them differently:

The receiver answersIt meansThe engineDefault
AE (application error)"Something went wrong on my side; try again"Tries again, like a receiver that's down; the message holds up its lane meanwhileforever (retry.max_attempts sets a limit)
AR (application reject)"I won't take this message"Quarantines it at once, for a person to look at; the messages behind it go onon_reject: quarantine; retry for receivers that answer AR when they're merely busy (destinations)

An HTTP receiver's answers map the same way: 408, 429 and 5xx are tried again, any other 4xx is quarantined.

$ ductus status
adt-in               sent         390
adt-in               quarantined  10

by destination:
adt-in → archive     sent         400
adt-in → lab         sent         390
adt-in → lab         quarantined  10

health:
adt-in               ok, received 15s ago
  → archive          ok, sent 15s ago
  → lab              error: 10 quarantined (last error: rejected (AR): rejected by the receiver)

The text after rejected (AR): is the receiver's own (MSA-3): the first thing to read, and what to quote when you ask its owner. Each one is logged:

WARN ductus_runtime::worker: destination rejected; quarantined interface=adt-in destination=lab id=395 error=rejected (AR): rejected by the receiver

The quarantine lists them (the console's Quarantine page, or the API; ordering_key and control_id find the message on the receiver's side):

$ curl -s -H "Authorization: Bearer $DUCTUS_API_TOKEN" 'http://127.0.0.1:7700/api/v1/quarantine?limit=2'
{
    "deliveries": [
        {
            "id": 395,
            "received_at": "2026-10-31T12:53:43.447943Z",
            "interface": "adt-in",
            "destination": "lab",
            "attempts": 0,
            "last_error": "rejected (AR): rejected by the receiver",
            "quarantined_at": "2026-10-31T12:53:58.559595Z",
            "ordering_key": "100015",
            "control_id": "95",
            "size": 150
        },
…

Do:

  1. Read the receiver's reason, and the message (the console's message page shows it field by field).
  2. If the data is wrong at the source, the sender fixes it and sends again; dismiss the rejected one with a reason (Dismiss).
  3. If your mapping is wrong, fix the code, replay to see what changes, deploy, then resend the quarantined ones: they go through the new code (Resend). One message by hand: Edit and resend.
  4. If the receiver rejected it by mistake (it was down for maintenance and said AR), resend once it's back.

An AE that never clears looks like stuck messages: hotspots shows AE: and the receiver's text on the first message. If the receiver will never take that message, give the destination a limit (retry: { max_attempts: 10 }) so it's quarantined instead of holding up its lane (delivery).

A TLS handshake fails

You see: messages waiting for a receiver with a TLS reason; a sender who says they can't connect since TLS was turned on; doctor ✗ on a TLS step.

Outbound (the engine to a receiver): each try fails with the reason, and what to change. Here the receiver's certificate comes from a CA this server doesn't trust (a self-signed lab certificate):

WARN ductus_runtime::worker: delivery failed; retrying in 4s interface=adt-in destination=lab id=401 attempts=3 error=TLS: the receiver's certificate isn't from a CA we trust (name its CA in `tls.ca`, or install it on this server)

ductus doctor shows the same at the step that fails, with what the certificate says:

$ ductus doctor --interface adt-in
…
✗ MLLP destination  127.0.0.1:6663 MLLP over TLS
    used by  adt-in → lab
    address  127.0.0.1 (an address, nothing to look up)
    connect  127.0.0.1:6663 from 127.0.0.1 in 0.2 ms
  ✗ TLS      not trusted: its issuer isn't one this server trusts (self-signed, or a private CA); certificate for 127.0.0.1, self-signed, valid until 2027-10-31 (364 days)
  → Name the issuing CA in `ca` (a `certificates:` entry in resources.yaml), or add it to this server's trusted certificates, or ask for a certificate from a public CA.
…

The fix, as it says: name the receiver's CA (here, its own certificate) in resources.yaml, and point the destination's tls: at it:

# resources.yaml
certificates:
  lab-ca:
    ca_file: tls/lab.pem
# interfaces/adt-in/interface.yaml, in part
destinations:
  lab: { mllp: { host: 127.0.0.1, port: 6663, tls: { ca: lab-ca } } }
  archive: { file: { dir: out/archive } }

Deployed with a handover (systemctl reload ductus on a server; the lab ran ductus handover, which is what the reload runs), the waiting messages went out within 2 s, and doctor agrees:

✓ MLLP destination  127.0.0.1:6663 MLLP over TLS
    used by  adt-in → lab
    address  127.0.0.1 (an address, nothing to look up)
    connect  127.0.0.1:6663 from 127.0.0.1 in 0.3 ms
    TLS      TLS 1.3, post-quantum key exchange (X25519MLKEM768), trusted; certificate for 127.0.0.1, self-signed, valid until 2027-10-31 (364 days)

Other reasons, and their fixes:

The reason saysDo
the certificate isn't valid for the nameConnect by a name the certificate lists (doctor shows them), or ask for one with the name you use
expired, or not valid yetThe receiver renews it; "not valid yet" can also be this server's clock
the receiver wants a client certificateName a pair in tls: { certificate: … } (TLS to receivers)
presents a CA's certificate as its ownA lab receiver made with openssl req -x509: name that exact certificate in ca:
"timed out waiting for ACK" right after turning tls: onThat port speaks plain MLLP: ask which port is TLS

Inbound (a sender to the engine): the engine logs each refused sender once a minute with the reason ("the sender isn't speaking TLS", "no client certificate", "a subject not on the list"). No line at all means the connection never reached the engine: a firewall, or the wrong port. The full table is in TLS: when it doesn't work (why once a minute). With TLS 1.3 a sender whose client certificate is refused learns it at its first read, not when it connects, so its own error may say the connection closed rather than the certificate.

Postgres connection errors

You see: the engine won't start and names the database; or it runs, but sending has paused; or messages wait on a lookup (db()).

The engine's own database, at start. The start says what's wrong with the URL in DUCTUS_DATABASE_URL (on a server, in /etc/ductus/ductus.env or C:\ProgramData\Ductus\ductus.env):

$ ductus run
Error: connecting to PostgreSQL at 127.0.0.1:40713: the password for `postgres` was refused: check the password in the database URL (DUCTUS_DATABASE_URL; characters like @ or / in it are written %40, %2F)
$ ductus run
Error: connecting to PostgreSQL at 127.0.0.1:40713: there's no database `nosuch` on that server: create it (the PostgreSQL install guide says how) or correct its name in the database URL
$ ductus run
Error: connecting to PostgreSQL: no host is named `db1.hospital.invalid` (DNS has no address for it): check the host in the database URL (DUCTUS_DATABASE_URL)

A refused connection is tried again for 30 s before the start gives up, so a database that's starting at the same time as the engine (a server's boot) doesn't fail it (nuance):

$ ductus run
Error: connecting to PostgreSQL at 127.0.0.1:5999: refused (tried for 30 s): nothing accepts connections there. Postgres is down, listens on another port, or a firewall rejects it: check the host and port in the database URL (DUCTUS_DATABASE_URL), and that Postgres is running

ductus doctor checks the same connection step by step (address, connect, TLS, login, how fast a commit is), and is the quicker way to try a fix:

$ ductus doctor
…
✗ engine database  127.0.0.1:5999 Postgres
    used by  the engine (DUCTUS_DATABASE_URL)
    address  127.0.0.1 (an address, nothing to look up)
  ✗ connect  127.0.0.1:5999 from 127.0.0.1: refused
  → 127.0.0.1 is reachable but nothing accepts connections on port 5999: the service is down, listens on another port, or a firewall at the host rejects it. Ask its owner whether it's up on 5999.
The start or doctor saysDo
the password was refusedCorrect it in the URL (special characters percent-encoded); a secrets manager's value may have changed
no database xCreate it (PostgreSQL), or correct the name
the server refused the login (pg_hba.conf, the role)The DBA allows this server's address for the login in pg_hba.conf, or creates the role
refused, or no answerPostgres is down, on another port, or a firewall is in the way: Ports lists what the engine connects to
a TLS reason (the certificate, a CA)TLS to the database; the start and doctor name the URL parameter to add

While it runs. If the database goes away, the engine keeps receiving (acknowledging only what it could store) and pauses sending; it carries on by itself when the database is back (nuance). Behind PgBouncer in transaction mode the engine can't hold its lock (nuance).

A database your code looks things up in (db("patient_db")): a failed login or an unreachable server makes the message wait and retry, not fail (nuance); the reason is on the destination (hotspots) and in the log, and doctor checks every database in resources.yaml the same way as the engine's.

The service won't start

You see: systemctl start ductus fails or the service keeps restarting; on Windows the service stops right after starting.

Where the reason is:

systemctl status ductus                 # the last lines, and the exit code
journalctl -u ductus -n 50              # the start's lines
Get-Service Ductus
Get-WinEvent -FilterHashtable @{ LogName = 'Application'; ProviderName = 'Ductus' } -MaxEvents 10 | Format-List TimeCreated, Id, Message

On Windows, a failed start is Event Log 1003 with the reason; the full start is in C:\ProgramData\Ductus\logs\ (Event Log IDs). Before deploying, ductus validate finds most of what would stop a start (a configuration error, a type error, a certificate, the lanes), without starting anything.

The reasons a start refuses, as it says them:

A mistake in a configuration file names the file, the key and what's allowed:

$ ductus run
Error: in …/interfaces/adt-in/interface.yaml

Caused by:
    unknown field `destinatons`, expected one of `name`, `source`, `script`, `format`, `output`, `ordering`, `isolation`, `workers`, `dedicated_pools`, `durability`, `destination`, `destinations`, `retry`, `dedupe`, `rate_limit`, `timeout`, `about`, `retention`, `quarantined`, `monitor`, `alert`, `custom`

A port another program holds names the port and how to find who:

$ ductus run
Error: interface `adt-copy`

Caused by:
    0: binding 0.0.0.0:6661
    1: port 6661 is in use: another program listens on it (another engine, or a test tool); find it with `sudo ss -ltnp 'sport = :6661'`, or give this one another port
$ sudo ss -ltnp 'sport = :6661'
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0      128          0.0.0.0:6661      0.0.0.0:*    users:(("ductus",pid=1696,fd=28),("ductus",pid=1696,fd=17))

Another engine already running one of these interfaces on the same database is refused on purpose, with exit code 3:

$ ductus run
error: another engine is running one of this engine's interfaces on this database:
  adt-in
    owned by: DESKTOP-GCT6E6V, pid 1696, ductus 0.6.0-dev, started 2026-10-31 12:54:28 UTC (instance 18e39e6d3fcb787f-6a0)
Two engines running one interface would both send its messages: duplicates, and per-patient order breaks.
Stop that engine first, or remove these interfaces from this one's project.

And the database errors above.

How the service manager retries:

ExitMeansLinux (systemd)Windows
3Refused on purpose: another engine owns an interface, or a change to an interface's lanes would strand waiting messagesNot retried (RestartPreventExitStatus=3): fix it, then systemctl start ductusEvent 1003; the service stays stopped: fix it, then Start-Service Ductus
1Anything else (a configuration error, the database, a port, a certificate)Retried every 5 s, so it starts by itself once the cause is gone; each try is in the journalEvent 1003; the service stays stopped: fix it, then Start-Service Ductus

On Windows the service's recovery settings restart it only when a running engine dies (after a minute, twice a day); a start that fails waits for you.

While a start fails, the ports are closed, so senders are refused: that's on purpose, so they don't wait on an engine that isn't there (nuance). On Windows, a service account with a password leaves the service stopped after an install or upgrade until ductus service account NAME --start (event 1005); a missing "Log on as a service" right is a Group Policy matter.

Other symptoms

SymptomWhere to look
A sender says "connection refused"Logs: when a sender says "connection refused"
A sender is refused by allow:The log names the address and the list (who may connect)
An alert didn't reach anyoneductus alerts test (Alerts in practice)
An interface was stopped (its code ran past its timeout three times)Runaway scripts; the code needs a fix and the engine a restart
A certificate is about to expireRenewing: replace both files, no restart
Someone can't sign inPeople
Upgrade or deploy problemsUpgrade

Ductus architecture

How the engine works, and the rules that keep it correct. Read this before changing the runtime. What interface developers need to know about the resulting behaviour is in the nuances; the tables are described in schema.md (for transparency, not an API).

The shape of it

 sources                 store (Postgres)              workers                       destinations
 ───────                 ────────────────              ───────                       ────────────
 MLLP listener ─┐                                      pool thread 0..N  ─┐          MLLP client
 HTTP (queue)  ─┤   Intake          messages table ─► (shared V8 isolate) ├─► Sender ─ HTTP client
 File / SFTP   ─┼─► persist, then ─► (day partitions,   dedicated threads ─┘          File
 Schedule      ─┘   acknowledge      = the queue)      (own isolate)                 SFTP
                                                        │
 HTTP (respond) ───────────────► channel ──────────────►┘ transform(req) → HTTP response (no queue)
 Internal ◄──────────────────────────────────────────────── Route (destination `interface: x`): stored in
                                                            x's queue in the transaction that marks it sent

One binary (ductus) plus PostgreSQL. No other services. The engine also serves its REST API (/api/v1, default 127.0.0.1:7700), which is how the UI, scripts and ductus drain see and steer it, and the web UI itself (embedded in the binary) on the same listener. Every listener can take TLS (see "TLS on our listeners").

Invariants (never break these)

  1. Persist before acknowledge. A source only ACKs (MLLP AA, HTTP 202) after the message row is committed. If storing fails, the sender gets a retryable error (AE, 503). Nothing acknowledged is ever lost by an engine crash. For files, "acknowledge" is moving or deleting the file: that happens only after every record in it is committed.
  2. At-least-once delivery. A delivery stays pending until its destination confirms. A crash after sending but before recording sent re-sends it on restart; dedupe at ingest and idempotent receivers cover this. Sent statuses are flushed in batches of ≤50 and always before a retry back-off, so the re-send window is small.
  3. One owner per lane and destination = per-key order. The ordering key (e.g. MRN) hashes (FNV-1a, stable forever: ductus_core::partition_of) to one of N virtual partitions ("lanes"). For each destination, each lane is owned by exactly one worker (slot), which processes its pending deliveries strictly by message id. A failed message blocks its slot's lanes for that destination until it succeeds or is quarantined: that's the price of ordering. Across engines sharing a database, each interface has one engine (interface_owners, see "Engines on one database"): nothing may read an interface's queue or change its rows before the engine has claimed it. Storing new messages isn't owned: any engine may (a takeover receives before it claims).
  4. Migrations are expand-only. Never drop or rename a column/table in a migration that an older binary still uses. Older binaries tolerate unknown newer migrations (set_ignore_missing), so rollback = reinstall the previous binary. Every migration file from 0025 on starts with its header (-- Upgrade: what it does, -- Heavy: each existing table it works through, or none), which ductus upgrade --check shows with the table's size; 0001–0024 are described in ductus_store::migrations (an applied file can't change: sqlx checksums its whole text).
  5. No credentials in interface code. Secrets come from secrets/<env>.json (encrypted) via ${secret:NAME} in YAML or secret() in code; connections are named resources (resources.yaml).
  6. Interface code is sandboxed from the network and filesystem except through engine services (db(), fetch(), env(), secret()). Remote module imports are refused.

Crates

craterole
ductus-coreinterface.yaml (config.rs), alerts settings and the pure rules of an alert's life (alerts.rs), company-defined documentation fields (docs.rs: docs-schema.yaml declares them, a flat custom: key on interfaces, destinations and resources.yaml entries holds the values; checked by ductus validate only, never at startup; the engine writes the project's documentation to docs_snapshot at start), formats (format.rs), environments + ${VAR} substitution (env.rs), encrypted secrets (secrets.rs), resources.yaml (resources.rs: Postgres and SQL Server databases, SFTP servers, HTTP APIs, LDAP directories; each may name its application/vendor and owner), directory sign-in (ldap.rs: auth.ldap, search filters with the username escaped, membership filters, groups → role), about: and host/subnet matching (about.rs), ductus impact and the about: completeness check (impact.rs), owners and their in-place reassignment (owners.rs: what each owns, interfaces, destinations and resources.yaml entries; find matches an owner text to one account by username, then email, then display name, two at one level naming nobody, and reach adds teams in owners: and addresses, shared by alerts, the API and ductus owners --check; reassign edits owner: values in the YAML text, then checks the result parses to exactly the intended change, in resources.yaml finding each value's entry by changing it alone), schedule timing (schedule.rs), lane hashing, port-conflict checks, listeners and outbound connections (ports.rs)
ductus-hl7HL7v2 essentials in Rust: field lookup by path, ACK generation (ingest only; interface code uses the SDK)
ductus-mllpMLLP framing, listener (in order per connection), reconnecting client (with or without waiting for ACK)
ductus-storePostgres: migrations, messages (partitioned by retention tier and time, overall status), retention (retention.rs: changes, moves, dropping expired partitions, the held area), deliveries (status per destination; each destination's work queue), dedupe keys, reconcile at startup; for the API (api.rs): tokens (hashed), the message browser, queue counts
ductus-scriptEmbedded V8 (deno_core): module loader (local files + embedded @ductus/sdk), multi-interface isolates, async calls (begin/settle), runaway guard (guard.rs: time budgets, heap limit), native ops (Services trait), embedded TypeScript 6 type checker + shared-pool lints, and analyze (the db("...")/api("...") calls code makes, resolved through import aliases by the checker, plus the import graph, so validate and impact know what each interface uses)
ductus-runtimeThe engine: planning threads, Intake (ingest for any source, single or batched), MLLP + HTTP servers, file/SFTP watcher (files.rs) and record splitter (split.rs), SFTP client (sftp.rs), schedules (schedule.rs), workers, destinations, rate limiter, format-aware keys, engine services (Postgres pools; SQL Server pool in mssql.rs), connection checks for ductus doctor (doctor.rs: what to check from the project (plan, plus what alerts connect to), then DNS, TCP to every address with the source address, TLS through the same platform verifier as the HTTP client (the certificate chain read either way), then each protocol's login through the engine's own connection code; certificates mode stops after TLS), the engine's own scheduled checks (certificates.rs), what code uses (project.rs: the type checker's db/api references, shared by validate, impact, doctor and the API), the drain gate (gate.rs), retention (retention.rs: applies each interface's, runs changes, drops expired partitions), the running engine as the API sees it (control.rs: interfaces, health, queue monitor, drain, pauses), counts and timings (stats.rs), last activity and pauses kept across restarts (activity.rs), and the REST API (api/: axum + utoipa, browser sessions, and the embedded web UI)
ductus-cli (ductus)init (init.rs: a new project that passes test and validate), run, dev (dev.rs: run with a Postgres of its own, postgresql_embedded with zonky's binaries: cluster in <project>/.ductus/postgres, binaries cached per user, the port kept in dev.json, one at a time per project; the database URL has no default anywhere else), test, validate, ports, impact, doctor, owners (owners.rs), token, drain, resume, pause, retention, hotspots (admin.rs: tokens straight in the database; the others through the API), new interface (new.rs: templates per source/format; ports from ductus.yaml ranges, free in every environment), types, secret, status, api-spec, gen, sink (tests in tests/cli.rs run the real binary)
sdk/The TypeScript SDK, compiled into the binary: HL7 model, formats (CSV/fixed/XML), HTTP request/response, services, test framework, typings generator
ui/The web UI (React, TypeScript, Vite, Tailwind, TanStack Router/Query), built into ui/dist and embedded in the binary; uses only the public API. Unit tests (Vitest) and browser tests (Playwright, scripts/demo-ui.sh)

Message lifecycle

Statuses: 0 pending, 1 sent, 2 filtered (dropped on purpose: return null / kill(reason), over a rate limit with drop, or its destination was removed), 3 quarantined (needs a human), 4 duplicate (dedupe hit; stored for audit, never queued). last_error holds the reason.

Destinations and deliveries

  • An interface has one destination (destination:, named default) or several (destinations: by name). Each destination has its own function (transform:, default transform), output format, retry, rate_limit (its own allowance) and save_output.
  • deliveries (day-partitioned like messages): one row per queued message and destination, inserted in the same statement as the message (so still one commit per ACK). Its partial index deliveries_queue (destination_id, message_id, partition_key) WHERE status = 0 is each destination's queue, in message order: a worker's read of 50 stops after 50 matching rows, so it costs the same with 1k or 500k waiting (~1.3 ms). With the lane second (deliveries_pending, before migration 0011), Postgres would have had to sort a lane set's whole backlog, so it walked each partition's primary key instead, past every delivered row: ~300 ms a read with 1M delivered messages ahead of a 10k backlog, i.e. a destination back from an outage drained at ~170 messages/s. Rows carry status, attempts, last_error and, with save_output, output.
  • One worker per (slot, destination). Each runs the destination's function on the original message (with meta: id, receivedAt, destination, and origin for a routed message), then delivers. A destination that's down blocks only its own lanes; the others keep going. Ingest wakes every destination's worker for the lane's slot (Wake).
  • messages.status is the overall status: pending until no delivery is pending; then quarantined if any is, else sent if any is, else filtered. Store::settle sets it in a statement of its own after the delivery update commits: of two destinations finishing a message at once, the later always sees both done (in one statement, each could see the other pending: write skew). Settle locks the message rows in id order (ORDER BY id FOR UPDATE, a statement of its own) and only then reads their deliveries (a second statement, so a fresh snapshot): destinations settle overlapping sets at once, and left to the plan the lock order could differ between two of them (a deadlock, seen once with routes); and a resend moves a delivery from final back to pending while holding the message's lock, so a settle that had read the deliveries before waiting would mark the message done with a delivery pending again (a_resend_while_another_destination_settles_leaves_the_message_pending fails without it). A worker whose statuses committed but whose settle failed retries the settle alone (settle_saved), never the status write: by then a delivery may have been resent. Delivery updates only change deliveries that are still pending, so a retried flush changes nothing twice. Older binaries read only messages.status, so rolling back still works.
  • Reconcile at startup (Store::reconcile, three queries for all interfaces): deliveries for pending messages that lack one (stored by an older version, or a new destination); pending deliveries to removed destinations → filtered; pending deliveries whose message an older version finished → its status. Only interfaces with something waiting are reconciled (Store::with_waiting: two loose index scans over the pending indexes, ~20 ms at 1,001 interfaces with or without a 300,000 backlog); the others have nothing for it to change. Messages left pending with no pending delivery (Store::settle_finished) are settled right after the start: workers read deliveries, never messages.status.
  • The script host loads an interface module once and binds one entry per destination (Isolate::add_functions → FunctionIds); runaways are attributed per interface (InterfaceId), so every in-flight call of that interface takes the blame.

Routes between interfaces

  • A destination interface: <name> routes to another interface, which has source: internal (no listener; only routes fill it). ductus_core::config::check_routes (validate and startup) requires the target to exist with that source and to read the format the destination outputs, and refuses loops.
  • The routing worker has no Sender. A routed message joins the worker's flush batch (≤50, or after 20 ms, or at the end of a read), and Intake::accept_routed on the target's intake stores the batch: Store::finish_routed updates this destination's deliveries (pending ones only, RETURNING which) and inserts, for exactly those, the target's messages and deliveries (its keys, dedupe and destinations; origin_id = the source message) in one transaction. Then settle, then the target's lanes are woken. So each hop is exactly once: a crash before the commit routes again; a retry after it finds the deliveries done and routes nothing.
  • That commit skips the flush wait (synchronous_commit = off): nobody outside the database has been told anything, so a lost commit is lost whole and the deliveries are still pending.
  • Routed inserts don't pass the target's drain gate (they were received before the drain); the drain waits for them through the pending counts.
  • Startup builds every queued interface's Intake before any worker, since a route's worker holds its target's.

Worker per delivery: function → outcome:

  • deliver → rate limit → send → sent (batched)
  • Filter(reason) → filtered
  • Quarantine(reason) (transform threw, quarantine(), AR, 4xx) → quarantined
  • Retry (error text contains [ductus:transient], e.g. DB unreachable) → back off 1s→60s, retry the same message (order kept)
  • Runaway (the code ran past its timeout without pausing, or filled the isolate's heap) → quarantined; three timeouts in a row or one heap overflow stop the interface (health.rs): its messages stay pending until the code is fixed and the engine restarted; respond-mode requests get 503 Delivery failures: transient → back off and retry (forever unless retry.max_attempts); permanent → quarantine.

Files (file and SFTP sources)

  • One FileWatcher per interface over a Backend (local folder or SFTP connection). Each poll lists the folder; a file is ready when its size and time are unchanged since the last poll (or its marker exists). Dot-files are skipped. Ready files go oldest first, one at a time; a failure stops the poll so later files wait (file order kept).
  • split: records streams the file through split::Records (256 KB buffer, never the whole file): HL7 per MSH, CSV per record with the header prepended, others per line. Records go to Intake::accept_batch 500 at a time (or 4 MB): one transaction stores the records and the file's progress (source_state: size, time, byte offset, records, done).
  • After a crash, the saved offset is where reading resumes (same size and time) or the file is read again from the start (it changed). done: true is saved with the last batch, so a crash before the move doesn't read the file again. Then move/delete, then the progress row is deleted.
  • Unsplit files (one message each) are capped at 256 MB; bigger ones are left in place with an error.

Schedules

  • schedule::run sleeps until the next due time (re-checking the clock every minute), then Intake::accept_scheduled stores a message {"due", "previous"} and the schedule state (anchor, previous) in one transaction, unless a run of this interface is still pending (then the run is skipped). The worker runs transform on it like any message; the transform's input format is JSON and format/output describe what it produces.
  • ductus_core::schedule::Timing: every runs at start then every interval; cron (croner, server local time) waits for its next time. Missed runs collapse into one run on start (anchor_after).

Engines on one database (0.6.2)

  • One engine per interface. Each engine run (instance, the id engine.start logs) holds a session advisory lock with a random key on a connection of its own for its whole life (Store::lock_engine, ownership::Owner; engines table). Alive = the lock is held (pg_locks): Postgres frees it however the process ends, so there's no heartbeat and no timeout to wait out after a crash. The lock connection sets TCP keepalives (10 s, 3 × 5 s) so a vanished host's session (and lock) goes within ~25 s instead of the OS default ~2 h, and idle_session_timeout = 0.
  • The start sequence: load and check the project (nothing touches the database) → connect and migrate → lock → claim every queued interface in one transaction (Store::claim: delete owner rows whose engine isn't alive, INSERT … ORDER BY interface ON CONFLICT DO NOTHING, read who owns the rest; all or none, rolled back if any is owned) → the lanes guard (Owner::guard_lanes) → then everything that changes an interface's rows (docs snapshot, reconcile, retention, workers, listeners). A refused start writes engine.refused, deletes its engines row, closes the lock connection and exits 3 (ownership::Refused; systemd RestartPreventExitStatus=3). Claims are sorted, so two engines claiming overlapping sets at once wait for each other and the later one sees the earlier's rows (two_engines_claiming_the_same_interfaces_at_once_one_wins). Respond-mode interfaces aren't claimed (no queue). A clean stop deletes its claims and sets stopped_at after the workers have finished. Measured at 1,001 interfaces: claiming ~16 ms (fresh or from a dead engine), ~3 ms for its own rows again.
  • Listening sockets outlive the process under systemd (0.6.6.2): listeners are opened last in the start, through systemd::listen: the package's unit is Type=notify with a file descriptor store, so each new listening socket is handed to systemd (FDSTORE=1, named by address) and the next start, after a restart or a crash, listens on the same socket (LISTEN_FDS) instead of binding. Connections arriving in between wait in the kernel's backlog: measured under real systemd 255 and 252 (4 senders, 200 messages/s), a restart refused none (longest ACK wait 0.2–0.3 s; without the store 4 refused, 0.6 s), a kill -9 none (5.4 s: RestartSec). A drain, a stop of the service and a failed start close the sockets for real (FDSTOREREMOVE). READY=1 follows the started line; STOPPING=1 comes before the gates close. Nothing runs in the background: one notify datagram per listener at start.
  • The handover (0.6.6.3, Linux): systemctl reload runs ductus handover, which asks the running engine over its control socket ($RUNTIME_DIRECTORY/control, 0600, the asker's uid checked: handover::Control). The engine starts the binary at the path it was started from (argv[0], remembered at start: /proc/self/exe would be the deleted old file) with its arguments, a copy of every listening socket (systemd::handover_sockets, named in DUCTUS_LISTEN_FDS) and a socket pair (DUCTUS_TAKEOVER; JSON lines: From, Ready, Released, Running, Failed). The new engine runs the start with Owner::prepare instead of the claim (its own lock and engines row; refused, as a start would be, when a third live engine owns one of its interfaces, and when lanes go down: the old engine stores into every lane until it lets go, so the guard can only judge after), reads the tiers without applying retention (Retention::new), builds intakes and workers with Ownership not held, loads the code, listens on the inherited sockets (both engines accept on them now; any number may store), starts the API, says Ready. The old one stops HTTP sources first (in-flight requests answered there, not a 503 from a closed gate), then stops as a clean stop does (systemd::handing_over: no STOPPING=1, the sockets stay), releases, writes engine.stop, sends MAINPID=<new> (systemd follows the new process from here; NotifyAccess=main ignored the new one until then: systemd::promote then stores its new sockets and removes the dropped ones), says Released. The new one claims (claim_now), runs the lanes guard, reconciles (take_charge), re-reads pauses, holds: sending resumes; then the docs snapshot and retention (settle_in), audit/alerts/certificates/LDAP (two engines would judge alerts twice), engine.handover. Anything failing before Released ends the new process (Failed with the reason; its sockets are the old one's, so abandon does nothing) and the old one goes on. Measured: one interface at 300 msg/s, sending paused 80–170 ms, 0 refused; 1,001 interfaces 0.6 s (scripts/bench-handover.sh; ~40 ms per statement with 1,001-element arrays is sqlx 0.9's missing TCP_NODELAY). Background: one idle Unix socket listener.
  • The replay (0.6.6.4): ductus replay reads a period's originals per interface in id order (Store::originals, 500 a statement, with each destination's record: Store::recorded) over a read-only connection and runs them on one thread with one isolate (replay.rs): the clock pinned to each message's received_at and Math.random seeded per message and destination (Isolate::pin_clock/set_clock, installed before any module loads), meta and the decision exactly as the worker makes them (worker::meta, Outcome::of), services that refuse writes (db().execute, non-GET fetch) and mark the call not comparable, Postgres pools with default_transaction_read_only. Nothing is connected or stored. The other version runs as ductus replay --emit (JSON lines: start, interface, row, done) and the CLI merges both streams by (interface, id, destination) (both produce that order), comparing each row with the other version's, the record's decision and the kept output (replay/report.rs: HL7 by field, JSON by path, text by line; drift when the other version shares a difference). Measured: 100,000 messages × 3 destinations in 7.5 s alone, 8.6 s beside a second version (scripts/bench-replay.sh). Background: nothing; it runs when asked.
  • Losing the lock (Owner::watch: the lock connection is checked every 2 s with a 3 s timeout): Ownership turns false, so workers (in hold_while_paused), file watchers and schedules wait; intake keeps storing. The engine takes the same lock key again (retrying while the server still holds the old session) and claims again; if another engine owns an interface by then, it stops with exit 3. The ping is well inside the keepalive window, so an engine cut off from the database pauses before another can claim its interfaces.
  • The lanes guard: interface_applied keeps each interface's lanes (partition_count(): partitions with an ordering key, else 1). Lowered or never recorded → Store::stranded counts waiting deliveries in lanes ≥ the new count (the waiting rows of those destinations read once from the queue index in a materialized CTE, filtered at the smallest limit, then matched to each limit; a per-destination LATERAL count let the plan seq-scan a big partition once per destination: 18 s at 1,001 destinations with 300,000 waiting, against 18–55 ms now); any → refused. Raised with messages waiting → warning + interface.partitions_raised. Only changed values are saved. ductus validate runs the same check read-only (Store::connect_read_only: no migrations, since it may run with a newer binary than the engines there), plus retention changes running, waiting for a confirmation or starting at the next start.
  • Visibility: GET /engine has holds_interfaces and others (live engines on the database with what they own); the Overview shows both.

TLS on our listeners (0.6.3)

  • Certificates are resources.yaml entries (certificates:; ductus_core::resources::CertificateEntry): a pair from files (cert_file/key_file, relative to the project) or from PEM text (usually secrets), or a CA alone (ca/ca_file). Listeners name them: tls: { certificate, clients: { ca, subjects } } on MLLP and HTTP sources and on api in ductus.yaml (ductus_core::tls: the shape, subject patterns, and check for names and kinds). HTTP interfaces on one port share its tls (check_endpoints).
  • Key exchange (0.6.4.1): rustls's prefer-post-quantum feature (its own default, which default-features = false had dropped): X25519MLKEM768 first, both as client and server, for every rustls user in the binary (features unify: reqwest, lettre, ldap3, tiberius too). A client sends the hybrid and X25519 key shares together, so a peer without the hybrid costs no retry. +0.15 ms per full handshake (bench-tls.sh); doctor's TLS step names the group (doctor::negotiated).
  • Loaded before the database (ductus-runtime/src/tls.rs, Certificates::load): every entry, each pair's key checked against its certificate (CertifiedKey::from_der), expired ones refused, so a start with a broken certificate changes nothing. ductus validate runs the same per entry.
  • One rustls ServerConfig per listener (TLS 1.2 and 1.3, aws-lc, the same stack as the outbound connections), whose certificate comes from a resolver (Pair, a ResolvesServerCert behind a lock): reload swaps what the resolver hands out, so new connections get a renewed pair without rebuilding anything. One task checks every file-backed pair every minute, by a SHA-256 of both files' contents (a renewal of the same key type is often the same size, and copies can keep the old time); no database work. A changed pair that doesn't load is logged once per distinct content and the old one stays.
  • Client certificates: rustls's WebPKI client verifier against the named CA, wrapped (ClientCheck) to match the leaf's subject (CN, O, OU, C, L, ST, DC by OID) and DNS names against subjects.
  • Handshakes never hold up the accept loop: MLLP runs it on each connection's own task (ductus_mllp::serve_with: an open step before the first frame; the listener still closes at once on a drain). HTTP sources and the API use tls::Listener (axum's Listener): TLS connections are accepted and handshaken on their own tasks, finished ones handed to the server through a channel; the API wraps it in tap_io so axum's ConnectInfo<SocketAddr> (the audit log's address) still works. Handshakes time out after 10 s. Refusals are logged with a plain reason at most once a minute per listener and address (Refusals).
  • The API over HTTPS sends HSTS (a year). api.listen off loopback without tls warns at start and in validate (the session cookie is Secure).
  • Outbound (0.6.3.2): one way to verify (tls::Trust): the platform verifier (rustls_platform_verifier: the system's roots and its checks, the name against the host) plus the CA ca: names, and the pair certificate: names as the client certificate through a ResolvesClientCert (the same Pair, so a renewal applies to client certificates too). Names are ConnectTls { ca, certificate } (ductus_core::tls: tls: true | { ca, certificate } on MLLP destinations, flat ca/certificate on apis:, HTTP destinations with a url, alerts.smtp, webhooks and the heartbeat; LDAP's ca is a name); tls::outbound + check_outbound refuse wrong names and kinds in validate and at start. Certificates::client builds one ClientConfig per distinct ConnectTls and shares it (MLLP destinations naming the same CA share settings and session cache). Users: ductus_mllp::Client::with_connect (the client's connect step: a tokio_rustls handshake within the send's timeout, its refusals turned into a temporary error's words by tls::outbound_failure); reqwest via tls_backend_preconfigured (ALPN h2 + http/1.1 set by us) for HTTPS destinations, api() (a client per API that names TLS) and webhooks; ldap3's set_config; lettre takes certificates rather than settings (extra roots + identity from the pair's PEM; same platform verifier; the transport is rebuilt when the client pair's hash changes). Doctor builds its handshake from the same Trust with its recording verifier around the platform one, and after a finished handshake waits up to 500 ms for a TLS alert (TLS 1.3's refusal of a client certificate comes then).
  • Tools (0.6.3.4), all in the binary, none running in the engine: ductus cert request (rcgen: a new RSA 2048 or ECDSA P-256 key, written 0600 with create_new, and a CSR with the names, the subject given and server + client authentication; tls::request), ductus cert show (cert_show.rs: reads a file or entry and judges it with doctor's own pieces: doctor::judge_chain for order and completeness, tls::check_pair for the key, the same 30-day warning), ductus cert self-signed. gen --tls and sink --tls build certificates: entries in memory from their files and go through Certificates like the engine (client/server, tls::mllp_client, tls::handshake), so they verify, refuse and log in the engine's words.
  • Doctor's own connections to our listeners aren't refusals: every TCP connection doctor makes registers its local address (Refusals::our_own, kept 20 s; since 0.6.3.5 before connecting, tls::connect_own) and the listener's handshake from it isn't logged; otherwise the full check of a listener that wants client certificates logged a refusal from loopback and used up that address's once-a-minute line. An MLLP sender closing without a TLS close_notify is an ordinary close (debug, not a warning).
  • (0.6.3.3, our certificates seen: see Certificates below.)

Who may connect (0.6.3.5)

  • allow: on MLLP/HTTP sources and api (ductus_core::allow: addresses, subnets, host names; parsed with about::Network). Each listener with a list has an allow::Admission, checked right after accept(): in serve_with's open for MLLP (on the connection's own task) and in tls::Listener's accept loop for HTTP and the API, before the handshake task. A refused connection is dropped unread, counted, and logged through tls::Refusals (once a minute per listener and address). HTTP interfaces on one port share one admission (check_endpoints refuses different lists).
  • Host names: one allow::Names for the engine, every distinct name across all lists once. Resolved while the database connects (awaited before anything listens), then by one task every 5 minutes, only when some list names a host; a refusal at a listener with names looks them up again at once, at most once a minute per name. Accepted connections never look anything up (an in-memory compare). A failed lookup keeps the last good addresses. Background cost: nothing without names; with N names, N lookups every 5 minutes on tokio's blocking pool. No database work.
  • The engine's own checks are let in: doctor connects with tls::connect_own, which binds to the listener's own address first and registers it (Refusals::our_own) before connecting, so the admission (which runs before any handshake) can see it. allow::on_port finds a port's admission in this process (the API's /ports, the interface detail, doctor).
  • TCP keepalive on every accepted connection (allow::keepalive, socket2: 15 s idle, 5 s between probes, 3 probes ≈ 30 s), on MLLP, HTTP and the API. Probes are the kernel's.

Retention

  • Each interface keeps its messages for one of three windows (retention: 1w/3w/7w, i.e. 7/21/49 days; default from ductus.yaml, else 7w): its tier. Longer history belongs to the source systems or an archive (optional cold storage is planned); the engine isn't a data warehouse. Tiers and periods are chosen for partition count (statements Postgres can't prune pay for every partition): a weekly tier costs about window ÷ 7 partitions, the daily 1w tier one per day, about 24 in all. Shifting a window by a few days changes that by a fraction of a partition, so windows are whole weeks. A message and its deliveries are stored in the interface's tier and always share it (tier column, part of both primary keys). Old messages go by dropping whole partitions (Store::drop_expired, hourly, 5 s after startup, and after each retention change): a partition goes once its whole period is older than its tier's days.
  • The held area (migration 0014): an expired partition holding messages someone still needs (pending: a destination down or paused for months; quarantined, unless the interface says quarantined: purge) doesn't stay: those messages move, each with all its deliveries, to tier 0 (messages_held/deliveries_held, partitioned by month), and then the partition drops like any other. Without it, one waiting message kept its whole period for every interface in the tier (a receiver held three months ≈ double the disk; unresolved quarantines, growth without bound). The move (Store::hold_step, from the hourly run in ductus-runtime/src/retention.rs) walks the partition's primary key in batches of 1,000, busy at most half the time, locking messages then deliveries in id order. Held rows are still partitions of messages/deliveries: they stay in their destination's queue (same index, same order) and in the message browser; nothing reads or writes by tier. A worker whose update meets a row moved under it gets a serialization failure (40001) and runs the statement again (retrying in the store: every statement that updates messages or deliveries goes through it, deadlocks too), and finds the row where it is now; the stress test fails without it. A month that's over is dropped whole once nothing in it is unfinished (no row deletes; the disk goes back at once); the current month isn't, since more can still come. Held tables are created when first needed, one table per transaction. Counted hourly (every held row, so not per API call): held in GET /retention, ductus retention, ductus_held_messages/ductus_held_bytes in /metrics. Measured (HISTORY=100000 scripts/demo-held.sh, WSL): 300,000 pending messages with 2 deliveries each moved in 58 s (three partitions of 100,000, 18–22 s each, ~5,000/s with the throttle), then delivered in order in 174 s once the destination was resumed (~1,700/s, a single destination's usual rate). Its effect on ingest during a move isn't measured yet.
  • Kept instead of dropped: a partition with rows of an interface whose retention is now longer than the tier (a change still moving them) waits for the change. These are reported (kept in GET /retention, ductus retention).
  • Drops don't block: each partition is detached with DETACH PARTITION CONCURRENTLY (no lock that conflicts with inserts or reads; it waits for queries that started earlier instead), checked again (re-attached if it got unfinished messages meanwhile), then dropped. deliveries first. An interrupted run is finished by the next (pending detaches FINALIZEd, detached leftovers dropped).
  • Changing retention applies to old messages too (ductus_store::retention, ductus-runtime/src/retention.rs). The retention table holds each interface's applied tier and the running change. One change at a time per interface; a shorter value deletes messages, so it waits for ductus retention confirm (API: POST /retention/{name}/confirm). When a change starts, new messages go to the new tier at once (intake reads the tier from a shared atomic), and a background task works through the interface's rows in other tiers in batches of 1,000, busy at most half the time: finished messages past a shorter window are deleted with their deliveries; the rest move into the new tier with their deliveries, in one statement. Pending messages aren't touched (a worker is updating them) and move once delivered; the change ends when none are left. Quarantined messages move but are never deleted; past the window they stay in their old tier (and go to the held area when it expires). Held messages within the new window move back like any other.
  • Moving rows and settling: a message another destination just finished can be moved while a settle statement has it in view; Postgres can't re-check a row that moved partitions and raises a serialization failure (40001), so settle retries (it's idempotent). Found by the stress test.
  • Why tier first, then periods: with days × tiers (~690 leaves per table), statements Postgres can't prune by partition paid for every leaf: the worker's queue read went 8.8 → 20 ms, its delivery update 3.9 → 9.6 ms, the message browser 0.2 → 9.6 ms; at ~100 leaves (the first five-tier design, 7/30/90/180/365) they were back to 5.9, 4.8 and 1.4 ms (pgbench on WSL, prepared statements); three tiers (1w/3w/7w) bring it to ~24. And statements that find messages by (id, received_at) pairs carry the batch's received_at range (span): without it, Postgres priced looking up each message in all ~100 partitions above scanning them, and settling 50 messages read every messages partition (84 ms instead of 1.8 ms on 375k rows, and growing with the table).
  • Rows stored by engines that don't know tiers go to the 7w tier (the longest) (the column default); the first start of an interface moves its rows to its own tier (that's how migration 0010's copy of older data gets its tiers).

REST API and drain mode

  • /api/v1 (ductus-runtime/src/api/): axum, one listener (api.listen in ductus.yaml, default 127.0.0.1:7700, off to disable; --api-listen overrides). Routes are declared with utoipa_axum::routes!, which builds the router and the OpenAPI document (/api/v1/openapi.json) from the same handler annotations: the spec can't drift from the code. The UI will use only this API.
  • Who calls (Caller, a Principal): a user (signed in with a password) or a token. Roles: viewer (read), operator (+ drain, pauses, resend/edit, connection checks), admin (+ users and tokens, and every permission). Two permissions go beyond a role (users.permissions, api_tokens.permissions): payloads (read message payloads and saved outputs; editing needs it too) and audit (the audit log, below). A token that belongs to a user (api_tokens.user_id) acts as that user at most: role = the lower of the two, permissions = the intersection, and it stops when the user is disabled. Handlers call caller.require(Role) / require_permission(..); api/mod.rs has one table (ACCESS) of who may call every operation, and a test fails for an operation without a row.
  • Tokens: Authorization: Bearer dux_<64 hex>. Stored as SHA-256 hashes (api_tokens); every call looks the hash up (an index hit) joined to the owner, so revoking or disabling works at once. Service tokens (no owner) are made by admins or on the server with ductus token create; users make their own under /me/tokens (listed as <user>/<name>). Migration 0018 revoked every token made before users existed.
  • Users (users, ductus_core::password, api/users.rs, api/me.rs): local accounts with argon2id hashes (hashing runs on blocking threads, at most 4 at once, so sign-ins can't take intake's cores); wrong passwords count per username (failed_logins, lockout window and length in auth.lockout); a password an admin chose sets must_change_password, and then only GET/DELETE /session and POST /me/password work (the MustChange extractor; everything else answers 403 password_change_required). The first admin is made on the server (ductus user create; ductus dev makes a dev admin).
  • Directory sign-in (auth.ldap; ductus-runtime/src/ldap.rs, ductus_core::ldap, ductus_store directory.rs, migration 0022): POST /session checks a name a local account holds locally and only locally; any other name goes to the directory named by auth.ldap.resource (an ldap: entry in resources.yaml, so doctor, ductus ports, documentation fields and the certificate table cover it). One connection per sign-in (ldap3 over our rustls and platform verifier, plus the entry's ca): bind as the service account, find the person (user_filter, the username escaped per RFC 4515; more than one match is refused), refuse an Active Directory account disabled in userAccountControl, bind as them with the password (never an empty one: that would be an unauthenticated bind), bind back as the service account, read the groups (a base search per configured group; with nested, Active Directory's in-chain rule when the root entry says it's AD, else a walk up the groups, at most 8 levels), then highest role group wins. The account (users.source = 'ldap', no hash) is made or updated in one transaction with its audit row (user.create, user.directory_change). One task (ldap::Round), started with the background work (after a handover's takeover), runs a round at once and then every refresh, on one connection: it checks again, by directory id, everyone with a live session or token and every directory account an owner text of this engine's project reaches (owners::reach); a refusal sets users.directory_refused_at (the identity join treats it like disabled_at, so sessions and tokens stop; the sessions are deleted), logged once (user.directory_refused); an account the directory says nothing new about is read without a lock and not written (check_directory_user), and the round notes when it asked in one statement (directory_checked); a directory that can't be reached changes nothing, and never delays a start. Then (0.6.7.3) owner emails that reach nobody and owner texts naming a disabled account are looked up by attributes.email: one entry, in a role group, gets its account (save_directory_user, audit user.create by the engine with "reason": "owner"); two, or none in a group, are warned about once per run. Two engines saving one new person at once: the second insert hits users_username (or users_directory_id) and is retried once, finding the account. Measured on loopback slapd and WSL's disk (measure_a_refresh_round, measure_an_owners_round, ignored tests): 200 accounts 0.41 s a round (was 1.25 s with a commit per account), 200 owners' accounts 0.38 s, 200 owner emails nobody has 75 ms, none 0.3 ms (no connection). A wrong directory password counts toward the local lockout once the account exists. No AuthProvider trait: SSO (OIDC/SAML) is a redirect flow, not a password check, so its extension point is designed with it.
  • Browser sessions (api/session.rs, migrations 0015, 0018): POST /session with {username, password} or {token} (the latter unless auth.token_sign_in is off) sets the cookie ductus_session=dus_<64 hex> (HttpOnly, Secure, SameSite=Strict, Path=/api), stored hashed in api_sessions with the user's or the token's id. Caller takes a bearer token first, else the cookie: a session acts as its user or token (one join, checked per call) and ends after 12 h idle, 7 days (auth.session), sign-out (DELETE /session), sign-out everywhere else (DELETE /me/sessions), a password change (every other session of that user), or when the user is disabled or the token revoked. Requests that change something with the cookie must carry X-Ductus: 1: a header other origins can't send without a CORS preflight the engine never grants, so the cookie can't be used cross-site (SameSite=Strict alone is the first line). GET /session says who is calling and with which permissions.
  • The audit log (audit_log, migration 0019; ductus_store::audit, ductus-runtime/src/audit.rs, api/audit.rs): one row per thing done, with who (By: a user, a token, system, anonymous), how (session, token, cli, engine), the connection's address (into_make_service_with_connect_info), what, to what, the outcome and a detail document. Rows are written with the change they describe: a resend, edit or dismissal inserts its audit row first in its own transaction (its message_actions point at it with audit_id, and a request that finds nothing to do rolls the row back); account, token and session changes take an &Audit and insert it in the same transaction; a sign-in, a failed password (counted and logged together) and a sign-out likewise. Actions with no transaction of their own (pause, resume, drain, retention confirm, connection checks, an export of the log) write the row first (record_first) and are refused with 503 if it can't be written. A payload read is written before the payload is returned (record_payload_read: one row per actor and message per audit.payload_read_window; refused with 503 if the row can't be written). The engine writes engine.start (with how the previous run on the host ended: an engine.start without an engine.stop of its instance is a crash), engine.stop, interface.stopped (the runaway guard tells Health, which tells a channel, which a task drains), and retention.change. An hourly job (60 s after start, then every hour) sets message_actions.previous_output NULL once an action is older than its interface's retention (a copy of a payload must not outlive the message), and deletes message_actions and audit_log rows older than audit.keep (default 6 years, at least 1) in batches of 5,000. GET /audit needs the audit permission (format=ndjson is an oldest-first export, itself logged); GET /me/activity is the person's own rows (audit_log.user_id: the user, or the owner of a token).
  • Dismissing (Store::dismiss, POST /messages/{id}/dismiss, /messages/dismiss): a quarantined delivery becomes filtered ("dismissed by NAME: reason", reason required), the message is set pending and settled again like after an edit, in the held area as anywhere. It locks the messages first in id order and moves deliveries final to final, so it adds no backward move to the locking rules.
  • Appearance (user_settings, GET/PUT /me/settings): a user's theme and custom colors follow them to any browser; the UI keeps a browser copy for the first paint and for token sessions.
  • The web UI (ui/, React; api/ui.rs): crates/ductus-runtime/build.rs embeds ui/dist (each file with a gzip copy when that's a tenth smaller) as a sorted static table; every path outside /api is served from it, unknown ones as index.html (the app's router shows the page) except under /assets/, whose content-hashed names are cached for a year (immutable); everything else is no-cache, so an upgrade shows at once. Every UI response carries a CSP (own scripts only, connect-src 'self', no framing). Cargo never runs npm: without ui/dist a "build the UI" page is embedded, and DUCTUS_REQUIRE_UI=1 (CI, check.sh) makes that an error. The UI's API types are generated from ductus api-spec (the OpenAPI document, without an engine) and committed as ui/openapi.json + ui/src/api/schema.d.ts; check.sh and CI fail when they don't match the binary. Operation ids must be unique (utoipa takes the handler's name; every_operation_has_its_own_id). Size: a first visit loads what index.html references, at most 300 KB gzipped (182 KB at step 7c); pages used rarely load on demand in chunks of their own (≤ 50 KB each), enforced by ui/scripts/budget.mjs. The API reference is one: our own renderer of /api/v1/openapi.json (3.7 KB gzipped, the document fetched when opened; no CDN, which hospital networks often block). Off-the-shelf renderers measured 128–1,256 KB gzipped (RapiDoc 128, Redoc 320, Swagger UI 447, Stoplight Elements 650, Scalar 1,256), wouldn't follow the UI's themes or its AAA high-contrast check, and Redoc and Swagger UI call eval/new Function, which the CSP forbids.
  • What it serves: engine info and /health (no token: for load balancers); interfaces with state (running/stopped by the runaway guard), drain gate and each destination's pending/quarantined counts; the message browser (an interface's messages newest first, paged by id; one message with payload and deliveries); drain status, start and stop; pausing and resuming a destination; retention (each interface's, changes in progress, partitions kept) and confirming a shorter one; impact, owners and doctor as JSON (the same code as the CLI; doctor skips listening ports, which the engine itself holds); owners with who each is (GET /owners: an account, a team, an address or nobody, and whether it is the caller, which is the UI's "Mine"; GET /owners/people, the accounts that can own things); a preview of moving ownership (POST /owners/reassign/preview: ductus_core::owners::reassign on the project's interface.yaml files and resources.yaml as they are on disk, a warning when the new owner isn't an account, each file's changed lines, a unified diff for git apply and the CLI command; it writes nothing, since the project is the repository's and a server-side edit would be undone by the next deploy); token management; /events, Server-Sent Events with the same counts and each interface's and destination's health, sent whenever a count or a health state changes or a delivery finishes (the workers' in-memory counters, so a message resent and quarantined again between two counts still shows); /metrics for Prometheus and /hotspots (see Monitoring).
  • Message actions (api/actions.rs, ductus_store::actions, migration 0016; operator): resend puts finished deliveries back to pending in place (Store::resend: locks the messages in id order, resets the chosen deliveries, records each in message_actions with what it was before, marks the messages pending; the lane's workers are woken through the interface's Intake). The message keeps its id, so its worker takes it next in its lane. Quarantined deliveries by default; named destinations whatever they ended as (a replay); /messages/resend takes up to 500 ids in one transaction. Edit (Intake::copy_edited → Store::copy_edited) inserts a corrected copy with keys read from the new payload (no dedupe, the original's origin_id), filters the original's quarantined deliveries to the copy's destinations ("replaced by edited copy N") and records the link (copy_id), in one transaction; refused for a destination where the original is still pending (a worker may hold it). GET /quarantine reads quarantined deliveries across interfaces from deliveries_quarantined.
  • Queue counts come from the partial indexes (deliveries_queue, deliveries_quarantined): their cost grows with the backlog, not with what's kept (about 10 ms for 15k pending here). One monitor task (control.rs) counts every 2 s, only while someone is subscribed to /events or a drain is running, and shares the result with every subscriber.
  • Drain: each interface has a Gate (gate.rs). Draining closes them all: Intake refuses to store (gate::Closed), MLLP listeners close their socket and connections (ductus_mllp::serve_until), queued HTTP answers 503 + Retry-After, file watchers and schedules wait for the gate. Every store holds a counted Pass; the monitor declares the engine drained once every gate is closed, no pass is in flight, and none of this engine's destinations has a pending delivery. With stop, the engine then exits (Engine::shutdown). Resume opens the gates; MLLP listeners bind again.
  • Stop (ductus_runtime::stop; on Ctrl-C, SIGTERM, or drained with stop): close every gate and wait until no pass is in flight (each message being stored gets its ACK; an MLLP message being handled is still answered), then cancel the workers' stopping token. A worker treats it like a pause (a transform or send under way finishes; it's checked before each message, before each send and in every wait: back-off, rate limit, pause, idle), saves its finished statuses and ends; a thread ends when its workers have (respond-mode workers once their HTTP server has answered what was in flight). Then last activity is saved once more and the API answers its requests in flight. All within --stop-timeout (30 s); past it, or on a second signal, the process exits and what was in hand is processed again at the next start (at-least-once, as after a crash). Before this, SIGTERM killed the process outright, as kill -9 does.

Monitoring

  • Counts and timings (stats.rs) are atomics in memory, one set per interface, destination and resource, created at startup and shared by intake, the workers and the API: an increment per event, no lock, no write. They start at zero with the engine (Prometheus expects that). Intake counts received / duplicates / store failures; each destination's worker counts sent / filtered / quarantined / retries and times its function, each successful send, and receipt → sent (received_at to the moment it's handed over; routed messages when they're routed); respond-mode requests are timed in the responder; db()/api()/fetch() calls are timed per resource around the call on the main runtime (ResourceCalls: fixed at startup, so no lock).
  • Histograms have 17 buckets (1, 2.5, 5 per decade from 0.5 ms to 10 s, then 1 min, 5 min, 1 h) plus a sum; quantiles are interpolated inside a bucket like Prometheus's histogram_quantile, means are exact. Few buckets because each is a series per destination.
  • Exposed as since_start in GET /interfaces (counts, and mean/p50/p95 in ms), resources in GET /engine, and GET /metrics (Prometheus text format, viewer token: ductus_messages_received_total, ductus_deliveries_total{outcome}, ductus_delivery_retries_total, ductus_delivery_latency_seconds, ductus_send_seconds, ductus_transform_seconds, ductus_http_request_seconds, ductus_resource_call_seconds, queue gauges from the database at each scrape, ductus_interface_stopped, ductus_draining).
  • Expectations (monitor: in interface.yaml, ductus_core::monitor): active_within (with optional between hours), active_by (a daily, weekly or cron deadline, its window from midnight or from), behind (messages waiting, the oldest one's age, or both; a destination may set its own). Pure functions of the times below, unit-tested with local times.
  • Last activity survives a restart or a failover (activity.rs, migration 0012): intake and the workers mark last_received, last_sent and last_error in memory (stats::Moment: an atomic fetch_max, no lock), loaded from interface_activity/destination_activity at startup; one task saves the ones that changed every 5 s (two statements, whatever the volume; a time never moves back, so a second engine's older write can't undo a newer one). watched_since is when an engine first ran the interface: deadlines before it don't count. Rates and timings stay in memory.
  • Pausing a destination (ductus pause, POST /interfaces/{name}/destinations/{d}/pause): written to destination_activity first, then a watch switch shared by the destination's workers (every slot). A worker checks it before each message, before each send (also after a rate-limit wait) and after each retry back-off; while paused it saves its finished statuses and waits on the switch, so it costs nothing. A send under way finishes. Pausing again with a reason adds it, keeping the original time and author (act first, explain after). The drain's "nothing pending" leaves paused destinations out. Resuming (DELETE, ductus resume <interface> [destination]) returns the pause it ended.
  • Evaluation (ductus_core::monitor::diagnose and interface_state, pure; Engine::diagnose gathers the facts): per destination the waiting count and the oldest waiting message's age (Store::oldest_pending: one probe of the queue index per destination with a backlog, added to every snapshot), quarantined count, last sent and last error, pause, stopped interface, the last 5 minutes and the normal for this hour of the week. States, worst wins: paused > stalled (waiting over STALL_AFTER = 1 min and nothing finished for as long; the last error if it's newer than the stall) > error (quarantined) > behind (past behind) > ok. For a backlog over a minute old: the trend (catching up with an ETA = waiting ÷ (out − in), or not) and causes (burst: over 2× normal inflow and ≥ 5/min more; slow receiver: over 2× normal send and ≥ 5 ms more). Shown as health in GET /interfaces, ductus_destination_state/ductus_interface_state/ductus_queue_oldest_seconds in /metrics, and in ductus status with a token.
  • Rates and normal (activity::Trends): the activity task samples each destination's counts every 5 s (received, finished, sends and their total time): a 5-minute window of samples, and when it last finished something. When the hour of the week changes, the hour just ended is learned if at least 15 minutes of it were watched (Baseline::learn: each hour moves 30% of the way to the new value), and the changed baselines are saved in one statement (destination_activity.baseline_*, migration 0013, 168 values per array). The window starts over with the engine; the baseline doesn't.
  • Hotspots (ductus_core::hotspots, pure; GET /hotspots, ductus hotspots): for each destination with a backlog, Store::lanes counts the waiting deliveries per lane from the queue index and looks up each lane's first one (its key, attempts, last error). Since a worker handles its lanes one message at a time, a lane whose first message has failed attempts is the one its worker is retrying, and every lane of that worker waits behind it. From that: blocked workers (and whether the receiver took other messages since the blocking one arrived, i.e. the message is the trouble, not the receiver), every worker blocked (the receiver looks down), a worker or lane with most of the backlog (a hot key; 100 waiting at least), and lanes no worker owns (ordering.partitions lowered with messages waiting). On demand only: 300,000 waiting for one destination over 64 lanes takes ~40 ms (WSL), about what counting the queue costs.
  • Alerts (ductus_core::alerts, pure; ductus-runtime/src/alerts.rs; ductus_store::alerts, migration 0020; api/alerts.rs): alerts: in ductus.yaml (the only section that may use ${...}: it is read per environment, Settings::alerts) names an SMTP server, channels (email addresses, a webhook in json, slack or teams format) and rules (the first naming a state gives its severity and recipients; owner resolves to the email of the one account it names (owners::find; not a disabled one), else the owners: directory, else the owner text if it is an address). Three tasks run while the engine does, whoever watches: judging every 15 s takes one queue snapshot, interface_state and diagnose for everything (the same as the API), and alerts::step on each stored alert (alert_state, one row per interface and destination, made at start): fire, escalate (higher severity), change quietly, reopen (bad again within settle), recovering, recover, remind (every repeat until acknowledged). Paused destinations hold; states that need watching wait until the engine has run a minute (error and stopped don't). A change is written with its event and its notifications in one transaction, compare-and-set on rev, so two engines on one database record it once. Sending claims due rows of alert_deliveries (the outbox) with FOR UPDATE SKIP LOCKED and a two-minute lease, sends each on its own task (lettre over rustls with the platform verifier, as the HTTP client; reqwest for webhooks), and retries with back-off (30 s doubling to an hour, ten tries); at least once, like deliveries. Quiet hours make a notification due when they end; a recovery cancels what still waits and is sent only to an alert someone was told about. The heartbeat calls a URL every every. History is kept alerts.keep (a year). Acknowledge and silence are operator actions, written with their audit row. Measured (1,001 interfaces, WSL): 35 ms a round, +0.1% of a core, +7 MB.
  • Certificates (ductus-runtime/src/certificates.rs, doctor.rs; ductus_store certificates.rs, migration 0021; api/certificates.rs): every doctor.schedule (6 h; the first 60–120 s after start) the engine runs doctor's checks in certificates mode: DNS, TCP and the TLS handshake (after STARTTLS for the mail server) of what has a certificate: HTTPS APIs and destinations, the directory (ldaps://, or StartTLS written by hand like SMTP's: a 31-byte extended request), the alerts' SMTP server, webhooks and heartbeat, and Postgres connections the engine verifies (verify-full/verify-ca: the engine's own database as "engine database", and resources.yaml entries; the 8-byte SSLRequest written by hand, then the same handshake; prefer/require ones are described by doctor, not watched), and SQL Servers the engine verifies (no trust_server_certificate; 0.6.4.3: a PRELOGIN packet asking for encryption written by hand, then the handshake with its records framed in PRELOGIN packets (doctor::Prelogin), or plain TLS with ALPN tds/8.0 for encrypt: strict; a named instance's port from the SQL Server Browser). The verifier records the chain before judging it, so a certificate is read even when it isn't trusted. Each server's certificate goes to certificate_status (a check that couldn't read one keeps the earlier dates, read_at), each run to doctor_runs (the last 30; POST /doctor stores its run too). No code analysis in certificates mode (it only labels "used by"). Judging (the alerts task, every 15 s) reads the stored dates: the earliest expiry in the chain, whole UTC days, against alerts.certificates.warn_days (30/14/7): fire at the first step, escalate at each closer one and when expired (critical), no reminders before expiry, quiet change for a short renewal, recover after settle (ductus_core::alerts::certificate_step; alert_state.stage). GET /certificates, ductus_certificate_expiry_seconds{side,resource,host,port}. Measured: a check of 6 servers ~0.1 s. Ours (0.6.3.3, migration 0024 side): each certificates: pair the engine presents (a TLS listener's, the API's, a client certificate; ductus_core::tls::presented) is one target cert:<name>:<this host>:0 (port 0: no remote row can collide, and one pair served on many ports alerts once), owner = the entry's, else its one interface's. Its row is built from the loaded pair (certificates::our_row), so judging, the API and the gauge read memory, not the table; the table is written at start (one transaction) and when tls::Certificates::watch (the minute's file check) or a connection check finds a renewal or a renewal that can't be used. In full mode doctor also connects to each TLS listener (loopback for 0.0.0.0/::), records the chain served without verifying it, and judges the path (judge_chain: in order, ends at a root, a certificates: CA, or a root of the system's (rustls-native-certs)).
  • Respond-mode profile (v0.4 demo, 2 queries + 1 HTTP call to another respond interface, local database): 2.1–3.0 ms in the engine on average; the three calls one after another ~0.5–0.7 ms each, the engine itself ~0.6 ms; curl sees ~0.4 ms more (HTTP both ways). The lever is in interface code (independent calls together): nuance.

Threads and isolates

  • Shared pool (default isolation: shared): --threads (default = cores) OS threads, each a tokio current-thread runtime + LocalSet + one V8 isolate hosting many interfaces. Each interface is one async task (Worker); shared interfaces have one slot (all lanes). See docs/shared-pool.md for the scoping rules interface authors follow.
  • Dedicated (isolation: dedicated, workers: N): N threads, each with its own isolate, lanes split round-robin.
  • V8 isolates can't move between threads; the isolate is Rc<RefCell<Isolate>> per thread and is only borrowed for synchronous calls, never across an await.
  • Async transforms: Isolate::begin returns Call::Done (sync transform) or Call::Pending (a future not borrowing the isolate). A per-thread drive_event_loop task polls the JS event loop when kicked, so many interfaces can await DB calls concurrently on one thread. settle converts the result.
  • Wake-ups: ingest calls notify_one on the owning slot's Notify (permit kept if busy); a 30 s idle poll is only a safety net.
  • Store calls from workers run on the main runtime (Worker::db). The Postgres pool is shared with intake, and a connection's socket is served by the runtime that opened it: a connection opened on a worker thread stalled intake's commits (the senders' ACKs) whenever that thread was busy in V8. Measured in the runaway demo: one sender's 200 ACKs took 4.0 s instead of 0.9 s; a pass-through run 3.3 s → 0.5 s.

Runaway scripts (ductus-script/src/guard.rs)

  • Every call into V8 is armed with a deadline: the interface's timeout (default 5 s, 10 ms–10 min) for its transform, the module's for loading it. A single script-watchdog thread checks all isolates every 10 ms and, past a deadline, calls IsolateHandle::request_interrupt. The interrupt runs on the isolate's thread inside the running code: it checks the call is still the one armed (generation counter), reads the JS stack to find whose code it is (module URL, then folder), and calls terminate_execution. disarm then cancels the termination; the isolate stays usable. Terminating only from inside the interrupt means a call that finished in the meantime can never be hit.
  • The event loop runs continuations of any interface, so it's armed with the shortest budget on the isolate; when it passes, the interrupt extends the deadline to the budget of the interface found on the stack (or the longest, if none is found).
  • Heap: each isolate is created with heap_limits(0, isolates.heap_mb) (ductus.yaml, default 512). Near the limit V8 calls our callback: we raise the limit by a quarter (or the process dies) and stop the running call the same way, then run a full GC.
  • A termination in the event loop drops every queued continuation (V8 clears the microtask queue), not just the runaway's: every async transform in flight on that thread will never settle. The worker's drive_event_loop signals them all (watch): the culprit gets the Runaway, the others a transient error and are retried.
  • Limits: code in a shared module resumed after an await has no interface file on its stack, so it can't be attributed: all in-flight transforms are retried and it can recur. While runaway code runs, its thread's other interfaces wait up to timeout.

SDK ↔ engine protocol

  • Each interface is loaded through a generated glue module: export const run = (raw) => __invoke(entry.transform, raw, formats) where formats = InterfaceConfig::format_spec() ({"input", "output", "http"}).
  • __invoke decodes the payload by format, calls transform, serializes the result. Special results: null → filtered; kill(reason) → string prefixed ductus_script::KILL; errors containing QUARANTINE / TRANSIENT markers are classified by the worker.
  • Respond-mode HTTP: the engine passes a JSON envelope (method, path, params, query, headers, body); the SDK returns a JSON envelope (status, headers, body).
  • Native ops (ops.rs / ops.js): op_ductus_db_query/execute/fetch/variable/secret, exposed to the SDK as globalThis.__ductus_ops. The runtime implements the Services trait (services.rs); parameters are always bound.
    • Postgres: queries are wrapped as WITH ductus_q AS (<sql>) SELECT json_agg(...) so Postgres produces the JSON.
    • SQL Server (mssql.rs, tiberius): $1 → @P1 outside literals/quoted names/comments; rows are converted in Rust to match Postgres's JSON (numbers, ISO 8601 times, \x hex binary, upper-case GUIDs; unnamed columns column<N>); first result set only. Own small pool: a connection goes back only after a call that succeeded (a failed one may be mid-response or inside an open transaction); idle > 5 min is closed (firewalls drop quiet connections); Azure/AG routing is followed once; named instances via SQL Browser. TLS throughout (tiberius Required, or Strict for TDS 8), verified against the system's certificates plus ca: (trust_cert_ca_bundle) for host or name_in_certificate, unless trust_server_certificate; refusals worded by mssql::refusal.
    • fetch() (services.rs http_fetch, one shared reqwest client): the engine returns every status as a response and marks only network errors/timeouts transient; the SDK turns 5xx/408/429 into transient errors unless retry: false. Responses capped at 16 MB; messages never show the query string or URL password.
    • api(name) uses the same op with api set and a path as url; the engine (resolve_api) puts the path under the API's URL, adds its headers (the code's win) and its timeout, so tokens stay in resources.yaml and never enter V8. HTTP destinations with api: are resolved the same way when their sender is built.
    • Under ductus test the SDK swaps the ops for mocks (mock.db/fetch/env/secret, reset before each test; unmocked calls fail with how to mock them), and tests may be async (run_tests drives the event loop).
    • Pools: one shared pool per database resource (max_connections). A dedicated interface can have its own (dedicated_pools: { patient_db: 4 }), created once and shared by its worker threads; its db() calls use it before the shared one (a bulkhead: a busy interface can't starve the others). Shared-pool interfaces can't: the calling interface isn't known inside a shared isolate.
    • DB and HTTP calls are spawned on the main multi-thread runtime, not the worker thread's: a socket is served by the runtime that opened it, and a worker thread busy in V8 would delay its replies.
    • Temporary (message waits and retries): connection/TLS errors, pool timeouts, deadlocks, failovers, login failures and missing databases (fixed by fixing config; quarantining every message would be worse). Everything else (SQL errors) is permanent → quarantine.

Storage

  • messages: partitioned by retention tier (tier, LIST: messages_t7 ... messages_t365), then by time (received_at, RANGE): a day for the 1w tier, a week for 3w/7w (messages_t21_20261005, tiers named in days, partitions by the period's first day, bounds at midnight UTC). About 24 leaves per table; see Retention below for why. Partial index (interface, partition_key, id) WHERE status = 0 (the queue of older versions; still used to find pending messages). deliveries has the same tiers and a partition for every messages partition, with the same bounds (copied from the catalog). Partitions created 3 days ahead, hourly, one table and tier per transaction (messages, then deliveries): one transaction holding both tables deadlocked with status settling, which reads deliveries then updates messages (migration 0006; reproduced 8/8 by partition_maintenance_never_deadlocks_with_settling), and one holding two tier tables deadlocks with queries, which lock every tier in creation order (found by the stress test, migration 0010). Each waits at most 2 s for its lock (lock_timeout), then gives up until the next hour: a partition DDL waiting behind a long query makes every later insert queue behind it (measured: an insert waited the whole 4 s of a "report"; partition_maintenance_never_holds_up_inserts). Startup retries for up to a minute. Last resort: when tomorrow's partitions are still missing (every hourly try for two days was blocked), maintenance waits for the lock however long it takes (lock_timeout = 0, logged as a warning): new messages wait behind it for as long as the blocking query runs, which beats having nowhere to store them at midnight (partition_maintenance_waits_when_the_day_is_close). With nothing to create, maintenance takes no lock at all. Locking rules and the stress test that checks them (everything_at_once_without_lock_errors: inserts with colliding dedupe keys, a batch writer, three destinations draining, partitions created, keys purged, all at once; it catches the old deadlock 3/3): see decisions.md "Locking" in the planning repo, and CLAUDE.md.
  • dedupe_keys(interface, key, seen_at): checked and claimed in the same transaction as the message insert (one commit).
  • durability: relaxed sets synchronous_commit = off for that insert only.
  • source_state(interface, key, value jsonb): what sources remember (file progress file:<name>, schedule). Always written in the same transaction as the messages it accounts for.
  • Batched inserts (Store::insert_batch) use one INSERT ... SELECT FROM unnest(...) WITH ORDINALITY ORDER BY n (ids keep the batch's order) and claim dedupe keys in bulk (repeats inside the batch are duplicates after the first).

The docs site and the generated references (0.6.8)

  • What it is: docs/ is an mdBook (docs/book.toml, docs/SUMMARY.md), built into target/book. The references under docs/reference/ (CLI, REST API, configuration, Event Log IDs, metrics, audit actions) are written by ductus reference docs from the binary itself and committed, so a diff shows what a change did to them and the site's build needs no Rust compile. docs/reference/libraries.md comes from scripts/dev/dependencies.sh (Cargo.lock and the UI's lock file). The configuration schemas (docs/schema/*.json) are written by a ductus-core test (schemars is a dev-dependency only) and embedded in the binary as text, so ductus types gives the editor the schema of the installed version, offline.
  • Two builds of one book (0.6.8.3): target/book (mdBook's own search) is what the links are checked on and what each release attaches as the offline docs zip; target/site (scripts/dev/docs-site.sh: the same book with mdBook's search turned off, then a Pagefind index of each page's <main>) is what docs.getductus.com serves. docs/site/search.js puts Pagefind's search modal in the menu bar (S, / and Ctrl+K open it) only where mdBook's search isn't on the page and the page isn't opened from disk, so the zip's pages never show it. Why two: mdBook's search downloads the whole index before its first result, and Pagefind downloads pieces but can't search from file:// (measurements below).
  • Checks: scripts/dev/docs-check.sh builds the book and runs lychee offline, with anchors, over the built HTML. It runs in check.sh (when mdBook and lychee are installed) and in .github/workflows/docs.yml on every docs change; a weekly run also checks other sites' links and reports without failing. docs.yml then builds the site and runs scripts/dev/docs-search-check.cjs: both searches in a real browser (the runner's Chrome through playwright-core). Publishing (pages job, target/site) waits for the repository variable DOCS_PAGES=on.
  • What it costs (measured 2026-10-30, 0.6.8.1, WSL on the D: disk, the repo on /mnt/d):
    • The binary: 142,961,384 bytes against 142,246,552 for 0.6.7.3, both from cargo build --release (check.sh's build step) in their own target dirs: +714,832 bytes (+0.50%). The five embedded schemas are 288,659 bytes of it (pretty-printed JSON, every setting's description included); the rest is the reference command, the tables it reads (metrics, audit actions, Event Log IDs) and 75 options' new help texts. Minified, the schemas are 236,160 bytes: rebuilt that way the binary is 142,911,792 bytes, 49,592 bytes (0.035%) smaller. Not done: the files people read in .ductus/schema/ stay readable, and 50 KB is noise next to V8. (Gzipped the schemas would be ~46 KB, at the price of a decompression step; also not worth it.) Note: the binary check.sh left in ~/target was 125,800 bytes larger (143,087,184): its test stage builds the binary again, likely with dev-dependency features unified in. Compare sizes from cargo build --release alone; the sizes in STATUS and history before 0.6.8.1 part C were read after check.sh, so they carry that difference.

    • check.sh: ductus reference docs 0.32–0.49 s (5 runs); docs-check.sh 5.98–6.59 s (5 runs), of which mdBook 1.74–1.78 s and lychee 4.32–4.38 s over 3,122 links (3 runs each). So ~6.5 s on a ~600 s check.sh (~1%). The design expected 1–2 s: lychee over the built HTML on /mnt/d (Windows' drive, slow from WSL) is most of it.

    • docs.yml on GitHub's runner (ubuntu-24.04): the build job 5–8 s from start to end, installing mdBook and lychee included (~1 s); building and checking the site ~1 s; the whole run 9–19 s from push to done (4 runs). The network check (links-online) adds a parallel job of ~7 s.

    • The site: 7,993,318 bytes, 109 files, 64 pages, from 61 markdown files (591,129 bytes, 95,396 words; the generated references are 291,955 bytes of it). The search index (searchindex.js, loaded by the browser on the first search) is 3,286,297 bytes, 465,479 gzipped (GitHub Pages serves it compressed). The theme (fonts, CSS, scripts) is 1,928,680 bytes; print.html (every page in one) 893,859 bytes.

    • After the first half of the guides (0.6.8.2 part A, measured 2026-10-30): 84 pages in the book, 770,058 bytes of markdown (120,558 words; the 25 new guide pages 178,123 bytes, 24,783 words); the site 9,482,184 bytes, 133 files, 88 HTML pages. The search index 4,036,253 bytes (+749,956), 589,073 gzipped (-9; 619,144 at -6): 5.2 bytes of index per byte of markdown. docs-check.sh 8.7–9.0 s (3 runs; 4,379 links, lychee most of it). Under both thresholds below, but the second half of the guides (writing interfaces, operate, upgrade, troubleshooting) is likely to bring the index to about 5 MB: measure again there.

    • After the guides (0.6.8.2 part C, measured 2026-10-31): 136 HTML pages, 1,010,951 bytes of markdown (the reference 291,907 of it); the site 11,587,479 bytes. The search index 5,004,962 bytes, 776,262 gzipped (-6; 739,527 at -9): past the ~5 MB line. It's loaded on the first search, not with the page. The first search (headless Chromium, the book served gzipped as GitHub Pages does, network throttled through DevTools, cache off, from typing "retention" to the first result shown, 3 runs each; search-time.cjs in history.md): unthrottled 0.14–0.33 s; 10 Mbit/s with 40 ms latency 0.90–1.04 s; 4 Mbit/s, 100 ms 1.87–1.91 s; 1.6 Mbit/s, 150 ms (3G) 4.45–4.47 s. What mdBook's settings can do (same book): no search on reference/libraries.md 749,205 gzipped (−3.5%); no search on it and reference/api/ 686,972 (−11.5%; 4 Mbit/s 1.87–1.89 s, 3G 3.89–3.91 s: latency and parsing count as much as bytes there); heading-split-level = 2 706,796 (−9%, but a setting's result would land on its page's top: the configuration reference has a ### per setting); heading-split-level = 1 561,707 (−28%, every result a page's top); no search on reference/ 551,797 (−29%), but mdBook 0.4.52 then finds nothing at all (no error; the same query on the full build finds 30). A split index (Pagefind 1.3.0, which loads its index in pieces): indexed in 2.5 s, 2,023,605 bytes in 167 files; a first search fetches ~220 KB in 11 requests: unthrottled 0.09–0.12 s, 10 Mbit/s 0.45–0.47 s, 4 Mbit/s 0.90–0.91 s, 3G 1.57–1.59 s. But Pagefind can't search from file:// (it loads its index as modules: "Failed to fetch dynamically imported module"), while mdBook's search works there: the release's docs zip, read offline, would lose search.

    • Pagefind on the site (0.6.8.3, measured 2026-10-31): Todd chose Pagefind for docs.getductus.com and mdBook's search for the zip. Pagefind 1.5.2 (pinned with its checksum in docs-tools.sh) indexes the 133 searchable pages in 0.75 s; docs-site.sh takes 7–10 s on /mnt/d. Its folder in the site is 1,423,654 bytes in 159 files. The first search, measured again with the search as people use it (scripts/bench-docs-search.cjs; a second run gave 4 Mbit/s 1.21–1.27 s): the page loaded, S pressed, "retention" typed, until the first result is in the page, timed by the page itself; the test server paces every response (a latency per request, one shared link at the profile's bandwidth, gzipped as Pages serves it), because Pagefind 1.5 searches in a Web Worker whose requests DevTools' throttling neither slows nor counts; 3 runs each, both builds of the same commit:

      local10 Mbit/s, 40 ms4 Mbit/s, 100 ms3G (1.6 Mbit/s, 150 ms)
      mdBook (the zip; the site before)0.20–0.30 s0.83–0.90 s1.80–1.91 s4.18–4.29 s
      Pagefind's modal (the site now)0.45–0.53 s0.65–0.72 s1.09–1.21 s1.82–1.89 s

      mdBook's first search downloads 772,889 bytes in one request; Pagefind's 143,455 bytes in 12, six of them in a chain (its script, its worker, the entry, the metadata, the 72 KB WebAssembly, then the 33 KB index chunk for the word, then a fragment per result shown). Slower on a fast connection: Pagefind's input waits 300 ms after the last key before it searches (its default; mdBook doesn't wait), which on slow connections overlaps the downloads and costs nothing. Each page load is 29,256 bytes more (399,515 vs 370,259: Pagefind's UI script and styles, 39 KB and 7 KB gzipped, in place of mdBook's search scripts). Part C's numbers were taken with DevTools throttling and Playwright's waitFor, which re-checks at growing intervals and rounds times up, and Pagefind 1.3's API without its UI: these replace them.

    • Ranking: guides first (Todd, 2026-10-31). Pagefind ranks short pages first, so for 8 of 12 everyday words (retention, alerts, owners, drain, handover, upgrade, quarantine, ldap) a reference page came first ("retention": the CLI page, then the API's, before operate/retention). docs-site.sh weighs each page's <main> (data-pagefind-weight, which scales every word in it, headings included): reference pages 0.1, nuances 0.6, guides Pagefind's 1. Then an explanation comes first for 11 of the 12 (the CLI's page stays first for "handover"), the reference still shows in the first five for the commands' names, and a word only the reference has (payload_read_window) still finds it first. Weighting guides up instead (2 or 5 on their <main>) lifted the long pages (schema.md first for "alerts"). docs-search-check.cjs checks "retention" opens with the guide. Pagefind's 300 ms wait after the last key stays (Todd, 2026-10-31).

    • The offline zip (target/book zipped: 3,212,622 bytes), unpacked into a folder whose name has a space and opened from file://: mdBook's search finds and opens pages, no errors, no Pagefind on the page (docs-search-check.cjs checks this on every docs change).

    • When to act: the site's search no longer loads one index of everything, so the ~5 MB line applies only to the zip's search now, read from disk where size costs little. Watch instead Pagefind's index chunk for common words (33 KB for "retention") and the page-load cost above. If docs-check.sh passes ~15 s in check.sh (10 s on 2026-10-31, 6,398 links), run lychee from WSL's own disk (target/book under $CARGO_TARGET_DIR) instead of /mnt/d.

Performance notes (measured, WSL, 2.5 ms fsync disk)

  • One sender connection is bounded by one durable commit per ACK: ~443 msg/s strict, ~868/s relaxed; 16 connections ~2,100/s (group commit).

  • SDK: ~14.8k realistic ORU transforms/s per thread. Embedded V8 call overhead is negligible.

  • 1,001 interfaces: ~111 MB, 0 idle CPU. Ready (launch → started) in 0.69–0.83 s warm, 1.3 s with a cold file cache (0.6.2, WSL D: disk; it had grown to 4.9–5.2 s by v0.5, unmeasured since v0.3's 0.4 s). Where it went: retention applied in one transaction per interface (3.3 s; now one read, plus writes only for changes), every worker reading its queue at once through the 32-connection pool (startup tasks queued behind ~1,000 reads; now only interfaces with something waiting read at start), reconcile over the whole backlog for every interface (now only those with something waiting), destinations registered with a conflict check each (now read first). Each step's time: RUST_LOG=info,ductus_runtime=debug ("start: …"). Over a Unix socket (or with TCP_NODELAY) the start is 0.35 s: sqlx 0.9 doesn't set TCP_NODELAY, and statements with large parameters (1,001-element arrays) wait ~40 ms for a delayed ACK over TCP. Message flow is unaffected (fan-out benchmark within noise with and without it). Fixed upstream (transact-rs/sqlx#4336, after 0.9.0): the next sqlx release brings it.

  • TLS against plain (0.6.3.4, scripts/bench-tls.sh, one engine, WSL on the D: disk: ~5 ms durable commits, so absolute rates sit below the 443/s above): strict MLLP ingest the same within noise: 1 connection 234–239 ACKs/s TLS vs 238–245 plain, 16 connections 2,690–2,780 vs 2,720–2,930 (two rounds each, alternated; one handshake per connection, then the commit flush bounds both). Handshake (a sender's connect to its end, 500 one after another): ECDSA P-256 0.56 ms p50 / 0.80 p95, RSA 2048 0.96 / 1.53, ECDSA with a client certificate checked 0.66 / 0.85, TLS 1.3 resumption 0.54 / 0.68 (it still does a key exchange); plain TCP connect 0.08. Engine memory per idle connection, 1,000 held (a fresh engine each): TLS ~22.6 kB, plain ~14.9 kB. Respond mode (a trivial JSON answer, 2,000 GETs one after another): HTTPS kept alive 0.29 ms p50 / 0.39 p95 (HTTP 0.27 / 0.36); a new connection and full handshake per request 1.2 / 3.0 (HTTP 0.51 / 0.73). Guideline: TLS on every feed that crosses a network not ours, no capacity planning needed for it; respond-mode callers keep connections alive (a new HTTPS connection per call ~4× the time); senders handshaking per message pay ~1 ms each with RSA (many at once not measured). the nuances ("What TLS costs").

  • TLS to Postgres (0.6.4.2, scripts/bench-pgtls.sh, a throwaway Postgres 18 with its own CA on the D: disk, sslmode=disable against verify-full, three rounds alternated): strict ingest 1 connection 217–233 ACKs/s plain vs 224–241 TLS (noise), 16 connections 2,786–2,883 vs 2,560–2,739 (~7% lower); a durable commit 2.8 vs 3.0 ms. Opening a connection (TLS and login, 200 in a row): 5.2 ms p50 plain, 64 prefer, 79 verify-full. Nearly all of it is sqlx 0.9.0 not setting TCP_NODELAY (the handshake then the startup message wait on delayed ACKs): with an LD_PRELOAD shim setting it, opening is 8.6 / 10.6 / 22 ms and 16-connection ingest 2,821–2,834 vs 2,765–2,770 (~2%). verify-full's extra ~10 ms is sqlx reading the system's certificates at every new connection. The 1,001-interface start on a dev Postgres with ssl on (so prefer = TLS): 0.91–1.06 s vs 0.52–0.66 s with sslmode=disable; with the shim 0.63–0.72 vs 0.54–0.60. Background: nothing new runs; each verified database adds one handshake to the scheduled certificate check, and a renewed client certificate is put in its pool's options by the minute's file check that already runs. Binary +0.34 MB (141,809,832 bytes). the nuances ("Opening a database connection over TLS costs ~60 ms, for now".

  • TLS to SQL Server (0.6.4.3, scripts/bench-mssqltls.sh, the test SQL Server 2025 in Docker on this machine, three rounds alternated, 100 connections in a row each): opening a connection as db()'s pool does (PRELOGIN, TLS, login, SELECT 1): 123–130 ms p50 trust_server_certificate, 120–141 verified, 116–141 strict verified: within the noise between rounds, so verification costs nothing measurable (tiberius sets TCP_NODELAY). Doctor's certificate check of it (DNS, TCP, PRELOGIN, the handshake): 33–36 ms p50 through PRELOGIN (TLS 1.2, so no wait for a late refusal), 545–564 ms strict (TLS 1.3: ~45 ms plus the 0.5 s wait every TLS 1.3 check makes for a refused client certificate). Doctor's TCP connections now set TCP_NODELAY and skip that wait after TLS 1.2 (it was 550 ms through PRELOGIN). Background: nothing new runs; each verified SQL Server adds one handshake to the 6-hourly certificate check.

  • File ingest (split: records, durability: relaxed): ~100k CSV rows/s (300,000 rows, 13 MB, 500 per transaction); ~155k before status per destination (a delivery row costs about as much to insert as the message). Strict MLLP ingest (16 connections) unchanged: ~3,100/s either way, it's bound by the commit flush.

  • Fan-out (scripts/bench-fanout.sh, 6,000 messages, 16 connections, strict, file destinations): 1 destination 3,125 ACKs/s, all delivered in 3.9 s; 5 destinations 3,081/s, 3.6 s (8,444 deliveries/s); 25 destinations 1,710/s, 8.0 s (18,776 deliveries/s). Ingest holds up to 5 destinations; at 25 the drain's status writes (~19k/s) compete with ingest for the same database. A single destination drains at ~1,500/s: a shared interface has one worker per destination (isolation: dedicated with workers: N for more). With retention tiers (v0.5 step 5; fresh databases, two runs each, before → after): 1 and 5 destinations unchanged within noise; 25 destinations ingest 1,663–1,751 → 1,394–1,445 ACKs/s and deliver 4–13% slower (each message writes 25 delivery rows, whose key now includes the tier). A fresh database has ~15 partitions per table; after 7 weeks, ~24 (see Retention). At steady state (every partition a database has once its longest window has passed; two runs each): five tiers (115 partitions per table) → four (46): everything delivered 1 destination 5.9–6.5 → 4.8–5.0 s, 5 destinations 5.3 → 4.1–4.2 s, 25 destinations 12.3–12.7 → 9.3–9.4 s (~12,000 → ~16,000 deliveries/s); ingest unchanged for 1 destination, ~8% higher with 5 and 25. 50 destinations (step 6, three tiers at steady state): ~500–700 ACKs/s, everything delivered in ~16 s (~19,000 deliveries/s): delivery throughput is flat from 25 destinations up, ingest falls with each delivery row the ACK waits for (guideline in the nuances). The queue index (migration 0011) changed none of these (fresh tables: no delivered rows ahead of the queue); it matters once a day of delivered traffic sits ahead of a backlog (queue read ~300 ms → ~1.3 ms).

Nuances: what to know before you rely on it

Every engine has behaviour that surprises people the first time. Ductus can't remove all of it, but it can tell you about it up front. These pages are the running list: what happens, why, and how to write your interfaces and set expectations with it in mind.

Each entry has a level (1 = everyone needs it on day one, 5 = expert). The levels match the planned course tracks, from first interface to expert.

For engine developers: whenever a change adds or reveals a nuance, add it to its section's page in the same commit, with a level, and link it from the guide page it affects (every_nuance_is_linked_from_a_guide fails otherwise).

LevelWho it's for
1 FoundationsWriting a first interface
2 PractitionerBuilding and testing interfaces day to day
3 ProfessionalDatabases, APIs, files, schedules, ordering, retries
4 AdvancedIsolation, performance, operations
5 ExpertDelivery guarantees and failure semantics under crashes and runaways

By subject

Level 1: Foundations (24)

Level 2: Practitioner (84)

Level 3: Professional (67)

Level 4: Advanced (29)

Level 5: Expert (4)

Nuances: messages and delivery

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

A message can be delivered twice (level 3)

  • What: delivery is at least once. If the engine crashes (or is killed) after the destination accepted a message but before recording it as sent, the message is sent again on restart. Sent statuses are saved in batches of up to 50, so a crash can re-send up to that many per worker and destination. A clean stop saves them first (see Stopping the engine below), so it re-sends nothing.
  • Why: the alternative (at most once) loses messages. Nothing that was ACKed is ever lost.
  • What to do: make receivers tolerate repeats (most HL7 receivers dedupe on MSH-10), or use dedupe on the receiving interface.

Your transform can run more than once for the same message (level 3)

  • What: after a crash, a temporary failure (database down, fetch() getting a 503) or an interruption (see Runaway scripts), the whole transform runs again from the start. Any db().execute() or fetch() POST it made the first time happens again.
  • What to do: make writes idempotent: INSERT ... ON CONFLICT DO NOTHING, upserts keyed on the message's control ID, or an idempotency key header on API calls.

Only the original is stored; outputs are recomputed (level 3)

  • What: the engine stores each message exactly as received and never changes it. What was sent isn't stored by default; it's reproduced by running the same code on the original. That's exact only if the transform depends on nothing but the message and the code. Lookups (db(), fetch()), the current time and environment values can give a different result when re-run later.
  • Why: transforms are fast, and storing every output would cost a database write per message.
  • What to do: if you must be able to prove exactly what was sent, set save_output: true on that destination: what it was sent is kept with its delivery. Keep transforms that don't need lookups free of them. ductus replay recomputes a period's outputs on demand and compares them (see Upgrades).

Stopping the engine: what finishes and what waits (level 2)

  • What: Ctrl-C, SIGTERM (systemctl stop, docker stop, Kubernetes) and ductus drain --stop all stop cleanly: listeners close, messages being stored get their ACK, each worker finishes the message in hand (a transform or a send under way completes) and saves what it finished, then the engine exits with code 0. A message waiting in a retry back-off or a rate-limit wait isn't sent: it stays first in its lane for the next start. A stop that takes longer than --stop-timeout (DUCTUS_STOP_TIMEOUT, 30 s) exits anyway, and so does a second Ctrl-C or SIGTERM; then the messages in hand are processed again at the next start, like after a crash. Measured (scripts/demo-stop.sh, 6,000 messages over 8 connections, stopped 1 s in; WSL): SIGTERM re-sent nothing in 6 runs and exited with code 0 within 27–42 ms (the stop itself ~15–21 ms); kill -9 at the same point re-sent 5 messages in one run of 3 (the window is small: workers save after every read). With the receiver down, SIGTERM exited in ~40 ms instead of waiting out the back-off, leaving the 20 messages pending.
  • Why: an upgrade or a server reboot shouldn't count as a crash.
  • What to do: prefer ductus drain --stop before planned work (it also lets the queues empty first); a plain service stop is just as safe for messages. Keep a transform's timeout well under the stop limit, and give the service manager longer than the stop limit (the packages set 45 s; Docker's default is 10 s, so use docker stop --time 45 or stop_grace_period).

A "done" marker can't prevent duplicates (level 5)

  • What: recording "this insert happened" in the engine's database doesn't make an insert in another database happen exactly once: the two writes can't share a transaction, so a crash between them still repeats the insert on re-run.
  • What to do: put the guarantee where the data lands: a unique key or upsert in the target table, or an idempotency key the receiving API checks. Use the message's stable key meta.id (the second argument of transform: transform(msg, meta)) for it. It's unique within one engine database; add something per environment if several engines write to the same place.

Nuances: the store (Postgres)

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

A query that holds the message tables for days can pause intake (level 4)

  • What: each day's tables are created ahead of time, hourly. Creating one needs a brief exclusive lock on messages/deliveries, which has to wait for every open query on them. Maintenance gives up after 2 s and tries again in an hour (partitions exist 3 days ahead), so a long report never holds up ACKs. If tomorrow's partitions still don't exist (something held the tables through every try for two days), maintenance stops giving up and waits: new messages (and their ACKs) then wait as long as the blocking query runs, rather than fail at midnight. The engine logs a warning when that happens.
  • What to do: run long reports against a replica, or keep them short; never leave a transaction open on the store. Watch for the warning "a partition needed soon is missing".

A start waits 30 s for a database that refuses, then says why (level 2)

  • What: when the engine's database refuses connections or doesn't answer, the start keeps trying for 30 s before it gives up and says why ("refused (tried for 30 s): nothing accepts connections there…"). A wrong password, a database that doesn't exist or a host name nobody knows fail at once, since trying again can't help.
  • Why: at a server's boot the database may still be starting when the engine does; 30 s covers that without a failed start. Under the Linux package's service a failed start is tried again every 5 s anyway.
  • What to do: nothing at boot. When you're fixing a URL, ductus doctor answers in under a second, step by step.

Nuances: several engines on one database

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

An interface runs on one engine at a time; a second one is refused (level 2)

  • What: every engine holds a lock on the database while it runs and claims its interfaces when it starts. An engine started with an interface another live engine runs refuses to start (exit 3), whatever else it has: "another engine is running 2 of this engine's interfaces on this database: adt, orders; owned by: host-b, pid 4120, ductus 0.6.0, started …". The refusal is in the audit log (engine.refused). Engines with different interfaces share a database without trouble; each lists the others (Overview, GET /engine others). Respond-mode HTTP interfaces aren't claimed: they have no queue, so several engines may answer them behind a load balancer.
  • Why: two engines running one interface both read its queue: both send each message (duplicates), and per-patient order breaks. A DR server booted with production's config, or a test VM pointed at the production database, did exactly that before v0.6.
  • What to do: stop the other engine first (the message names its host and pid), or take the interfaces out of this one's project. A refused start isn't retried by the service manager (systemd: RestartPreventExitStatus=3; Windows restarts only after a crash): fix the cause, then start it.

A dead engine's interfaces are free at once; a vanished host's within ~25 s (level 3)

  • What: an engine's lock lives in its own database session. When the process ends in any way (kill -9, a crash), the operating system closes the connection and Postgres frees the lock: the next engine can claim its interfaces immediately (0.3 s after kill -9 in demo-one-owner.sh). When the whole host vanishes (power loss, a network cut), nothing closes the connection; the engine asks Postgres to probe it (TCP keepalives on that session: 10 s idle, then 3 probes 5 s apart), so the lock goes within about 25 s.
  • Automatic standby is not this: a second engine doesn't wait to take over; it refuses, and you start it when the first is gone. A standby that takes over by itself is v1.2's clustering.
  • What to do: for DR, keep the standby engine stopped (or pointed elsewhere) and start it after the primary is down. Nothing else to configure.

Losing the database connection pauses sending, not receiving (level 3)

  • What: if the engine loses the connection that holds its lock (a database restart, a failover, a DBA ending the session), it pauses sending, file pickup and schedules within a few seconds (it checks every 2 s), keeps receiving (storing is safe from any number of engines), and takes the lock and its interfaces back as soon as it can; then it sends what came in meanwhile. If another engine took an interface over in the gap, this one stops (exit 3) rather than run it twice. The Overview says "Cut off from the database" while it lasts.
  • What to do: nothing for restarts and failovers. Don't end the ductus engine lock session by hand unless you mean to pause that engine.

PgBouncer in transaction mode can't hold the engine's lock (level 3)

  • What: the lock is a session advisory lock: it needs one database session for the engine's whole life. A pooler in transaction (or statement) mode hands each transaction a different server connection, so the lock would be taken on one and lost at once.
  • What to do: point the engine at Postgres directly, or through a pooler in session mode. The engine pools its own connections (--db-connections / DUCTUS_DB_CONNECTIONS, 32 by default) plus one for the lock.

Nuances: retention

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

Retention is 1, 3 or 7 weeks, not any number of days (level 1)

  • What: retention: in interface.yaml is 1w, 3w or 7w (or the same in days: 7d, 21d, 49d; default: retention in ductus.yaml, else 7w). Anything else is refused by ductus validate.
  • Why: performance. Each window is a tier of partitions, and old messages go by dropping whole partitions, which costs nothing however many messages there are. Deleting rows one by one (the usual pruner) slows the busiest tables and needs vacuuming after. Each tier adds partitions that some queries have to plan for, so there are few, short tiers, keeping the count around 24.
  • What to do: pick the window that covers how far back you need to look into problems. Older history: the source system, or an archive on cheaper disk (an export is planned). The engine isn't a data warehouse.

Messages are kept at least their window, and gone within a period after (level 2)

  • What: a partition covers a day (1w) or a week (3w, 7w), and it's dropped once all of it is older than the window. So a 1w message lives 7 to 8 days, a 7w one 49 to 56. Partitions are checked hourly.
  • What to do: treat the window as "at least". If something must be gone sooner, use a shorter tier.

Pending messages are never purged; past the window they're held (level 2)

  • What: when a partition expires, the messages in it that someone still needs (pending for a destination, and quarantined ones) move to the held area with all their deliveries, and the partition drops as usual. Held messages stay in their destination's queue, in order, and in the message browser; they're delivered or resolved like any other. A month of the held area is dropped whole once it's over and nothing in it is pending or quarantined (its finished messages go with it). ductus retention shows the held area by month and interface, and /metrics has ductus_held_messages and ductus_held_bytes (both counted at the hourly retention run). A shorter retention never deletes pending or quarantined messages either.
  • Why: a destination that was down for a month must not lose its backlog, and quarantined messages wait for a person. Before the held area, one waiting message kept its whole week for every interface in the tier.
  • What to do: watch ductus_held_messages (alert on quarantined ones growing: each is a question nobody has answered). Moving a backlog writes each message once more, once, in the background: about 5,000 messages a second with two destinations each (300,000 in 58 s on a dev machine, busy half the time), so a day of a 1M-message feed held for one receiver takes 3 to 4 minutes to move, and 90M about 5 hours. Nothing to do unless the database is already at its limit at the top of the hour.

Quarantined messages are kept until resolved, unless you say purge (level 2)

  • What: quarantined: keep (the default) holds quarantined messages past the retention window until someone resolves them. quarantined: purge (in interface.yaml, or ductus.yaml for every interface) lets them go with their partition, like the finished ones. There's no second time limit.
  • Why: in most engines unresolved quarantines pile up for years; keeping them is the safe default, and purging is a decision someone makes on purpose for a feed where a missed message is resent anyway.
  • What to do: keep the default unless the feed's quarantines are routinely ignored and resent by the sender; then purge, and say why in about:.

Shortening retention deletes messages, so it needs a confirmation (level 2)

  • What: deploying a shorter retention: doesn't apply it. The engine logs a warning and keeps the old one, and ductus retention shows how many messages the new value would delete. ductus retention confirm <interface> (an operator token) applies it: its finished messages past the new window are deleted in the background, the others move into the new tier. Lengthening needs no confirmation and keeps everything not yet dropped.
  • Why: a typo (7d for 70d, say) shouldn't delete a quarter of the history.
  • What to do: confirm after deploying, on each engine (it's per database).

One retention change at a time per interface (level 3)

  • What: a change moves (or deletes) the interface's older messages in batches, at most half the time busy (the demo's 8,000 messages took 1 to 1.5 s on a dev machine: a day of a 1M-message feed would take a few minutes). A different value deployed meanwhile waits until it's done; values in between are skipped. A change also waits for the interface's pending messages to be delivered before it finishes (they move once delivered).
  • What to do: watch progress with ductus retention (messages left). Don't flip retention back and forth.

Shortening frees disk when partitions go, not at once (level 4)

  • What: deleted messages leave space that Postgres reuses only within the same partition, and old partitions get no new messages. The disk space comes back when their partitions are dropped, at most the old window later.
  • What to do: to free space now, shorten and then, off-hours, VACUUM FULL the affected old partitions yourself (it locks each while it runs). A built-in compaction is planned.

Nuances: several destinations

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

Each destination runs its own function on the original message (level 2)

  • What: with destinations:, every destination calls its function (transform unless it names another with transform: toBilling) on the message as received, not on another destination's result. Three destinations using transform run it three times, and each run makes its own db() and fetch() calls.
  • Why: each destination works through its own queue at its own pace (one can be hours behind), and outputs aren't stored, so nothing can be handed from one to the other.
  • What to do: put shared preparation in a helper the functions call. Keep lookups in the functions that need them. Module-level state is shared by all of an interface's functions (one module), so a bounded cache can save repeated lookups.

One destination down holds up only itself (level 2)

  • What: each destination has its own queue, order and retries. While the lab is down, billing and the archive keep receiving; the lab's messages wait, in order, and go once it's back. A message's overall status stays pending until every destination is done with it. Then it's quarantined if any destination quarantined it, sent if any destination got it, and filtered if all of them filtered it. The status per destination says which (ductus status lists them).
  • What to do: watch each destination's queue, not just the interface's.

Pausing a destination: at once, no questions, and it holds (level 2)

  • What: ductus pause <interface> [destination] (or the API) stops sending to that destination at once: no new sends and no retries. A send already under way finishes. Messages keep arriving and wait, in order. No reason is needed to pause. Add one when there's time by running it again with --reason "..." (until then it shows "no reason yet"). The pause is stored, so a restart or a failover keeps it. A paused destination doesn't hold up a drain. ductus resume <interface> [destination] sends the backlog in order. Without arguments, ductus resume ends a drain.
  • Why: in an emergency (a receiver breached, a vendor sending garbage back) you stop the damage first and explain after. A destination that's merely down already waits and retries by itself, so it needs no pause.
  • What to do:
    • Down for minutes to hours: do nothing; it retries.
    • Cut off on purpose, or down for days to weeks: pause it, so it stops retrying and shows as paused, not stalled. Add the reason.
    • Longer than the interface's retention window: the waiting messages move to the held area as their partitions expire, and the rest of the history goes on schedule, so the disk holds the normal window plus the backlog (not the whole period it spans). Watch the held area's size (ductus retention). Agree a release plan with the vendor before they're back: the backlog goes in order, at the destination's rate limit (90M at 200/s is about 5 days).

Filtering and quarantine are per destination (level 2)

  • What: return null, kill() and quarantine() in a destination's function apply to that destination only. The message still goes to the others.

Adding, removing or renaming a destination (level 3)

  • What: at startup the engine matches stored deliveries to interface.yaml. A new destination also gets the messages still in flight (pending for any destination), not older ones. A removed destination's waiting messages are marked filtered ("destination x was removed from interface.yaml") and logged. A rename is both: the new name gets everything in flight, including what the old name had waiting, and may re-send some the old name had already sent.
  • What to do: rename only when the destination is caught up. Removing a destination by mistake can't be undone by putting it back: its waiting messages were already filtered.

Many destinations on one interface: when to split (level 3)

  • What: every destination adds a delivery row per message, written in the same statement as the message and updated when it's sent. So a message with 25 destinations costs the database about 25 small writes on the way in and 25 on the way out, and senders wait for the first part before their ACK.
  • Measured (dev laptop under WSL, strict durability, 16 sender connections, file destinations, steady-state partitions; scripts/bench-fanout.sh): 1 and 5 destinations ingest ~2,700–3,400 messages/s; 25 destinations ~1,150–1,300/s; 50 destinations ~500–700/s. Delivery tops out around 15,000–20,000 deliveries/s in total, whatever the fan-out: at 50 destinations, 6,000 messages took ~16 s to reach everyone (one run 27 s). For scale, 1M ADT messages a day averages ~12/s, with peaks of a few hundred.
  • What to do:
    • Up to ~10 destinations per interface: nothing to plan, at any realistic volume.
    • More than that: multiply your peak messages/s by the number of destinations and compare with the deliveries/s your server measures (~15,000 on a laptop; ductus bench will measure it, v0.6). Under a third of it is comfortable: that leaves room for a burst of 3x and for catching up after a receiver's outage. Over that, a burst builds a backlog that clears only as fast as the spare capacity allows.
    • If it doesn't fit: split the feed. Route to a few internal interfaces (destination: { interface: adt_out_a }) that each fan out to a share of the receivers, or give low-priority receivers (archives, analytics) their own interface fed from a route. The sender's ACK then waits for a few delivery rows instead of dozens; in exchange, each hop stores one more copy of the message, so total writes grow a little.

A runaway is blamed on the interface, not the destination (level 5)

  • What: when code runs away after an await, the engine knows which interface's code it was, not which function. Every call of that interface in flight on the thread is quarantined, so another destination's message can be quarantined with it. Each one counts towards stopping the interface.

Nuances: order, retries and durability

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

Order is per key, and one stuck message holds up its key (level 3)

  • What: messages with the same ordering key (e.g. MRN) are processed strictly in arrival order. A message that keeps failing temporarily holds up every lane of its worker for that destination, not just its own key, until it succeeds or is quarantined: a shared interface has one worker per destination, so one failing message holds up the whole destination; a dedicated one with workers: 4 holds up about a quarter of the patients. With no ordering.key, the whole interface is one lane.
  • Why: that's the price of ordering: patient B's update must not overtake patient A's. A worker handles its lanes one message at a time, in arrival order; it doesn't skip ahead past a message it's retrying.
  • What to do: choose a key that's as narrow as correctness allows. Set retry.max_attempts on destinations where waiting forever is worse than quarantining. When a destination is stalled, ductus hotspots <interface> names the message, its key and its worker (see Monitoring).

Change ordering.partitions only with an empty queue (level 4)

  • What: each message's lane is computed from its key and the interface's lanes when it arrives, and stored. The lanes are partitions with an ordering.key, and one without (removing the key is lowering them to 1). Change them while messages are waiting and:
    • Lowered: waiting messages in lanes at or above the new count would belong to no worker and never be sent. The engine refuses to start (exit 3), naming each destination, how many wait and in which lanes: "adt → lab: 412 messages waiting in lanes 32–63; adt now has 32 lanes (it had 64)". ductus validate with DUCTUS_DATABASE_URL set says the same before you deploy.
    • Raised: allowed, with a warning in the log and an interface.partitions_raised row in the audit log: a patient's waiting messages stay in their old lane while new ones go to another (maybe another worker), so until the backlog drains that patient's messages can be delivered out of order. Raising doesn't lose anything, so it isn't refused; whether that brief disorder matters is your call.
    • workers alone: safe (every lane still has one owner).
  • Why: the engine records the lanes each interface last ran with (interface_applied) and, when they went down, counts what waits in the lanes being dropped (one pass over the queue index: ~20–60 ms for 1,001 interfaces with 300,000 waiting, measured on WSL).
  • What to do: change the lanes only when the interface has nothing pending: keep the old value, ductus drain --wait (new messages wait at the sender, the engine sends what it has), then restart with the new value. Run ductus validate against the database before deploying. To give each of N workers 64 lanes, set partitions: 64 * N. Re-assigning waiting messages to the new lanes automatically is planned (v0.7).

Without an ordering key, more workers don't help (level 3)

  • What: an interface with no ordering.key is one lane, and a lane has exactly one owner. isolation: dedicated with workers: 8 still processes it one message at a time per destination (the other seven workers have no lanes).
  • Why: with no key, the only safe order is the order of arrival for everything.
  • What to do: scale in this order: (1) set an ordering key (64 lanes by default), (2) isolation: dedicated (own threads, 4 workers by default), (3) raise workers up to about the number of cores, (4) dedicated_pools if it does database lookups. Most interfaces need none of it: one destination of a shared interface drained ~1,500 messages/s on a laptop.

Temporary failures retry forever by default (level 3)

  • What: a destination that's down, or a transform that hits a temporary error, is retried with back-off (1 s up to 60 s) until it works. retry.max_attempts limits delivery attempts; transform retries (database or API down) have no limit.
  • What to do: set monitor: { behind: ... } (see Monitoring) rather than expecting failures to show up as quarantined messages. A destination retrying for a minute with messages waiting shows as stalled with the error it gets.

Database login failures make messages wait, not fail (level 3)

  • What: a wrong password or a missing database counts as temporary: messages wait and retry instead of being quarantined.
  • Why: it's a config problem; quarantining thousands of messages would make the fix much harder.

durability: relaxed can lose the last fraction of a second (level 4)

  • What: ACKs don't wait for the disk flush. If the database server crashes or loses power, the last moments of ACKed messages can be lost. An engine crash alone never loses anything.

Respond-mode HTTP requests aren't stored (level 3)

  • What: in mode: respond, the transform's result is the HTTP response; there's no queue and no stored message. If something fails, the caller gets an error status and decides whether to retry.

Nuances: resending and correcting messages

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

Resend sends a message again in place: first in its lane, not last (level 2)

  • What: resending (POST /api/v1/messages/{id}/resend, or many at once with /messages/resend) puts a destination's finished delivery back to pending. The message keeps its id, so its worker takes it next in its key's lane, ahead of the messages waiting there, but after the newer messages for the same key that were already sent. Without destinations named, only the quarantined deliveries are resent. Naming a destination resends to it whatever its delivery ended as, sent or filtered too (a replay: "the lab lost yesterday's results"). Pending deliveries are left alone: they're being sent or retried already. Each resend is recorded with who did it, why, and what the delivery was before (GET /messages/{id} shows them).
  • Why: in place keeps the message's identity and history in one row; nothing is copied.
  • What to do: for feeds that carry state (ADT updates, results corrections), look before resending an old message: if newer messages for the same patient were sent since, the receiver may apply the old one over newer data. The message's page shows its ordering key; the message browser shows what came after it.

A replay is the same message; an edited copy is a new one (level 3)

  • What: a resent message has the same meta.id and the same MSH-10, and the transform runs again with today's code and lookups (see Only the original is stored). A receiver that deduplicates on meta.id or the control id will drop a replay as a repeat. An edited copy (POST /messages/{id}/edit) is a new message: a new id (so a new meta.id), its own ordering key and control id read from what you typed, never deduplicated, at the back of its lane. The original stays exactly as received; its quarantined delivery to the copy's destinations becomes filtered ("replaced by edited copy N"), and the copy shows which message it corrects. A routed original's copy keeps its origin.
  • What to do: to replay to a receiver that deduplicates, ask them to accept it (or change MSH-10 in an edited copy). Replaying to a route (interface: x destination) queues the message in x again, where x's dedupe applies.

A message still pending can't be replaced yet (level 3)

  • What: an edit is refused for a destination where the original is still pending: its worker may be sending or retrying it at that moment, and both would go. A message that fails with a temporary error stays pending and retries forever by default, holding up its key's lane.
  • What to do: for a destination whose receiver rejects bad data with a temporary error (5xx), set retry.max_attempts so such a message ends in quarantine, where it can be corrected. Pausing the destination doesn't make it editable (a send under way finishes, and the message stays pending). A "skip this message" action is planned.

Nuances: routing between interfaces

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

A route is exactly once inside the engine, not beyond it (level 3)

  • What: destination: { interface: lab } stores the message in lab (which has source: internal) in the same database transaction that marks it sent here. A crash before that commit routes it again later; after it, never. Each routed message knows where it came from: meta.origin in code, origin in the API, origin_id in the table. What lab then sends to an outside system is at-least-once, as always.
  • Why: both sides are rows in the same database, so one commit covers both. There's no network hop and no ACK to lose.
  • What to do: nothing for the hop. Use meta.id (or meta.origin) as the idempotency key towards outside receivers, as usual.

The receiving interface's keys, dedupe and destinations apply (level 2)

  • What: a routed message is a new message of the receiving interface: its ordering.key, dedupe and destinations are applied as it arrives, read from what the sending function produced. The sending destination's output format must be the receiving interface's format (ductus validate and startup check).
  • What to do: if the receiving interface orders by MRN, the routed message must still carry the MRN where its ordering.key looks.

Order holds across a route when the sender keeps it (level 4)

  • What: messages leave a sending lane in order and are stored in the target in that order. Per-key order holds end to end when the sender orders by the same key, or has no key at all. If several interfaces route to one target, their messages interleave (each sender's own order is kept).
  • What to do: give the sending interface the same ordering.key as the target when order matters.

A routed message waits up to 20 ms before the next interface sees it (level 4)

  • What: routes are stored in batches (up to 50 messages, or after 20 ms, or when the worker runs out of work), like sent statuses, so each commit carries many messages.
  • What to do: nothing, unless a chain of many hops must answer in single milliseconds.

Routes are fixed in interface.yaml, and loops are refused (level 2)

  • What: where a destination routes to is configuration, not code. A route that would bring a message back to an interface it passed through (a → b → a) fails ductus validate and stops startup. An interface with source: internal that nothing routes to only gets a warning.
  • What to do: to route by content, give the sender a destination per target and return null (or kill()) in the functions of the ones a message shouldn't go to. Rule-based routing without code comes in v1.1.

Routes keep moving while draining (level 4)

  • What: draining stops new messages from outside. Routed messages were already received, so they still flow to their targets, and the drain waits for the targets too.

Nuances: writing transforms

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

Missing fields read as "" and never throw (level 1)

  • What: msg.PID[30][1] on a message without it is "". This avoids null checks everywhere, but a typo in a position also quietly gives "".
  • What to do: test with real sample messages (ductus test) and assert on the fields you map.

return null filters; throwing quarantines (level 1)

  • What: null → filtered (kept for audit, never delivered); kill("reason") → filtered with a reason; a thrown error or quarantine("reason") → quarantined for a person to look at.

Imports used only for their side effects are removed (level 2)

  • What: TypeScript drops import { x } from "./m.ts" if x is never used, so code that runs when m.ts loads won't run.
  • What to do: write import "./m.ts"; when you need a module only for what it does on load.

Copy what you change, not the whole thing through JSON (level 2)

  • What: JSON.parse(JSON.stringify(x)) builds the whole object twice, as text and again as objects, for every message. It also changes what it copies: dates become strings, undefined and functions disappear, a Map becomes {}.
  • What to do: you rarely need a copy: each destination's function already gets the message as received (see Each destination runs its own function on the original message), so change it in place. For an object from db() or fetch(), copy only what you change: { ...row, name: row.name.trim() }.

One query for many values, not one query per value (level 3)

  • What: each db() call is a round trip to the database and a step from your code into the engine and back. A lookup inside a loop (one query per OBX, per row of a file) adds one round trip per item, and the transform waits for each in turn.
  • What to do: collect the values first and ask once. An array parameter arrives in Postgres as jsonb: db("lab").query("SELECT code, name FROM tests WHERE code IN (SELECT jsonb_array_elements_text($1))", [codes]), then look the answers up in a Map. Lookups that don't depend on each other can also run together (see In respond mode, calls run one after another unless you await them together).

Short-lived objects are cheap; what you keep is what costs (level 4)

  • What: the JavaScript engine (V8) frees memory by itself, in two ways. Objects made while handling one message and dropped at the end are collected often and quickly. Objects still referenced later (a module-level array or Map that keeps growing, results kept "for next time") move to a second area that is collected less often, with longer pauses that delay every interface on that thread, and they count toward isolates.heap_mb until the engine restarts.
  • Why: this is how V8 works, as Java's and .NET's collectors do. Rust code in the engine has no collector; only your TypeScript does.
  • What to do: make what you need inside the transform and let it go. Don't avoid creating objects to "save memory": per-message garbage is the cheap kind. Keep anything that outlives a message bounded (see Module-level caches live until the engine restarts).

Loops over thousands of items: give objects one shape (level 4)

  • What: V8 makes property access fast by remembering how objects of the same shape (the same properties, added in the same order) are laid out. Objects built in different ways, or with properties added later or deleted, take a slower path at the places that read them.
  • When it matters: only in code that touches many objects per message: a file of 10,000 rows, a result with hundreds of OBX. A message's handful of objects never notices.
  • What to do: build such objects in one place with every property, in the same order ({ mrn, name, dob } with "" for missing ones), and set a property to "" or undefined rather than delete.

Fixtures can be read only inside a test (level 1)

  • What: fixture(), hl7() and input() work inside test(...) (or a function a test calls), not at the top of a test file: ductus test hands the interface's fixtures over after the test files have loaded. Read at the top, they fail with "fixture "x" read when the test file loads: fixtures can be read only inside a test".
  • What to do: read fixtures in each test, or write a small function (const row = (n) => parseCsv(fixture("census.csv"))[n]) and call it from the tests.

With split: records, input() gives a fixture's first record (level 2)

  • What: on an interface whose file source has split: records, the engine gives transform one record per message, and input("file.csv") in a test does the same: it parses the fixture as the interface's format and returns its first record, not the list. A test that indexes input(...)[1] gets undefined.
  • What to do: keep one record per fixture file, or parse a file of several yourself and pick one: parseCsv(fixture("census_20261031.csv"))[1] (parseFixed for fixed-width).

A quoted number in an environment file is text (level 1)

  • What: a setting that's only a reference keeps the variable's type, so port: ${LAB_PORT} with LAB_PORT: "6670" in environments/dev.yaml is the text "6670", and the interface is refused: "invalid type: string "6670", expected u16". LAB_PORT: 6670 is the number.
  • What to do: write numbers in environment files without quotes. Quote only what YAML would otherwise read wrongly (a value starting with *, &, { or [; yes/no meant as text; a time like 07:00).

Nuances: shared pool

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

How the shared pool works: The shared pool.

Interfaces share a global scope (level 2)

  • What: in the shared pool many interfaces run in one JavaScript engine. Writing to globalThis or changing built-in prototypes affects them all. ductus validate warns (DX1, DX2).

A shared library file is one copy per thread, not one per project (level 4)

  • What: lib/x.ts imported by several interfaces is loaded once per isolate. Interfaces on the same pool thread share its top-level state (DX3); interfaces on different threads each see their own copy. A dedicated interface with workers: 4 has four copies of its own module state too.
  • What to do: never use module-level variables for data that must be consistent across the engine (counters, "last seen" values). Use the database.

Module-level caches live until the engine restarts (level 2)

  • What: a module-level Map keeps its contents across messages, and loses them on restart. It also counts against the isolate's heap limit.
  • What to do: keep caches bounded, and make sure the code works with an empty cache.

Nuances: runaway scripts

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

timeout counts time without pausing, not total time (level 2)

  • What: each run of your code may last timeout (default 5 s) without giving up the thread. Waiting on db() or fetch() doesn't count: a transform waiting 20 s on a slow database is fine; a loop running 6 s is not.
  • Why: waiting doesn't hold up anyone; a loop holds up every interface on the thread.

A runaway message is quarantined; three in a row stop the interface (level 2)

  • What: code past its timeout is stopped and its message quarantined ("transform stopped: ..."), and the interface carries on: it might have been one bad message. Three timeouts in a row, or one heap overflow, stop the interface: new messages are still received and ACKed, and wait as pending. A stopped interface needs an engine restart once the code is fixed (a restart button comes with the admin API).
  • What to do: guard loops that depend on message content (segment counts, repetition indexes) so they always end.

Neighbours wait while runaway code runs (level 4)

  • What: until the engine stops it, runaway code holds its thread for up to its timeout. Every other interface on that thread waits that long.
  • What to do: keep timeout close to what the code really needs (timeout: 500ms is plenty for most HL7 transforms). Put interfaces with heavy or untrusted code in isolation: dedicated.

The heap limit is shared by everything in an isolate (level 4)

  • What: isolates.heap_mb (in ductus.yaml, default 512) applies to the whole isolate. In the shared pool, a heap overflow is blamed on whichever interface was running when memory ran out: usually the one allocating, but not necessarily the one that has been filling the heap slowly (e.g. an unbounded cache).
  • What to do: bound caches; give memory-hungry interfaces their own isolate.

A runaway after await interrupts every async transform on its thread (level 5)

  • What: code that runs away after an await is stopped inside the JavaScript event loop. V8 then drops every queued continuation on that isolate, not just the runaway's. The engine fails every async transform in flight on that thread: the culprit is quarantined, the rest are retried. A retried transform may already have made some of its database or API calls, and an abandoned original may still finish a call that was in flight.
  • What to do: this is one more reason to make writes idempotent (see above).

A runaway in a shared library after await can't be blamed on anyone (level 5)

  • What: to tell whose code ran away, the engine looks for an interface's files on the JavaScript call stack. Code in a shared library file, resumed after an await, has none, so nobody can be quarantined or stopped: every transform in flight on the thread is retried, and the problem can come back.
  • What to do: keep loops over message content in the interface's own files, or call the library from the interface without awaiting in between.

Nuances: databases and APIs

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

SQL Server: one result set, one batch per call (level 3)

  • What: only the first result set is returned. Each query/execute is its own batch, so a transaction must begin and end within one call. Text parameters are sent as nvarchar: comparing them with a varchar column can skip its index (cast the parameter).

fetch() and api() retry 5xx, 408 and 429 by default (level 3)

  • What: those statuses become temporary errors (the message waits and retries). Pass retry: false to handle them yourself. Bodies are text only, up to 16 MB.

api() headers: the API's, then yours (level 2)

  • What: an API in resources.yaml brings its URL, headers and timeout. A header your code (or an HTTP destination) sets with the same name replaces the API's. Paths go under the API's URL (/patients/1 under https://h/api/v2 is https://h/api/v2/patients/1); a full URL is refused.
  • Why: the token lives in one place, per environment, and never passes through interface code; overriding is for the rare call that needs something else.
  • What to do: keep credentials in the API's headers (with ${secret:...}), not in code. Tests answer with mock.api("name", ...); mock.fetch doesn't answer api() calls.

Write resource names out: db("patient_db"), not db(name) (level 2)

  • What: ductus validate checks that every database and API named in code exists in resources.yaml, and ductus impact uses the same names to know which interfaces use what. A name computed at run time can't be seen before the code runs: validate warns (DX4), and impact doesn't know that code uses it. The engine itself doesn't check names at startup: a wrong name quarantines messages at run time.
  • What to do: write the name in the call. If a shared helper takes a name, have callers pass a literal through a small wrapper per resource, or accept the warning knowingly. Run ductus validate before deploying (in CI).

Dedicated connection pools only for dedicated interfaces (level 4)

  • What: dedicated_pools needs isolation: dedicated; shared-pool interfaces use the shared pools from resources.yaml.

Nuances: impact and about:

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

ductus impact only knows what's named (level 2)

  • What: it finds interfaces through about: (applications, vendors, sender hosts), destinations' application/vendor, the resources.yaml entries they use, and the databases and APIs their code names. A side with no system named is invisible to --vendor and --application. Host names are compared as written unless you pass --resolve (DNS lookups, from where you run it).
  • What to do: fill in about: when creating an interface (ductus new writes it); validate warns about gaps, and require_about: true in ductus.yaml makes them errors. Name the vendor on shared resources once in resources.yaml. List senders' subnets in about.from.hosts: the engine can't know where inbound connections come from.

A destination's owner is its interface's unless it says otherwise (level 1)

  • What: about.owner owns the interface and every destination; owner: on a destination overrides it for that destination only (e.g. its code is another team's). ductus owners lists destinations separately only when their owner differs. ductus owners reassign --from A --to B changes only owner: values that are written down: a destination that inherits moves with its interface.
  • What to do: to hand one destination to someone else, add owner: to it by hand; to split a person's interfaces, run reassign once per group with --interface. It edits interface.yaml in place (comments and layout kept), so commit and deploy the change like any other.

Moving ownership in the web UI is a preview, not a change (level 1)

  • What: the Owners screen (and POST /owners/reassign/preview) shows what would change and gives a patch and the ductus owners reassign command; it writes nothing. The engine never edits its own project: interface.yaml lives in your repository, and a change made on the server would be undone by the next deploy. Owners shown stay as they are until the change is deployed.
  • What to do: apply the patch in the project's repository (git apply owners.patch) or run the command there, review, commit and deploy. The preview reads the files on the engine's server, so preview against the engine running the version you'll change. The patch keeps each file's own line endings, which is what git apply needs: from an engine on Windows (CRLF files, Git for Windows's core.autocrlf) it applies to a Windows checkout; a patch made on Linux (LF) applies to a Linux checkout or one with autocrlf off.

An owner is a person when the text names exactly one account (level 1)

  • What: an owner: is free text. The engine matches it to an account ignoring case and surrounding spaces: a username first, then an email, then a display name; the first of those that matches anyone decides. At that level one enabled account wins over any number of disabled ones (a person given a new account keeps their email; the old one, refused, doesn't make it ambiguous); two enabled ones (two "Anna Smith"s) name nobody: no link, no alert mail to either (a guess would send one person's alerts to the other); only disabled ones name that account, flagged: its person probably left, and alerts go nowhere for it (not even to the text, when it is an email: their old mailbox; since 0.6.7.3). When no account has the text now, the names accounts had before are tried the same way (since 0.6.7 every change of a username, display name or email keeps the old value, whoever made it: an admin, or the directory at a sign-in or refresh): the one account that had it is the owner, shown as renamed ("this was their display name until 2026-03-02"), and alerts go to its email now. When an account has the text now and another had it before, the one that has it now is the owner (the current names always decide first), shown as reused: the text may have been written for the earlier person. Both are warnings (⚠): the owner still reaches a person. An owner that is an account shows as that person (name, email) on the Owners and interface pages, makes the interface theirs under Interfaces → Mine, and gets to: owner alerts. Otherwise it is a team in owners: (ductus.yaml; a team wins over an account's former name), an email address, or nobody. With a directory, an email address of someone in a Ductus group becomes their account (see Directory owners get their account when they're in a Ductus group). Renames before 0.6.7 aren't known (no old values were kept then): such an owner reads as nobody until fixed once. ductus owners with DUCTUS_DATABASE_URL marks each owner, ductus validate does too when it has the database, the Owners page says the same, and the engine logs at start the owners it can't reach and those it reaches by a former name.
  • What to do: write owners as email addresses (unique; a display name may not be, and both get renamed). An owner written another way that names an account is warned with the email to write ("write their email: olaf@…"), and renamed and reused owners with the exact fix: ductus owners --check prints ductus owners reassign --from '<text>' --to <email> under each and still passes (exit 0); the Owners page's Update opens that move, previewed. For a reused one, decide who is meant first: the fix offered is the one alerts reach now, the warning names the earlier one's email too. Name teams and mailing lists in owners:. Run ductus owners --check in the pipeline that deploys the project (it fails on an owner that reaches nobody, a disabled account, or one that names several). When someone leaves, move what they own with ductus owners reassign before disabling them.

Moving an owner moves their resources.yaml entries too (level 2)

  • What: ductus owners reassign --from A --to B (and the Owners page's preview) changes owner: A in every interface.yaml and in resources.yaml (databases, APIs, SFTP servers, directories), so certificate alerts follow the person. Naming --interface x or --resource y moves only what is named (naming interfaces leaves resources.yaml as it is). The preview says when the new owner isn't an account.
  • What to do: to split someone's work, run it per group with --interface/--resource (or untick on the Owners page). Commit the resources.yaml change with the interface files.

Operators start on their own interfaces (level 1)

  • What: Interfaces opens on "Mine" for an operator who owns at least one interface (or a destination of one); everyone else, and an operator who owns nothing, starts on All. "Mine" goes by the owner matching above, for the person signed in (a token acts for its owner; a service token has no "Mine"). The choice is in the address (?mine=false), so a bookmark keeps it.
  • What to do: nothing; if an operator sees an empty "Mine", their owner texts don't name their account (see above).

Your own documentation fields are checked by ductus validate, not by the engine (level 2)

  • What: docs-schema.yaml declares the fields your company wants on every interface, destination or resources.yaml entry (text, enum, date, url, person, list; required or not; where each applies); the values go in a flat custom: block beside the other keys (not under about:). A missing required value, a value outside an enum, a date that isn't YYYY-MM-DD, an undeclared name or a field used where it doesn't apply is an error in ductus validate (and ductus docs --missing exits 1) — but the engine starts anyway and shows what is there. Documentation must never be the reason a hospital's interfaces are down. In the web UI a missing required value reads "Missing"; a broken docs-schema.yaml is logged at startup and the fields are ignored until it is fixed. Enums match exactly, case included (yes is not Yes): that is the point, since it stops "Yes/yes/Y" from drifting into your reports.
  • What to do: put ductus validate in the pipeline that deploys the project, so a gap is caught before the engine sees it. Declare a field you can't fill in yet as required: false first, then tighten it. Quote "yes" and "no" values in YAML so no tool reads them as booleans (the engine takes them as text).

The documentation snapshot is as old as the last engine start (level 3)

  • What: at every start the engine writes each interface's and resource's documentation as JSON, in your field names, to the docs_snapshot table, so reports and BI tools can ask docs->'custom'->>'baa_signed' = 'no' in SQL. The YAML in Git stays the source of truth: the table is made to match the project at start (entries you removed disappear) and is not updated while the engine runs. There is no history yet (it comes with versioned releases). The web UI and API read the files as loaded at start too: editing custom: on a running engine shows after a restart.
  • What to do: deploy documentation changes like any other (restart or roll the engine); don't edit docs_snapshot by hand, it is rewritten. Several engines on one database each write their own project's rows, the last to start wins: give each project its own database, as you already do.

Nuances: checking connections (ductus doctor)

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

Doctor checks from where it runs (level 2)

  • What: every result (DNS, routes, firewalls, the source address in a firewall request) is about the machine running ductus doctor. A laptop on the VPN and the engine's server can get opposite answers.
  • What to do: run it on the engine's server, as the account the engine runs as (folder rights and trusted certificates are per account), with the environment it uses (--env prod).

A pass means "can connect and log in", not "will accept your messages" (level 2)

  • What: doctor never sends anything to a destination: MLLP and HTTP destinations are only connected to (and TLS checked). For an API it sends one GET to the API's base URL with its headers and reports the status: a 404 there is normal, a 401/403 means the token was refused. Databases get a login and a version query; SFTP a host key check, login and SFTP start. Code that writes to a table it has no rights to still fails at run time.
  • What to do: use doctor for "can we reach it"; use the interface's tests and a test message for "does it accept what we send". The engine runs the certificate part of these checks by itself every few hours (see Certificates).

One working address is enough to pass, with a warning (level 3)

  • What: a name can resolve to several addresses (IPv4 and IPv6, or a cluster). Doctor tries each. If some fail and one works, the check warns: the engine (like any client) may try a failing address first and be slow, or fail now and then.
  • What to do: ask for the DNS entry to list only working addresses, or for the others to be opened.

The commit time is the store's disk, and the ceiling per strict sender (level 4)

  • What: doctor times 20 durable commits against the engine's database. With durability: strict, each ACK waits for one, so one sender connection can't go faster than about 1 / commit time (2.4 ms ≈ 410 msg/s); more connections share commits. Doctor warns above 10 ms.
  • What to do: if that's near your peak per connection, use a faster disk (SSD/NVMe, write cache with battery), more sender connections, or durability: relaxed where losing the last fraction of a second on a database crash is acceptable.

Nuances: files, SFTP and schedules

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

A file is picked up one poll after it stops changing (level 3)

  • What: a file is ready when its size and modification time haven't changed since the previous poll (or its marker file exists). Expect a delay of up to two poll intervals. Dot-files are skipped.

One file at a time, oldest first; a failure stops the rest (level 3)

  • What: files keep their order: if one fails, later files wait. Unsplit files over 256 MB are left in place with an error.

Cron uses the server's local time; missed runs collapse to one (level 3)

  • What: after downtime, a schedule runs once, not once per missed time. A run is skipped if the previous run's message is still pending.

On Linux the service can write only /var/lib/ductus until you add a folder (level 2)

  • What: the package's systemd unit makes the whole file system read-only to the engine except /var/lib/ductus (ProtectSystem=strict, ReadWritePaths=/var/lib/ductus), and hides home folders (ProtectHome=yes). A file destination writing to /data/outbound, or a file source moving what it read to an archive outside that folder, fails with "Read-only file system" (the message waits and is tried again, as for any temporary failure). The same interface works under ductus run in a terminal, which has no sandbox.
  • Why: one interface with a wrong path shouldn't be able to write anywhere on the server.
  • What to do: add the folders interfaces use: sudo systemctl edit ductus, then [Service] and ReadWritePaths=/data/outbound /data/inbound, then sudo systemctl restart ductus (a unit change needs a restart, not a reload). Give the ductus user the folders' permissions too (chown or a group). Windows has the same idea with its own means: see "The Windows service can only touch what you give it".

Nuances: operations: drain mode and the REST API

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

A start logs one line, not one per listener (level 2)

  • What: an engine's start ends with one line: started interfaces=1001 mllp=1001 http=0 files=0 schedules=0 internal=0 shared=1001 pool_threads=8 dedicated_threads=0 took=712ms. Since v0.6 the per-interface lines ("listening (MLLP)", "watching for files", "schedule started", and "MLLP listener closed" at a stop or drain) are debug lines: a thousand-interface engine wrote a thousand of each at every start, stop and drain. Measured with 1,001 interfaces (WSL): ready in 0.69–0.83 s warm (1.3 s with a cold file cache), down from ~5 s in v0.5.
  • What to do: ductus ports lists what a project listens on. To see each listener in the log, RUST_LOG=info,ductus_runtime=debug; that also logs how long each step of the start took ("start: ...").

Draining closes MLLP listeners; it doesn't answer AE (level 4)

  • What: ductus drain (or POST /api/v1/drain) stops taking new messages. MLLP listeners close: open connections are closed after the message being handled is ACKed, new ones are refused. A message that was half received, or that arrived just as the drain started, is dropped unanswered, never ACKed, so the sender sends it again later. Queued HTTP sources answer 503 with Retry-After: 30. File pickup pauses (a file being read stops between batches and resumes at its saved offset after ductus resume or a restart). A schedule that comes due while draining runs once on resume, however many times it came due.
  • Why: to a sender, a closed port means "receiver down: keep the message and retry", which every sender handles. An AE can be configured to mean "skip it after N tries" on some engines, which would lose messages.
  • What to do: expect senders to show the connection as down during a drain. Use it for upgrades and server moves: ductus drain --wait --stop, upgrade, start.

Respond-mode HTTP keeps answering while draining (level 4)

  • What: respond-mode interfaces (APIs that answer from a transform) keep answering until the engine stops. They store nothing, so there's nothing to drain.
  • What to do: take the server out of your load balancer when you drain it. GET /api/v1/health reports drain: draining, which a load balancer can check.

A drain finishes only when every destination has caught up (level 4)

  • What: "drained" means nothing is pending anywhere. A destination that's down, or an interface stopped because its code ran away, keeps the drain waiting (ductus drain --wait and GET /api/v1/drain show which interfaces). Quarantined messages don't count: they wait for a person anyway.
  • What to do: fix the destination, or resume and plan the maintenance for later. Stopping anyway is safe: what's pending stays stored and goes out after the restart.

Drain mode isn't remembered across a restart (level 4)

  • What: an engine that restarts takes messages again, even if it was draining.
  • Why: the usual sequence is drain, stop, upgrade, start, and the start should take messages.
  • What to do: to keep senders out across a restart, stop the senders, or drain again right after starting.

The API listens on this machine only, until you open it (level 4)

  • What: the REST API listens on 127.0.0.1:7700 by default (api.listen in ductus.yaml, --api-listen, or off). Every call except /health and /openapi.json needs a token. Tokens are made on the server with ductus token create and are stored only as hashes: a lost token can't be shown again; revoke it and make a new one.
  • What to do: before opening it to the network, give it tls: (see The API on the network needs tls), or put a TLS reverse proxy in front and keep it on 127.0.0.1.

Nuances: the web UI

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

The UI works over https, or at localhost on the engine's own server (level 4)

  • What: the engine serves the web UI on the API's listener (http://127.0.0.1:7700/ by default). Signing in trades a token for a session cookie marked Secure, which browsers keep only over https, or over plain http to localhost/127.0.0.1. Over plain http to another machine the browser drops it, and the sign-in page says so.
  • Why: the cookie is as good as the token while it lasts; sent in clear text over a hospital network, anyone on the path could take it.
  • What to do: to reach the UI from other machines, give the API tls: in ductus.yaml (api: { listen: 0.0.0.0:7700, tls: { certificate: ductus-api } }) and open https://; or put a TLS reverse proxy in front (keep api.listen on 127.0.0.1 behind it), or use an SSH tunnel (ssh -L 7700:127.0.0.1:7700 server, then http://localhost:7700).

A console session acts as whoever signed in (level 2)

  • What: signing in to the console (a username and password, a directory account, or a token where auth.token_sign_in allows it) starts a session that acts with that person's role and permissions, or the token's, and the audit log names them. It ends after 12 hours unused or 7 days after sign-in (auth.session), on sign-out, and at once when the account is disabled (or, for a token sign-in, the token revoked). Changes made through the session cookie (pausing, draining) must carry the X-Ductus: 1 header, which the console sends and other websites can't, so a page elsewhere can't act through someone's session. Scripts use a token and don't need the header.
  • Why: a session is a person at a browser; it shouldn't outlive their access, and another site open in the same browser mustn't be able to use it.
  • What to do: give each person an account (or a directory sign-in), not a shared token, so the audit log says who looked at which message or paused what. Shorten auth.session.idle where screens are shared or left unlocked.

Your own colors can be unreadable on purpose (level 1)

  • What: Appearance (user menu → Customize colors…) takes five colors (header, accent, page background, panel, text) and works the rest out from them. It checks every pair the screens draw against WCAG AA (4.5:1 for text, 3:1 for the focus ring) and marks the ones below it "Hard to read". It warns and does not stop you: Use these colors works either way, and the pages are then as hard to read as the check said. The built-in High contrast theme is the one that promises AAA (7:1).
  • Why: it is your screen and your eyes; a rule that refuses a color nobody else sees would be in the way. But people spend all day in this, so the check is always shown.
  • What to do: press Fix it on a row (or Fix all): it moves only the text or the accent, in lightness and just far enough, and shows the old and new values with Undo. It leaves the header, page background and panel alone, unless no text color could be read on them at all (a black page with a white panel): then it pulls the panel toward the page background and says so. The colors are kept in this browser, not on the server, until logins arrive (v0.5 step 8): another browser or machine starts from the system theme.

Nuances: monitoring

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

Counts and timings start at zero when the engine starts (level 3)

  • What: GET /api/v1/metrics (Prometheus) and since_start in GET /api/v1/interfaces count messages received, sent, filtered, quarantined and retried, and time transforms, sends, database/API calls and the whole journey from receipt to sent. They're kept in memory and start again at zero with the engine. Queue sizes (pending, quarantined) come from the database and are always current.
  • Why: counting in memory costs nothing per message; writing every count to the database would cost a write per message. Prometheus expects counters to reset and its rate() handles it.
  • What to do: graph rates (rate(ductus_deliveries_total[5m])), not raw totals. For "how many today", use the message browser or the database, which keep everything for the retention window.

Timings are estimates to a bucket, averages are exact (level 4)

  • What: p50/p95 (half / 95% of the messages were faster than this) are estimated from histogram buckets (1, 2.5 and 5 per decade from 0.5 ms to 10 s, then 1 min, 5 min, 1 h), so they're good to about a bucket's width: a p50 of "3.5 ms" means somewhere between 2.5 and 5 ms. mean_ms is exact (the total time divided by the count).
  • What to do: use p95 to spot slow outliers and changes over time, the mean to compare against a measurement.

Prometheus series grow with destinations (level 4)

  • What: each destination has 3 histograms (receipt to sent, send, transform) of 20 series each (18 buckets, a sum and a count), plus its counts and queue sizes: about 65 series per destination. 1,000 destinations ≈ 65,000 series, comfortable for Prometheus but a cost on hosted services that bill per series.
  • What to do: on a hosted service, drop what you don't graph with metric_relabel_configs (for example keep ductus_delivery_latency_seconds and drop ductus_transform_seconds).

In respond mode, calls run one after another unless you await them together (level 2)

  • What: each await waits for its call before the next starts. Measured in the v0.4 demo (a patient lookup API: 2 database queries and 1 EMPI call): 2.1–3.0 ms in the engine on average, of which ~1.5–2.1 ms is the three calls one after another (~0.5–0.7 ms each on a local database; a remote one takes longer) and ~0.6 ms the engine itself.
  • What to do: when calls don't depend on each other, start them together: const [patient, empi] = await Promise.all([db("patient_db").one(...), fetch(...)]). The response then takes about as long as the slowest call instead of the sum. GET /api/v1/engine (resources) shows each database's and API's call times, to see which one to work on.

Nothing is expected until you say so (level 2)

  • What: monitor: in interface.yaml says what operations expects: active_within: 30m (with optional between: "06:00-22:00"), active_by: "Mon-Fri 09:00" (with optional from:), behind: 500, behind: 10m or behind: { messages: 500, age: 10m } (a destination can set its own behind). Without it, an interface is ok however quiet it is, and a destination is behind only past a limit you set. Times are the server's local time.
  • Why: how quiet is too quiet, and how far behind is too far, depends on the feed: a lab feed that sleeps at night, a nightly file, a 1M/day ADT feed. A default would alert on the wrong things.
  • What to do: set behind with both a count and an age on every feed someone would call about (count alone misses an overnight stall when little arrives; age alone misses a daytime pile-up that still moves). Set active_within on steady feeds and active_by on scheduled ones. The engine alerts on these states itself once alerts: is in ductus.yaml (see Alerts below); Prometheus can too (ductus_destination_state > 0, ductus_interface_state > 0).

What each state means, and when it shows (level 2)

  • What: a destination is ok, behind (past its behind limit), error (has quarantined messages), stalled (messages waiting over a minute and nothing finished for a minute: retrying, a blocked lane, or a stopped interface; ductus hotspots shows which message) or paused, from best to worst (it shows the worst that applies; summary lists them all); says it in words ("behind 1,240 (oldest 12 min), catching up ~6 min"). An interface is ok, silent, missed (its active_by deadline) or stopped.
  • Why: "it's backing up but still sending" and "it's not sending at all" need different calls: the first is the vendor's capacity, the second is down.
  • What to do: treat stalled as the urgent one. error stays until the quarantined messages are dealt with, so clear them (or they hide the next real error). A backlog under a minute old isn't diagnosed at all: a flowing queue always has a few messages in it.

Burst and slow receiver need a few weeks of history (level 3)

  • What: "normal" is learned per hour of the week (Monday 07:00 compares with past Mondays at 07:00), from each hour the engine watched for at least 15 minutes; recent weeks weigh more. Until an hour has been watched once, a backlog shows its trend (catching up, or not) but no cause; for send times, the other hours' average stands in. The last few minutes are compared with it: a burst is over twice the normal arrivals, a slow receiver over twice the normal send time (and at least 5 ms more). The rates and the catch-up time cover the last 5 minutes and start over when the engine does; "normal" is kept in the database.
  • What to do: expect causes after the first week, and good ones after three or four. A feed whose traffic changed for good (a new hospital on it) becomes the new normal within a few weeks.

Finding the message or patient that holds a queue up (level 3)

  • What: ductus hotspots [interface] (GET /api/v1/hotspots) shows, for every destination with messages waiting, the lanes they wait in, the first message of each (id, key, age, attempts, last error) and how the backlog splits over the workers, and says what it sees:
    • worker N blocked by message M: that message keeps failing and everything on its worker waits behind it. "Other messages were sent since it arrived" means the receiver is up and takes other messages: the trouble is this message (its content, or the receiver's data for that patient).
    • every worker is retrying its first message: the receiver, or something the code calls, looks down. No single message is to blame.
    • lane L has 73% of the backlog (key K at its head) / worker N has 80% of the backlog: one patient (or a few) arrives faster than one worker can send. Ordering keeps a key on one worker, so more workers don't help that key.
    • a lane no worker reads: ordering.partitions was lowered while messages waited (see Change ordering.partitions only with an empty queue).
  • Why: a backlog in an ordered queue sits behind one message per worker. The count alone says that it's stuck, not which patient to look at.
  • What to do: for a blocked message, check its last error and the message (GET /api/v1/messages/{id}); fix the data or the receiver, or set retry.max_attempts so it's quarantined and the others go on (resending a single message comes with step 7). For a receiver that's down, call the vendor; ductus pause stops the retries meanwhile. A hot key under a few hundred messages clears by itself; one that stays hot every day is a feed to discuss with the sender (a batch load sent as one patient's updates?). Lanes with fewer than 100 waiting are never called hot: a flowing queue always has some.

A receiver that's back can stay stalled for up to a minute (level 2)

  • What: while a receiver is down, the wait between tries of the first waiting message doubles (1 s, 2 s, 4 s…) up to retry.max_backoff_ms, 60 s by default. When the receiver comes back, nothing is sent until that message's next try, so the destination can show stalled (and its alert stay open) for up to a minute after the receiver is up; then the backlog drains in order. Seen in a probe: the receiver back 8 s, still stalled, attempt 7 waiting its 60 s.
  • Why: trying a receiver that's down every second for an hour helps nobody and fills its logs; doubling finds it again within one wait.
  • What to do: nothing, usually. Where a receiver's comebacks must be noticed sooner, lower max_backoff_ms on that destination (retry: { max_backoff_ms: 10000 }).

There's no search by message content yet (level 1)

  • What: the console and the API list an interface's messages by status and time, a page at a time, and open one by its id. Finding "every message for MRN 123456 last Tuesday" isn't there: a search by patient and date is planned with long-term storage (v1.3).
  • What to do: each message keeps its ordering key's value (the MRN, for ordering.key: PID-3.1) and MSH-10 in their own columns, so a read-only database login can find ids with WHERE interface = … AND received_at in a range AND ordering_key = … (the time range keeps Postgres to that day's partitions). Such a query bypasses the payload-read audit: give that login to the people your privacy officer would let read every message, and select ids, not payloads (the message store).

Nuances: alerts

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

Alerts tell you once, then remind; they don't repeat every check (level 2)

  • What: with alerts: in ductus.yaml, every interface and destination is judged every 15 s, whether or not anyone has the UI open. A state a rule names (error, stalled, behind, stopped, silent, missed; certificate has steps of its own, see Certificates) fires once, to the rule's channels. It escalates (people are told again) only when the severity rises, warning to critical. Getting better without being fine (stalled to behind) changes the alert quietly. While it stays open and nobody acknowledges it, a reminder goes every repeat (4 h by default; 0 = never). Once it has been fine for settle (2 min) it recovers, and the people who were told hear that too. Going bad again within settle reopens the same alert instead of firing a new one (a flapping receiver is one alert). More quarantined messages on a destination already in error are not a new alert.
  • Why: an alert on every check is noise people learn to ignore. One per problem, a reminder while nobody owns it, and a word when it's over is what an on-call person can act on.
  • What to do: acknowledge what you're working on (the Alerts page, or POST /api/v1/alerts/{id}/ack): reminders stop until it gets worse or recovers. Keep repeat unless the channel pages someone. Clear quarantines (resend, correct or dismiss): error stays open until the destination has none.

A pause never alerts, and holds an alert open (level 2)

  • What: a paused destination is paused on purpose, so it never fires. If it was already alerting, the alert stays open while paused: no reminders, and no recovery either (a pause isn't the receiver being fine). After the resume it's judged as usual.
  • What to do: pause a destination you know is down (it stops the retries); the alert recovers once it delivers again.

Silencing is not pausing, and acknowledging is not fixing (level 1)

  • What: silence (up to 30 days, with a reason) sends nothing about an alert, not even its recovery, but it still shows, and you can silence a destination before planned work so nothing fires. Pause stops a destination sending. Acknowledge only says someone is on it. None of them touch messages: they keep waiting.
  • What to do: for a planned outage, pause the destination and silence its alert, both with a reason. Both are in the audit log.

An engine that is down sends nothing: use the heartbeat (level 2)

  • What: alerts come from the engine. If it is stopped, crashed, or can't reach its database, nobody is told. alerts.heartbeat: { url, every: 5m } calls a URL every so often while the engine runs. A service that expects the calls (healthchecks.io, Better Stack, Uptime Kuma, your monitoring's own "push" check) raises its own alarm when they stop.
  • What to do: set heartbeat in production, at a service that doesn't run on the engine's server, and set that service's grace period to about twice every.

Notifications are sent at least once, and a failing channel only shows in the UI (level 3)

  • What: a notification is stored with its alert (the outbox) before anything is sent, then sent by whichever engine claims it (two engines on one database send it once). A send that fails is retried: 30 s, then doubling, at most an hour apart, ten tries (about six hours), then counted as failed. A channel that fails never holds up another. If an engine dies after sending but before recording it, the notification goes out again after two minutes. A failing mail server or webhook can't alert about itself: failures show on the Alerts page (Channels), in the engine log, and in ductus_alert_send_failures_total.
  • What to do: send critical rules to two channels of different kinds (email and a chat webhook). Watch ductus_alert_send_failures_total from Prometheus if you have it. Try a channel before going live with Send test or ductus alerts test <channel> --env prod (that one needs no engine).

Quiet hours hold warnings until morning; critical ones never wait (level 2)

  • What: quiet: { between: "22:00-06:00", severities: [warning] } holds notifications of those severities until the quiet hours end (server local time, like schedules). A warning that fires and recovers overnight is never sent: it's in the history. A critical one, or a warning that escalates to critical, goes at once.
  • What to do: keep critical out of severities. Remember the server's time zone when the engine runs in UTC (containers often do).

Who "owner" is (level 2)

  • What: alerts go where a rule's to: says: channels, which are usually a team's mailing list or chat (channels: in alerts:), and an interface can add its own team with alert: { to: [lab-team] }. People change; a team's list doesn't, and someone on it is always awake. owner in to: is optional: it also mails whoever owns the destination (its own owner:, else its interface's about.owner). The owner text is matched to an account as "An owner is a person when the text names exactly one account" says (by a former name too: a renamed owner is mailed at their email now). Failing that it's looked up in owners: in ductus.yaml (a team's mailing list), and failing that it's used as is if it's an email address. An owner found nowhere gets nothing; the rule's other channels still do. Without a database ductus validate can't see accounts, so it only warns about owners that are neither in owners: nor addresses; with DUCTUS_DATABASE_URL it matches them. The engine logs every owner that reaches nobody at start, and the Alerts page lists them. Owners matter on their own anyway: the Owners page, Interfaces → Mine, ductus owners and the documentation say whom to ask about what.
  • What to do: send alerts to team channels in your rules; add owner only where a person should hear directly. Put teams in owners: with their list address, write people as their email, and give accounts an email. A test interface can say alert: { mute: true }; alert: { to: [pager] } adds recipients for one interface.

A restart doesn't alert on what it hasn't watched yet (level 3)

  • What: states that need watching (stalled, behind, silent, missed) are judged only after the engine has run a minute. error and stopped are facts in the database and alert at once. Open alerts are in the database too, so a restart neither repeats them nor forgets them.
  • What to do: nothing. Expect the first alerts about a problem that was already there about a minute after a start.

Webhook URLs are secrets (level 2)

  • What: a chat webhook URL is its own password: anyone with it can post to the channel. The engine shows only its scheme and host (https://hooks.slack.com/…), in the UI, the API, logs and errors.
  • What to do: write it as ${secret:CHAT_WEBHOOK} (ductus secret set); alerts: may use ${...} like resources.yaml, filled in per environment. Formats: slack (also Mattermost and Rocket.Chat), teams (a Workflows webhook: the old Office 365 connectors are being retired), json (everything, for your own tools).

Judging alerts costs little, and runs all the time (level 4)

  • What: every 15 s the engine counts its queues (as live updates do) and compares each state with its stored alert, writing only what changed. Measured with 1,001 interfaces (2,002 alerts, WSL): 35 ms a round, about 0.1% of one core in all, and 7 MB of memory.
  • What to do: nothing at that size. Each round's time is in the log at debug level (RUST_LOG=ductus_runtime::alerts=debug, "alerts judged").

Nuances: TLS on what the engine listens on

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

The API on the network needs tls (level 1)

  • What: api: { listen: 0.0.0.0:7700 } without tls: serves the API and the web UI over plain HTTP: passwords and tokens cross the network readable, and browsers won't keep the session cookie (it is Secure), so the UI can't sign in. The engine and ductus validate warn about it.
  • What to do: name a certificate (api: { listen: 0.0.0.0:7700, tls: { certificate: ductus-api } }, with ductus-api under certificates: in resources.yaml). Browsers that have seen it over HTTPS won't try plain HTTP to it again for a year (HSTS).

A certificate the engine can't use stops the start, with the reason (level 2)

  • What: every certificates: entry is loaded at start, before the database: a missing file, a file that isn't PEM, a key that isn't the certificate's, or an expired certificate stops the engine and says which entry and why. ductus validate checks the same, and shows each certificate's names and days left.
  • Why: a typo should show at deploy, not as the first sender's refused handshake at 2 a.m.
  • What to do: run ductus validate in the environment you deploy to (certificates from secrets differ per environment).

Renewing: replace both files, no restart (level 2)

  • What: a certificate given as cert_file and key_file is read again every minute (one task for the whole engine, comparing the files' contents; nothing touches the database). A changed pair that loads is used for new connections; connections already open keep theirs until they close. A changed pair that doesn't load (half copied, a new certificate with the old key, expired) is logged once as an error, and the engine keeps presenting the pair it had: a bad renewal never takes a listener down.
  • Why: that's how certbot, win-acme, step and most PKI tooling deliver certificates: they replace files. Any ACME client works this way, with no engine feature needed.
  • What to do: write the new files, then check the log for certificate renewed within a minute (or the "Ours" group on the Connections page; its "Check now" reads the files at once). Either order works: while only one of the two has changed, the old pair stays in use (a check that lands in between logs one error, which the next check clears). Certificates from secrets (cert:/key:) and CAs change with a deploy and a restart.

Give the whole chain in cert_file (level 3)

  • What: the engine presents exactly what cert_file (or cert) holds: the certificate first, then its intermediates. Browsers often fetch a missing intermediate themselves; Java and .NET senders and most integration engines don't, and refuse the handshake.
  • What to do: put the CA's intermediate certificates after yours in the same file (the CA usually sends a "full chain" or "bundle" file). Doctor's full mode (the Connections page's "Run the checks", ductus doctor while the engine runs) connects to each TLS listener and warns when the chain stops short of a root (see Doctor reads the chain our listeners serve).

Senders on TLS 1.0 or 1.1 can't connect (level 3)

  • What: listeners take TLS 1.2 and 1.3 only. Old engines and appliances that only speak 1.0/1.1 fail the handshake; the log says "no TLS version or cipher in common".
  • What to do: upgrade the sender, or put a TLS-terminating proxy in front for it alone (and keep that hop on a trusted network).

A client certificate says which system sent, not who (level 3)

  • What: with clients: { ca: hospital-ca, subjects: [...] }, a listener takes only senders whose certificate the CA issued and whose subject matches one pattern (CN=bridges*.hospital.org; all parts of a pattern must match; DNS= matches the certificate's DNS names). It identifies the sending system, not a person: nothing about it reaches the audit log as an actor.
  • What to do: keep subjects narrow: without it, every certificate that CA ever issued (often every machine in the hospital) is let in.

A refused handshake is logged once a minute per sender (level 3)

  • What: a sender that isn't speaking TLS, has no or the wrong client certificate, doesn't trust ours, or doesn't finish within 10 s is dropped without an answer (an MLLP sender sees the connection close, no ACK). The log says why, at most once a minute per listener and sender address: a misconfigured sender retries every few seconds, and the log isn't for counting them.
  • What to do: after changing a sender, wait a minute for the next line, or watch the sender's own log. A check the running engine makes of its own listeners (the Connections page's full checks) isn't logged as a refusal; ductus doctor run on the same server is another program, so a listener that wants client certificates logs its check once, from this server's own address.

A certificate from the hospital's CA: request, then show (level 2)

  • What: ductus cert request tls/mllp --dns mllp.hospital.org [--ip 10.1.2.3] [--subject "O=General Hospital, OU=Interfaces"] writes a new private key (tls/mllp.key, readable only by its owner, never overwritten) and a certificate signing request (tls/mllp.csr) asking for a certificate a server and a client can both use. The key is RSA 2048 unless you ask for --key ecdsa: every CA takes RSA (Microsoft AD CS templates often still require it). What comes back goes in tls/mllp.pem, ours first, then the CA's intermediates. ductus cert show tls/mllp.pem (or the entry's name) says what it holds and judges it the way the engine and doctor do: names, issuer, days left (the chain's earliest), the chain's order and whether it reaches a root or a CA named in resources.yaml, and whether the key is the certificate's. It exits 1 when the engine would refuse the pair (wrong key, expired, not a certificate), and says when a file is a CSR, a key, or DER (binary) instead of PEM.
  • Why: a key generated elsewhere and mailed around is a key someone else has; a chain that's incomplete or in the wrong order passes a browser test and fails Java and .NET senders.
  • What to do: run cert request on the engine's server, send only the .csr, and run cert show on what comes back before naming it in resources.yaml. To renew: a new request under another name, then copy its .pem and .key over the two in use (see Renewing).

A CA's certificate as a server's (level 3)

  • What: openssl req -x509 makes a certificate marked as a CA's (CA:TRUE) by default, which is how many lab receivers get theirs. The engine's connections (MLLP and HTTP destinations, apis:, webhooks, the heartbeat, directories) accept a receiver presenting one only when that exact certificate is itself trusted: named in ca:, or installed as trusted on the engine's server. Its dates and the host name are still checked. That's the same on Linux and Windows, and what Windows, Java (Mirth) and .NET do. Trusting one exact certificate is at least as strict as trusting a CA. A CA:TRUE certificate that is only issued by a trusted CA is refused ("the receiver presents a CA's certificate (CA:TRUE) as its own"), on Windows too, though Windows alone would take it. A client certificate like that is refused on our listeners. Exceptions: three connections use a TLS stack of their own. Alert mail (SMTP) follows the OS: Linux refuses any CA:TRUE certificate, Windows takes both kinds. Postgres and SQL Server refuse any CA:TRUE certificate on both. Doctor's mail check says what the mail connection would. ductus cert show warns about any CA:TRUE pair.
  • What to do: for a lab, naming the receiver's own certificate in ca: works. Anywhere else, ask for a server certificate (ductus cert request for the hospital's CA, or ductus cert self-signed for a lab of your own).

What TLS costs (level 4)

  • What: measured on one engine, TLS against plain (scripts/bench-tls.sh, WSL laptop, local Postgres with ~5 ms durable commits): strict MLLP ingest is the same within noise, 234–239 vs 238–245 ACKs/s on one connection and 2,690–2,780 vs 2,720–2,930 on 16 (the commit flush bounds both; the handshake is once per connection). A sender's connect to the end of its handshake takes ~0.56 ms p50 with an ECDSA certificate, ~0.96 ms with RSA 2048, ~0.66 ms when we also check its client certificate (plain TCP: 0.08 ms); TLS 1.3 resumption saves little (~0.54 ms: it still agrees a new key). An idle TLS connection holds ~23 kB of the engine's memory against ~15 kB plain (1,000 held open: +23 MB vs +15 MB). Respond mode over HTTPS: 0.29 ms p50 / 0.39 ms p95 on a kept-alive connection (HTTP: 0.27 / 0.36); with a new connection per request 1.2 ms / 3.0 ms (HTTP: 0.51 / 0.73).
  • What to do: turn TLS on wherever the network isn't yours: for MLLP feeds it costs nothing you'll see at hospital volumes (1M ADT a day is ~12 msg/s on average). Senders that open a connection per message (some do) pay one handshake each, ~1 ms with RSA, on top of the ~4 ms a strict commit takes here: unnoticeable at a few messages a second. Many such senders at once, handshaking hundreds of times a second, weren't measured: ask a busy one to keep its connection open, as MLLP senders usually do. Callers of respond-mode APIs should keep connections alive (most HTTP clients do by default): a new HTTPS connection per call roughly quadruples the time on the wire. Memory is no reason to avoid TLS: 1,000 connections add ~23 MB.

Post-quantum key exchange: on by default, never in the way (level 3)

  • What: every TLS connection the engine makes or accepts (listeners, the API, MLLP and HTTP destinations, api() and fetch(), mail, webhooks, the directory) offers and prefers the hybrid key exchange X25519MLKEM768 (classic X25519 plus ML-KEM-768, the one browsers use). A peer that knows it gets it; one that doesn't gets plain X25519 with no extra round trip (both key shares go in the first message). It protects the session keys, so traffic recorded today can't be decrypted later by a quantum computer ("record now, decrypt later": health records stay sensitive for decades). Certificates and signatures are unchanged (no post-quantum certificates yet: not issued by CAs). Measured (scripts/bench-tls.sh): +0.15 ms per full handshake (ECDSA p50 0.69–0.73 ms vs 0.55–0.58 ms with X25519 alone), ~1.2 kB more in the first message; nothing on a connection kept open. Checked against OpenSSL 3.5 both ways: hybrid when both offer it, X25519 when one doesn't.
  • What to do: nothing to turn on. Doctor's TLS step says what each server agreed ("TLS 1.3, post-quantum key exchange (X25519MLKEM768)" or "key exchange X25519"): the second kind are partners to ask about it (OpenSSL 3.5, Java 24+, recent Windows and Go have it).

Testing TLS without the real systems (level 1)

  • What: ductus gen --tls sends over TLS, trusting this server's roots plus --ca FILE and presenting --cert FILE --key FILE, verified the way the engine's destinations verify; it says why a handshake fails ("the receiver wants a client certificate (name one in --cert)"). ductus sink --tls --cert FILE --key FILE [--clients-ca FILE --subject PATTERN] takes MLLP over TLS only, as a listener with tls: does, and logs refusals the same way. Both read PEM files, the same as certificates: entries.
  • What to do: try a feed's TLS settings in a lab with ductus cert self-signed pairs before the receiver's team is involved (scripts/demo-tls.sh does the whole path, with a renewal mid-stream).

HTTP interfaces on one port share its tls (level 2)

  • What: HTTP interfaces on the same port are one server, so they're all HTTPS with the same certificate, or all plain. ductus validate and the engine refuse a port whose interfaces disagree.
  • What to do: give them the same tls:, or different ports.

Nuances: who may connect (allow:)

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

An allow list says who, which "one connection only" never did (level 2)

  • What: allow: [10.1.20.0/24, 10.1.30.15, epic-bridge01.hospital.org] on an MLLP or HTTP source (or on api in ductus.yaml) closes a connection from any other address before the TLS handshake and before a byte is read; without allow, anyone who can reach the port may connect, as before. Listeners take any number of connections; there is no connections: 1 setting.
  • Why: sites limit a listener to one connection as a stand-in for "only the sending system", but it doesn't say which system: whoever connects first wins, and after a network blip the real sender's reconnect can be refused while its old half-dead connection holds the slot. An allow list names the senders, and a sender that opens several connections (common: one per thread, or a new one before the old has timed out) isn't refused for it.
  • What to do: list the sending system's addresses (or its subnet), its failover or DR hosts, and anything that relays for it. ductus validate warns when about.from.hosts names a sender the list would refuse, and refuses a malformed entry (a bad subnet, a wildcard *.hospital.org, a range 10.1.20.1-10.1.20.50, allow: []).

It's defence in depth, next to the firewall (level 2)

  • What: the check is in the engine, so it holds wherever the engine runs (a new server, a container, a firewall rule opened too wide), and its refusals are in the engine's log with the listener and address. But the connection has already reached the engine's server: the firewall still keeps everyone else off the network, and stops floods the engine would otherwise have to accept and close.
  • What to do: keep the firewall rules; add allow so a mistake in either is caught by the other. Behind a proxy or load balancer the address seen is the proxy's: list the proxy (we don't trust X-Forwarded-For, which any caller can write).

Host names are looked up at start and every 5 minutes (level 3)

  • What: a host name in a list is resolved forwards (name → addresses: hosts file, then DNS) when the engine starts, every 5 minutes after, and at once after a refusal at a listener that lists names (at most once a minute per name, however many connections are refused). Connections never trigger a lookup: ten connections from epic-bridge01 compare against addresses already in memory. A name that doesn't resolve at start lets no one in for it (the engine still starts, with a warning); a name that stops resolving keeps the addresses it last had, so a DNS outage doesn't cut off senders that were working. No reverse lookups: a sender controls the reverse record of its own address. Cost: one lookup per distinct name every 5 minutes, and nothing at all when no list names a host.
  • What to do: prefer addresses or subnets for senders whose address never changes; use a name when the sending side moves (failover, a cloud host) and its DNS is kept current. After a sender moves, its first connection is refused and the next one, seconds later, gets in.

Refusals are logged once a minute per listener and address (level 3)

  • What: connection refused: not on the allow list with the listener, the address and the list (names with their addresses now), at most once a minute per listener and address, like refused TLS handshakes. The Ports card on the Connections page and GET /ports count refusals since the engine started and show the last address refused; the interface page shows the list. The sender sees its connection closed with nothing read: an MLLP sender gets no ACK and retries.
  • What to do: a sender that says it can't connect: look for its address in the log or on the Ports card. The engine's own checks (Connections → Check now) are let in whatever the list says; ductus doctor run as its own program on the same server is refused unless 127.0.0.1 is listed, and it says so instead of checking the TLS it can't reach.

List 127.0.0.1 on the API if you use it from its own server (level 2)

  • What: api.allow applies to everyone, including ductus status, drain and a browser on the engine's own server. ductus validate warns when the list leaves out 127.0.0.1.
  • What to do: api: { listen: 0.0.0.0:7700, tls: ..., allow: [127.0.0.1, 10.1.5.0/24] } (this machine and the operators' subnet).

HTTP interfaces on one port share its allow (level 2)

  • What: the list is checked before the request is read, so before the path is known: interfaces on one port share one list, and ductus validate and the engine refuse a port whose interfaces disagree.
  • What to do: give them the same allow:, or put callers with different lists on different ports.

Dead connections are noticed in about 30 seconds (level 3)

  • What: every connection our listeners accept has TCP keepalive on: after 15 s of silence the server's kernel probes the sender every 5 s, and after 3 unanswered probes the connection is closed (the operating system's default would take about 2 hours). A sender that was powered off or cut off by a firewall no longer holds a connection open for hours, and quiet MLLP connections stay open through firewalls that drop idle ones. The kernel sends the probes; the engine does nothing.
  • What to do: nothing; a firewall that forgets idle connections in under 15 s would still drop them (rare: most use minutes to an hour).

Nuances: TLS on what the engine connects to

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

A private CA is trusted only where you name it (level 2)

  • What: every connection the engine makes over TLS (MLLP destinations with tls:, HTTPS destinations and apis:, the directory, Postgres databases: entries, the alerts' mail server, webhooks and heartbeat) trusts what this server's system trusts, plus the CA its ca: names (a certificates: entry in resources.yaml), for that connection only. tls: true on an MLLP destination is the system's alone. The receiver's certificate must also cover the host (or URL host) connected to: a certificate for hie.example.org is refused at 10.1.2.3, unless it lists that address.
  • Why: a hospital CA added to the whole server would be trusted by every connection, and every program on it; naming it per connection keeps a partner's CA to that partner.
  • What to do: ask the receiver's team for the CA that issued their certificate (not their certificate itself, which changes at every renewal), put it under certificates: (ca_file: or ca: ${secret:...}), and name it in ca:. Connect by the name on their certificate. ductus validate and the engine's start refuse a ca: that names a pair, a certificate: that names a CA, or a name that isn't there.

A refused handshake is a temporary error: the message waits (level 2)

  • What: when TLS fails (the receiver's certificate is from a CA we don't trust, doesn't cover the host, has expired; the receiver wants a client certificate we didn't present, or refuses ours), the delivery is a temporary failure: it retries with the usual back-off, nothing is quarantined, and the destination's error says why in words ("TLS: the receiver wants a client certificate (name one in tls.certificate)"). With TLS 1.3 the receiver judges our client certificate after our side has finished the handshake, so the refusal arrives as the answer to the first message: that message was not delivered, and is sent again.
  • What to do: fix the configuration (or ask the receiver to); the waiting messages go as soon as the handshake works, in order. tls: pointed at a port that speaks plain MLLP gets no handshake and times out ("timed out waiting for ACK"): check with the receiver which port is TLS.

Our client certificate: tell the receiver what to expect (level 2)

  • What: certificate: (MLLP: tls: { certificate: ... }) presents a certificates: pair as our client certificate (mutual TLS). The receiver checks it against the CA it trusts for clients and often against its subject. A pair from files renews like ours (replace both files; new connections present the new one within a minute, no restart), as a listener's does; the mail server's connection picks it up at the next mail.
  • What to do: give the receiver's team the certificate's subject and the CA that issued it, and renew it before it expires (the receiver refuses an expired one, and every message waits). One pair can serve as both a listener's certificate and a client certificate if its CA allows both uses.

An HTTP destination through an API uses the API's TLS (level 2)

  • What: ca: and certificate: go on the apis: entry, which api() calls and http: { api: ... } destinations share. On an HTTP destination they are only for one with its own url: (https://): validate refuses them next to api: or on an http:// URL.
  • What to do: put TLS where the URL is.

Doctor makes the engine's handshake (level 3)

  • What: ductus doctor, the scheduled checks and the Connections page build each TLS connection from the same settings the engine uses (the named CA, the client certificate), so a check passes when the engine's connection would. After a TLS 1.3 handshake it waits up to half a second for the server to refuse the client certificate (TLS 1.3 says so only then; TLS 1.2 says so inside the handshake, so there's no wait), and says which. A certificate named in ca: or certificate: that doesn't load fails the checks that use it, with why, instead of stopping the run.
  • What to do: after changing TLS settings, run ductus doctor --interface <name> before deploying; the MLLP check connects and shakes hands but sends nothing.

Nuances: TLS to Postgres

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

Without sslmode, the database connection is TLS when offered, and nobody is checked (level 3)

  • What: DUCTUS_DATABASE_URL means what it means in psql: without sslmode it is libpq's prefer, TLS when the server offers it (Ubuntu's Postgres and most packages do, with a self-signed certificate), plain when it doesn't, and the certificate is never checked. require insists on TLS and checks nothing either. Only verify-full checks that a trusted CA issued the server's certificate and that it is for the host connected to (verify-ca: the CA, not the name). resources.yaml postgres entries work the same with tls: (default prefer; verify-full when ca: names a CA). The start log, ductus validate, doctor and the Connections page say which ("TLS 1.3, not verified (sslmode=prefer)", "TLS 1.3, verified (sslmode=verify-full)", "plain, no TLS"), and warn when the database is on another machine and not verified.
  • Why: a URL that works with psql must work here, and verify-full by default would refuse every install whose Postgres has its own self-signed certificate. But TLS that checks nothing stops only someone listening: anyone who can answer at the database's address (a DNS entry, a spoofed address on the way) gets the password and every query.
  • What to do: for a database on another machine, ?sslmode=verify-full, plus &sslrootcert=/path/ca.pem when this server doesn't already trust its CA (sslrootcert=system: the system's alone); in resources.yaml tls: verify-full and ca: (a certificates: entry). ductus validate says "verified" when it works. For one on this machine, a Unix socket (postgres:///ductus?host=/var/run/postgresql) needs no TLS and is the fastest.

Cloud Postgres requires TLS (level 1)

  • What: Azure Database for PostgreSQL and Amazon RDS are usually set to refuse plain connections. Azure's certificates chain to public roots this server already trusts; RDS's are issued by Amazon's own RDS CAs, which it doesn't.
  • What to do: sslmode=verify-full; for RDS add sslrootcert= with Amazon's RDS CA bundle (downloaded from AWS) for the region. Connect by the endpoint's DNS name, the one on its certificate.

A CA installed on this server counts (level 2)

  • What: the database's certificate is checked against this server's trusted certificates (Linux: update-ca-certificates; Windows: the machine's store) plus sslrootcert (or ca:), as MLLP and HTTPS connections are. sqlx reads the system's certificates at every new connection (about 10 ms with verify-full), so a CA installed while the engine runs counts from the next one.
  • What to do: install a hospital CA once on the server, or name it per connection; both work.

The database's certificate is watched when the engine checks it (level 3)

  • What: a Postgres connection with verify-full or verify-ca (the engine's own database or a resources.yaml entry) is in the engine's certificate checks: the 8-byte SSLRequest, then the handshake the engine would make, its dates kept and alerted at 30, 14 and 7 days, as "engine database" or the entry's name. Its expiry would stop the engine connecting. With prefer or require, doctor shows the certificate (and whether verify-full would accept it) but doesn't watch it: an expired one changes nothing for a connection that checks nothing.
  • What to do: switch to verify-full, and it's watched.

A client certificate renews at the next connection (level 3)

  • What: certificate: on a Postgres entry logs in with a certificates: pair (pg_hba's cert method: no password; or with a password and clientcert=verify-full). Renewed from its files, the pool's next connection presents it; open ones keep theirs until they close (sqlx keeps a connection up to 30 minutes, an idle one 10). The engine's own database reads sslcert= and sslkey= at every new connection.
  • What to do: renew before expiry: it counts down with "Ours" on the Connections page. Ask the DBA which CA they trust for clients and which name (the certificate's CN) maps to the database role.

Opening a database connection over TLS costs ~60 ms, for now (level 4)

  • What: sqlx 0.9.0 doesn't set TCP_NODELAY, and a TLS handshake followed by the login waits on the server's delayed ACKs: opening a connection took 64 ms (prefer) and 79 ms (verify-full) against 5 ms plain, on a local Postgres. Pools keep their connections, so messages rarely see it; a start does: 1,001 interfaces were ready in 0.91–1.06 s over TLS against 0.52–0.66 s plain. Strict ingest with 1 sender is within noise; with 16 senders it was ~7% lower (2,636 against 2,831 ACKs/s), ~2% with TCP_NODELAY set. Fixed in sqlx (transact-rs/sqlx#4336, after 0.9.0): with it, opening takes 8–11 ms (22 ms verify-full) and the start 0.63–0.72 s.
  • What to do: nothing in most installs; the next sqlx release removes it. If start time or pool growth matters for a database on this machine, use a Unix socket. Don't give up verification for it.

Nuances: TLS to SQL Server

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

SQL Server connections are verified, and trust_server_certificate is for labs (level 3)

  • What: every SQL Server connection is TLS for the whole session, and the engine checks the server's certificate against this server's trusted certificates plus ca: (a certificates: entry), for the name in host. trust_server_certificate: true accepts any certificate: anyone who can answer at the server's address (a DNS entry, a spoofed address) gets the login and every query. The start log and ductus validate warn about it for a server on another machine, doctor says "not verified (trust_server_certificate)" with what verifying would make of the certificate, and the Connections page shows the lock open. It's refused next to ca: or name_in_certificate: (they'd never be checked). There's no encrypt: false: SQL Server would then encrypt only the login and send queries and results readable.
  • Why: many SQL Servers present the certificate SQL Server generates for itself when none is configured, which nobody can verify: it's self-signed, an old X.509 v1 certificate (refused as such: "an old X.509 v1 certificate, which can't be verified"), and its name is always SSL_Self_Signed_Fallback, whatever the server is called (seen on 2022 and 2025). So drivers' "trust the server certificate" switch became a habit.
  • What to do: ask the DBA for a certificate from your CA for the name you connect by (SQL Server Configuration Manager on Windows; mssql-conf network.tlscert and network.tlskey on Linux), name the CA in ca: unless this server already trusts it, and drop trust_server_certificate. Refusals say what's wrong: "not trusted: its issuer isn't one this server trusts", "the certificate isn't for 10.1.2.3", "has expired".

Connecting by a name the certificate doesn't list: name_in_certificate (level 3)

  • What: the certificate must be for the name in host. An availability group listener, a DNS alias or an IP address often isn't on it. name_in_certificate: sql1.hospital.org (ADO.NET's HostNameInCertificate) checks the certificate for that name instead, while connecting to host.
  • What to do: use it rather than trust_server_certificate: the certificate is still checked, only for another of the server's names. Better still, ask for the listener's name on the certificate.

encrypt: strict is TDS 8: TLS before anything (level 3)

  • What: by default (mandatory), as every SQL Server client, the engine first exchanges PRELOGIN packets in the clear, then makes the TLS handshake inside them; this always negotiates TLS 1.2 (TDS 7 can't carry TLS 1.3). encrypt: strict makes the handshake first, asking for tds/8.0, with TLS 1.3 when the server allows it. Servers set to force strict accept nothing else. SQL Server accepts strict only with a certificate configured for its own host name, never with its generated one, and SQL Server 2022 on Linux refuses strict whatever it's given (tested with CU27); SQL Server 2025 on Linux accepts it. ADO.NET ignores TrustServerCertificate with strict and always verifies; here trust_server_certificate still turns verification off (for labs), warned about as without strict.
  • What to do: leave encrypt out unless the DBA forces strict; then set encrypt: strict. A server that doesn't accept strict closes the connection during the handshake, and the error says to leave encrypt: strict out.

SQL Server 2022 on Linux doesn't accept encrypt: strict (level 3)

  • What: tested against SQL Server 2022 on Linux (CU27, Microsoft's container image): it refuses every TDS 8 strict connection with "A valid TLS certificate is not configured to accept strict (TDS 8.0 and above) connections", whatever it's given: its generated certificate, a CA's certificate for its own host name (CN and subjectAltName, server authentication, key encipherment), TLS 1.2 and 1.3 or 1.3 alone. SQL Server 2025 on Linux (CU10) accepts strict with the same certificate. SQL Server 2022 on Windows isn't tested here (Microsoft documents strict for it). The default (mandatory: TLS after PRELOGIN, verified) works with both.
  • Why: not documented: Microsoft's page on encrypted connections for SQL Server on Linux doesn't mention strict for any version. Measured, not explained.
  • What to do: against SQL Server 2022 on Linux, leave encrypt out: the connection is still TLS throughout and verified. Use encrypt: strict with 2025, or on Windows once tried there. The engine's own tests run against 2025 for this reason.

SQL Server's certificate is read through PRELOGIN, and watched when verified (level 3)

  • What: doctor and the engine's own check read a SQL Server's certificate as the engine's connection meets it: a PRELOGIN packet asking for encryption (its answer gives the server's version), then the handshake inside PRELOGIN packets (with encrypt: strict, plain TLS). Nothing logs in, in the scheduled check. A verified connection's certificate is kept and alerted at 30, 14 and 7 days under the entry's name; one with trust_server_certificate is described, not watched (an expired certificate doesn't stop a connection that checks nothing). A named instance without port: is found through the SQL Server Browser first, and its certificate is kept under the port it answered with. The check takes ~35 ms through PRELOGIN (TLS 1.2), ~0.55 s with strict (TLS 1.3: see "Doctor makes the engine's handshake").
  • What to do: verify the connection, and the certificate is watched. A SQL Server reads its certificate at start: after the DBA renews it, the server's restart is what makes it count (and "Check now" shows it).

Verification costs nothing measurable (level 4)

  • What: opening a SQL Server connection (PRELOGIN, TLS, login, a first query) took ~120–140 ms in every mode against a local SQL Server 2025: trust_server_certificate, verified, and strict verified (scripts/bench-mssqltls.sh; differences within the noise between rounds). Pools keep their connections (closed after 5 idle minutes), so messages rarely wait for one.
  • What to do: verify; there's no speed to gain by not.

Nuances: SQL Server Windows logins

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

auth: windows: the engine's own account, or a named one (level 1)

  • What: auth: windows with no user logs in as the account the engine runs as (integrated, like Integrated Security=true); with user: 'HOSPITAL\svc-lab' and its password it logs in as that account. auth: sql (the default) is a SQL login. The account the engine runs as exists only on Windows: on Linux, auth: windows without a user is refused when resources.yaml loads.
  • Why: a SQL Server that allows only Windows logins (many hospital DBAs set it that way) needs one of the two; integrated keeps a password out of the configuration altogether.
  • What to do: on Windows prefer integrated, with the service running as a gMSA (no password anywhere). Ask the DBA for a login for that account: CREATE LOGIN [HOSPITAL\svc-ductus$] FROM WINDOWS, then a user in the database.

The service's own account is the computer on the network (level 3)

  • What: the Windows service runs as NT SERVICE\Ductus by default. On the network that account is the computer's: HOSPITAL\SERVER01$. An integrated login from the default service is therefore a login for HOSPITAL\SERVER01$, and anything else on that server running as a service account gets the same identity.
  • Why: a virtual service account has no network identity of its own; Windows uses the machine account for it.
  • What to do: for a dedicated identity, run the service as a gMSA (HOSPITAL\svc-ductus$: ductus service account 'HOSPITAL\svc-ductus$'); the start log and doctor say which account SQL Server saw ("SQL Server logged us in as HOSPITAL\SERVER01$"). ductus validate and ductus doctor on the server say which account the service runs as and warn when an integrated login would be the computer account's. Doctor's own integrated logins are yours (it runs in your terminal), not the service's: it says so.

Kerberos or NTLM: doctor and the start log say which (level 3)

  • What: on Windows the login asks for Kerberos and falls back to NTLM by itself (SSPI's Negotiate, as ODBC and ADO.NET do). Doctor's login step and the start log (once per SQL Server, for Windows logins) say who the server took the login for and how: "(Windows, Kerberos)" or "(Windows, NTLM: no Kerberos, most likely no SPN for MSSQLSvc/sql1.hospital.org:1433)". NTLM to another machine is a warning in doctor; NTLM to a SQL Server on the same machine is usual and passes.
  • Why: Kerberos needs the SQL Server's SPN (MSSQLSvc/host:port, or :INSTANCE) registered for the account SQL Server runs as, and the engine connecting by that name (not an IP address or an alias). Without it the login still works, as NTLM, and nothing else tells you.
  • What to do: if doctor says NTLM, ask the DBA to register the SPN (setspn -S MSSQLSvc/sql1.hospital.org:1433 HOSPITAL\sqlsvc) and connect by the name in it. Kerberos itself has not been tested against a domain yet (no domain in our test setups): the first site with one confirms it.

A refused Windows login: 18452 is Windows saying no, 18456 is SQL Server (level 2)

  • What: when Windows doesn't accept the account, SQL Server answers error 18452, "the login is from an untrusted domain", whatever the cause: a wrong password, an account the server's Windows doesn't know, or NTLM blocked by the domain. When Windows accepts it but SQL Server has no login for it (or the login is disabled, or can't open its default database), the answer is 18456, "Login failed for user". The engine words both, and, as with a refused SQL login, messages wait (a temporary error) until it's fixed.
  • Why: the server doesn't tell the client more; its own error log does.
  • What to do: 18452: check the password secret for this environment and the account's name, then whether the domain restricts NTLM (or get Kerberos working: the SPN). 18456: ask the DBA for CREATE LOGIN [DOMAIN\name] FROM WINDOWS and a user in the database. A login given to a Windows group (SQL Server Express gives one to BUILTIN\Users, which every signed-in account is in) lets every member in: an account can get in with no login of its own.

From Linux, a Windows login is always NTLM (level 3)

  • What: on Linux, auth: windows needs a named account and logs in with NTLM, in pure Rust: never Kerberos. Validate and the start warn about it. Microsoft has deprecated NTLM (2024), many security teams audit it, and a domain that blocks NTLM refuses these logins (18452).
  • Why: Kerberos from Linux needs a keytab and the system's GSSAPI library, which the binary would then need on every server whether it's used or not; it waits for the first site that needs it.
  • What to do: from Linux prefer a SQL login, or run the engine on Windows for Windows logins. If you use NTLM, tell the security team, and expect it to stop working when the domain turns NTLM off.

Extended Protection set to Required refuses NTLM logins (level 3)

  • What: a SQL Server with Extended Protection for Authentication set to Required refuses an NTLM login that carries no channel binding, and the engine's carries none. Kerberos logins are not refused for it.
  • Why: channel binding ties the login to the TLS connection; the library the engine uses doesn't send one yet (a possible later addition). Not tried here: our test server leaves Extended Protection off, as SQL Server does by default.
  • What to do: use Kerberos (the SPN), Extended Protection set to Allowed, or a SQL login.

Nuances: certificates

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

The engine checks certificates by itself, and logs in to nothing (level 2)

  • What: every doctor.schedule (6 h by default; the first a minute or two after start), the engine runs the connection checks in certificates mode: DNS, TCP and the TLS handshake of every connection with a certificate, nothing else. It keeps what it saw (the Connections page, GET /api/v1/certificates) and the run itself (the last 30). mode: full runs everything ductus doctor does, every database and SFTP login included, every few hours.
  • Why: a full run logs in to every system four times a day. Hospital security teams notice service accounts that do that, and a password that changed locks the account out after a few runs.
  • What to do: keep certificates unless someone asked for scheduled login checks; run full checks when you want them (the Connections page, POST /api/v1/doctor, ductus doctor). doctor: { schedule: off } turns the scheduled checks off (and with them the countdown's freshness: dates then come from the last check someone ran).

What has a certificate the engine watches (level 3)

  • What: HTTPS APIs (resources.yaml) and HTTPS destinations, MLLP destinations with tls:, the directory people sign in with (ldap: in resources.yaml, ldaps:// or StartTLS), Postgres with verify-full or verify-ca (the engine's own database and resources.yaml entries: see "The database's certificate is watched when the engine checks it"), SQL Server without trust_server_certificate (see SQL Server's certificate is read through PRELOGIN), and what alerts connect to: the mail server (tls: starttls or tls), HTTPS webhooks and the heartbeat. Not watched: databases the engine doesn't verify, SFTP (host keys, not certificates), MLLP destinations without tls: (plain TCP). Our own certificates are watched too, from what the engine loaded (see Our certificates count down like the rest).
  • What to do: verify every database on another machine, and its certificate is watched with it.

The earliest expiry in the chain counts, in whole days (level 2)

  • What: a server presents its certificate and usually one or more intermediates. Clients refuse the connection when any of them has expired, so the countdown runs to the earliest of them (the Connections page names an intermediate that ends first). Days are whole days in UTC dates: a certificate ending tomorrow at 00:30 UTC has 1 day left all of today, wherever the server is.
  • What to do: when the intermediate is the one ending, the fix is the server's certificate bundle (the CA has a newer intermediate), not a new certificate.

A certificate alert is told at 30, 14 and 7 days, and when it expires (level 2)

  • What: with a rule naming certificate ({ when: [certificate], severity: warning, to: [oncall] }), a certificate alerts when it is 30 days from expiry, again at 14 and at 7 (escalated, even if someone acknowledged it), and as critical once expired, whatever the rule's severity. Each step is told once: no reminders before it expires (the steps are the reminders), the usual repeat after. Renewed past 30 days, it recovers (after settle); renewed with a certificate that is itself short, it moves to that step quietly. alerts.certificates.warn_days changes the steps. Without a rule naming certificate, nothing is sent (ductus validate says so) and the Connections page still shows them.
  • What to do: send certificate to the people who renew certificates, not the on-call pager; warnings respect quiet hours, an expired one doesn't. to: [owner] reaches a destination's owner for a destination with its own URL, and a resources.yaml entry's owner: (an API, the directory) for its certificate; entries without one go only to the rule's channels.

A check that can't connect keeps the last dates (level 2)

  • What: a certificate is read even when this server doesn't trust it (a private CA, self-signed): the Connections page says "not trusted by this server", and the dates count all the same. When a check can't connect at all, the row shows the error and keeps the dates the last successful check read ("dates from ..."); the countdown and alerts go on from those. A server never reached shows "Not read yet" and can't alert.
  • What to do: a certificate stuck on "Not read yet", or with an old "dates from", needs the connection fixed first (the same row's error says why).

Behind a load balancer, you see the balancer's certificate (level 3)

  • What: the check reads whatever certificate answers at the configured name and port: a load balancer or proxy that ends TLS presents its own, not the servers' behind it. If the balancer's backends present different certificates, each check may see another one; the alert follows what it sees (renewed, then not, is one alert held open by settle, but a backend left on an old certificate shows again at the next check).
  • What to do: renew every backend, and ask the balancer's owner which certificate clients actually get.

Our certificates count down like the rest, with no check (level 2)

  • What: every certificates: pair the engine presents, on a TLS listener (an interface's or the API's) or as a client certificate, is one row in the Connections page's "Ours" group and in GET /api/v1/certificates (side: ours, on this server at port 0, with where it is presented). It is read from what the engine loaded, never over the network, and alerts like the servers' (30, 14, 7 days, expired) to its owner: in resources.yaml, or, without one, to the owner of the one interface it serves. Its row in the database is written at the start and again only when a renewal is picked up or can't be used: nothing runs for it while nothing changes (the minute's file check reads the files, not the database).
  • What to do: give each pair an owner: (the people who renew it), and a rule naming certificate to someone. After replacing the files, "Check now" (or any connection check) reads them at once instead of within the minute.

A renewal that can't be used shows on its row, not as an alert (level 2)

  • What: files that changed but don't load (a new certificate with the old key, half copied, expired) keep the pair in use, log one error, and show on the pair's row ("the new files can't be used: ..."). The countdown and its alerts go on from the pair still presented; nothing new is sent about the broken files themselves.
  • What to do: after a renewal, look at the "Ours" group (or the log's certificate renewed): an error there means senders still get the old certificate, and it still expires when it did.

Doctor reads the chain our listeners serve, but not the name senders use (level 3)

  • What: in full mode (the Connections page's "Run the checks", POST /doctor with mode: full, a scheduled mode: full), the engine connects to each of its TLS listeners, over loopback when it listens on every address (0.0.0.0, ::), takes the chain it serves whatever it is, and judges it the way senders build a path: complete when it ends at a root, or at a certificate issued by one this server trusts or a certificates: CA; a warning when it stops short ("only our certificate is served, not the one that issued it"), since Java and .NET senders don't fetch a missing intermediate. ductus doctor does the same for a listener whose port is in use (a running engine). Certificates-only runs skip it (it reads nothing new about expiry).
  • Why it can't say more: it doesn't know the name senders connect with (it shows the names the certificate covers), nor which roots they trust: a chain ending at your private CA is complete only for senders that trust that CA. A warning about a chain that ends at your private root can be right: name that root as a certificates: CA and doctor knows it.
  • What to do: fix a warning by appending the intermediate to cert_file; ask senders which name they connect with and check it is among the names shown.

A refused certificate is said once, not logged as an error each time (level 4)

  • What: the TLS verifier the engine uses logs every certificate it refuses at ERROR. The engine turns that log off by default (rustls_platform_verifier=off): the refusal is already in the check's result and in a failed delivery's error, and scheduled checks of a private-CA server would otherwise log an error every few hours.
  • What to do: to see the verifier's own lines, set RUST_LOG=info,sqlx=warn,rustls_platform_verifier=debug.

Nuances: people and access

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

Reading payloads is a permission, not part of a role (level 4)

  • What: GET /api/v1/messages/{id} (and the message page) returns the payload, and the saved output of each destination, only to admins and to users and tokens that were given the payloads permission. Without it the message page still shows its status, destinations and errors, and says the payload is hidden. Editing a message needs the permission too (correcting a message means reading it). Each read is in the audit log (next entries). Existing tokens were revoked when users came in (migration 0018); a new token has no permissions unless it is given them.
  • What it doesn't hide: the ordering key (often an MRN), the control id, error texts (a quarantine's error can quote a field value) and sizes are still shown to anyone who can read the interface. The permission protects the message content, not every identifier the engine has seen. Write error messages in interface code the way you would write a log line that a support person may read.
  • What to do: give payloads to the people who fix data problems and to nobody else; a monitoring token needs none. ductus user create ... --permission payloads, ductus token create ... --permission payloads.

Passwords, lockout and what a disabled account loses (level 2)

  • What: people sign in with a username (not case sensitive) and a password of at least 12 characters (auth.password.min_length; never below 8) that isn't the username, one character repeated, or one of a short list of common ones. Passwords are stored as argon2id hashes. Five wrong passwords for one username within 15 minutes lock that account for 15 minutes (auth.lockout); the right password doesn't help meanwhile, and an admin can unlock at once. A wrong username and a wrong password give the same answer in about the same time.
  • Why lock by username: per username, not per address, because sign-ins arrive through a proxy. The price is that someone who knows a username can keep that person locked out; an admin unlocks, and locking was the safer side of that choice for patient data.
  • Disabling a user ends their sessions at once and stops every token of theirs (their scripts get 401). A password an admin chose (a new account, a reset) is temporary: the person's first sign-in can do nothing but change it. There is always at least one admin who can sign in: the last one can't be disabled or demoted.
  • What to do: make accounts for people, tokens for scripts; let people make their own tokens under Account (they stop with their owner). Turn auth.token_sign_in off once everyone has an account, so a leaked token can't also open the UI. If you lock yourself out, ductus user on the server (it works on the database directly) makes or unlocks an admin.

The audit log records who did what, and what it leaves out (level 2)

  • What: GET /api/v1/audit (the Audit page, ductus audit export) lists, newest first: sign-ins, failed sign-ins (and whether they locked the account), sign-outs, password changes and resets, user and token changes, payload reads, resends, replays, edits and dismissals of messages, pauses and resumes, drains, retention changes and confirmations, connection checks, exports of the log itself, and the engine's own starts, stops and stopped interfaces. Each row has who (a user, a token, system for the engine or someone at the server's command line, anonymous for a sign-in as a name nobody has), how (web UI, token, command line), the address, what it was done to, and a reason or details. Reading it needs the audit permission (admins have it); anyone signed in with a password sees their own activity under Account.
  • What it leaves out: passwords, tokens and payloads are never in it. A failed sign-in for a name nobody has keeps no name: what people type in the username box is sometimes their password. Messages read through the database directly (psql) or by the engine's own code aren't API calls, so they aren't rows: the log covers the API, the web UI and the commands of ductus.
  • The address is the connection's. Behind a TLS proxy (which the UI needs over a network) it's the proxy's address for everyone; the audit log can still say who, just not from where. A forwarded-for header is not trusted yet.
  • What to do: give audit to the compliance or security person, not to operators. Ship the log to your SIEM with ductus audit export --since <day> (JSON lines, oldest first; the export is itself a row).

Payload reads are logged before they are shown (level 3)

  • What: a read of a payload (GET /messages/{id} by someone with payloads) writes its audit row first. If the database can't take the row, the answer is 503 and no payload is returned: an unlogged read of patient data is worse than a refused one. The same message read again by the same person within audit.payload_read_window (10 minutes) is one row: a screen that reloads itself (the UI refetches when the window regains focus) would otherwise write a row every few seconds and bury the log. 0s logs every read. audit.payload_reads: false turns the rows off (the permission still applies).
  • What to do: read "viewed message N between these times" as "at least once in a window", not as a count of looks. If you need every read, set the window to 0s and size the log for it (a row is about 300 bytes).

Dismissing a quarantined message is not deleting it (level 2)

  • What: POST /messages/{id}/dismiss and /messages/dismiss (the Quarantine screen's "Dismiss selected") close a quarantined delivery nobody will fix or resend. The delivery becomes filtered with "dismissed by NAME: reason", leaves the quarantine, and nothing is sent; the message is settled again (sent, if another destination sent it). A reason is required. Only quarantined deliveries are touched: a pending one is being retried, a sent one isn't a problem. The message, its payload (while its retention lasts) and the dismissal stay visible on its page, and it can still be replayed later. In the held area a dismissal is how a held quarantine ends: the month's table drops once nothing in it is unfinished.
  • What to do: write the reason for the next person ("lab retired the unit; these are the 37 results for it"), not for yourself. A dismissal is a decision about a patient's data not reaching a system; the audit log keeps who made it.

Copies of payloads and the audit log are kept for different times (level 3)

  • What: every resend, edit and dismissal keeps what the delivery was before, including the output it had sent (message_actions.previous_output, for destinations with save_output: true). That is a copy of patient data, so it goes when the message's own retention ends: an hourly job sets it to NULL for actions older than the interface's retention (1, 3 or 7 weeks; an interface that is no longer configured gets 7); the row (who, when, what it was) stays. The rows themselves, and the audit log, are deleted after audit.keep: six years unless the project says otherwise (at least one year; ask your compliance office what your organization needs). At about a thousand rows a day that is some two million rows over six years.
  • What to do: set audit.keep in ductus.yaml to your documented retention period, then leave it; shortening it deletes history at the next hourly run. The engine's log shows what the job cleared and deleted.

A crash shows in the audit log (level 3)

  • What: each start writes engine.start (version, host, and how the previous run on this host ended); each clean stop writes engine.stop with the same instance. A start whose predecessor has no stop was killed, crashed or lost power; the new start's row says previous: unclean and the engine logs a warning. A stop that had to give up waiting (30 s) is written forced. With two engines on one database, each host is judged by its own rows.
  • What to do: after an unclean start, nothing is lost (messages in hand are processed again, as the at-least-once rule says), but look at the receivers for duplicates around that time.

Signing in with the directory: local names first, groups decide the role (level 2)

  • What: with auth.ldap in ductus.yaml (and its directory in resources.yaml), people sign in on the same page with their directory username and password. A name a local account holds is checked here and only here, never against the directory: a directory account can't take over the break-glass admin's name, and a directory person whose name a local account already has can't sign in until one of them is renamed. Any other name goes to the directory: the service account finds the person (user_filter, with what they typed escaped, so * or )( match nothing), the engine binds as them with their password, then reads their groups. The highest role group they are in wins; permissions groups only add to a role; in no role group, they are told they have no access (the password was right, so that tells nobody anything new). Their account is made at the first sign-in and shows as a directory account; role, permissions, name and email are the directory's at every sign-in, read-only here. An admin can still disable one here: then it can't sign in, whatever the directory says.
  • What to do: make Ductus groups in the directory (admins, operators, viewers, and PHI readers for payloads), not one per person. Keep one local admin (ductus user create) for when the directory is down, with a long password kept where the on-call can get it. Don't map a role to a group everyone is in (Active Directory's primary group, Domain Users, isn't in member and wouldn't match anyway).

Write several group DNs one per line, not [a, b] (level 2)

  • What: a group takes one DN or a list. In YAML's flow style (payloads: [CN=PHI,OU=Groups,DC=h, CN=Night,OU=Groups,DC=h]) every comma separates items, the commas inside each DN too, so the list becomes CN=PHI, OU=Groups, DC=h, ...: pieces that each look like a DN and match no group. The engine refuses to start (and ductus validate says) when a group "DN" has only one part, naming the piece.
  • What to do: write lists in block style, one DN per line (- CN=PHI,OU=Groups,DC=h); a single DN can stay on the key's line.

A person removed from the groups loses access within refresh (level 2)

  • What: right after the start (in the background) and then every auth.ldap.refresh (15 minutes by default, at least 1 minute) the engine reads again the groups and account of every directory person with a live session or token, and of every directory account an owner in its project names (since 0.6.7.3, whether or not they ever signed in: next entry). Removed from every role group, disabled in Active Directory (userAccountControl), or deleted: their sessions end, their tokens stop, the account shows "Refused by the directory", and the audit log has a user.directory_refused row by the engine. Changed groups change the role and permissions the same way (user.directory_change). Put back, they sign in again (and their tokens work again at the next refresh). Between two refreshes, a session keeps the role it had: a removal takes up to refresh.
  • A sign-in the directory refuses (unknown name, disabled, no group) refuses the account with that name only when the entry it found is that account's person (or the account has no id yet): someone typing a renamed person's old name doesn't end their sessions; the refresh, by id, decides.
  • What to do: for someone who must lose access now, disable the account on the Users page (at once), then remove them in the directory. A shorter refresh costs one search per person per group each time: 5 minutes is fine for a few hundred people.

Directory owners get their account when they're in a Ductus group (level 2)

  • What: with auth.ldap, an owner written as an email address that reaches nobody (no account has it, now or before, and it isn't a team in owners:) is looked up in the directory by its email attribute (attributes.email) at each refresh round, the first right after the start. When exactly one entry has that email and it is in a Ductus role group (the same check as a sign-in), the engine makes their directory account then: made by the engine (audit user.create, "reason": "owner"), never signed in (the Users page says "Never signed in" and "made because they own something"). From then on it is an ordinary account: the Owners page shows the person, alerts mail them, Interfaces → Mine works at their first sign-in, and the round checks it like a signed-in one (a rename is seen within refresh; someone deleted or removed from the groups there is refused, so the owner shows as disabled, ductus owners --check fails on it, and no alert mail goes to their old address, even when the owner is written as that address). Anyone else stays an address (alerts go to the text itself): two entries with that email, or one in no Ductus group, make nothing, and the engine warns once per run ("in the directory but in no Ductus group: add them to one"). Owners written as a name or display name are never looked up: they must already be an account. A directory down, or slow, never delays a start: the round runs in the background, and tries again at the next refresh.
  • Cost: one search per owner email that isn't an account yet, every round, plus the group reads for each one found; once an account, a check by id with its groups, like a signed-in person's. Measured against a directory on the same machine: 200 owner emails nobody there has, 75 ms a round; 200 owners' accounts, 0.38 s a round (nearly all of it the directory's searches: an account the directory says nothing new about writes nothing). Over a network, add about a millisecond per search.
  • What to do: write owners as their email, and put the people who own interfaces in a Ductus group (a viewer group is enough to be an owner). For outside people (a vendor's support address), the address is fine: it is mailed as it is.

A new directory account for the same person can't be linked to the old one (level 2)

  • What: a person deleted in the directory and made again (rather than renamed) is a new entry with a new id: Ductus can't tell it's them. Their old Ductus account is refused at the next round, and an owner that names it shows as disabled (no alert mail) until the new account exists. When the owner text is an email and the new entry has the same email and a Ductus group, the next round makes the new account, and it wins (at one level an enabled account beats disabled ones). Written another way, the owner stays disabled until it is reassigned.
  • What to do: rename people in the directory rather than re-creating them. When one was re-created, check ductus owners --check, and ductus owners reassign --from <old text> --to <their email> where it still fails.

A renamed directory person keeps one account; a reused name gets a new one (level 2)

  • What: a directory account is the directory's person, known by their directory id (auth.ldap.attributes.id: by default Active Directory's objectGUID, else entryUUID, which OpenLDAP, 389 Directory Server and FreeIPA give every entry), read at each sign-in and refresh and kept on the account. The refresh looks people up by that id, not by name. So when the directory changes someone's sign-in name (jsmith → jjones at a marriage), the next refresh or their sign-in with the new name renames their account: sessions, tokens, settings and history stay, and the old name is kept (audit user.directory_change with "username": [old, new]). When a departed person's sign-in name is given to someone new (a new entry, so a new id), the new person gets an account of their own; the earlier account is renamed <name>#<its id> out of the way and refused, so its tokens never work again, whoever holds them. Every change of a username, display name or email, by the directory or an admin, keeps the old value (for 0.6.7.2's owners). A person given a new directory account (deleted and made again, rather than renamed) is a new id too: Ductus can't link the two, so their old account is refused at the next refresh and they start with a new one.
  • Before 0.6.7.1 no id was kept: an existing account takes its id at its first sign-in or refresh by name, so a name reused before that check can't be told apart (as before). With attributes.id: none, or a directory that gives no id, accounts are known by sign-in name only: ductus validate warns, and the engine logs it once.
  • The sign-in name is read from the attribute user_filter matches {username} against (sAMAccountName by default); a filter that matches it another way needs attributes.username, or a refresh can't see the name change (validate warns).
  • What to do: rename people in the directory rather than deleting and re-creating them, and revoke a person's tokens when they leave (the refusal stops them anyway; revoking makes it final). A rename onto a name a local account holds is refused and logged at each refresh: rename the local account.

OpenLDAP: group membership follows a rename only with refint (level 3)

  • What: Active Directory's group member links follow a renamed or moved person by themselves. OpenLDAP's are plain DNs: without the refint overlay, a modrdn of a person leaves their old DN in every group, so after the rename they're in no Ductus group, and the refresh refuses them ("in no Ductus group") although Ductus found them by id. An owner who never signs in is refused the same way (it shows as disabled, and gets no alert mail).
  • What to do: on OpenLDAP, turn on refint for member and uniqueMember (the tests' directory does: scripts/dev/slapd.sh), or update the groups by hand at each rename.

When the directory is down (level 2)

  • What: a directory person's sign-in answers 503 "the directory can't be reached right now" (not "wrong password": they would retype it and lock themselves out). Local accounts keep working. Sessions already open keep working: a refresh that can't reach the directory changes nothing (an outage must not sign the whole hospital out), so an outage also delays a removal until the directory answers again. A start never waits for the directory (its refresh round runs in the background, measured: a start with the directory down took as long as one without auth.ldap). A wrong service account password (bind_password) looks the same as an outage: the engine logs it as an error, and ductus doctor (full) says which.
  • What to do: watch the directory's certificate like the others (it is in the Connections table) and keep the break-glass admin. Point url at a name that fails over (a domain's DNS name that resolves to several controllers), not at one controller.

Wrong passwords count twice: here and in the directory (level 3)

  • What: a wrong directory password counts toward Ductus's lockout for that username (5 in 15 minutes, auth.lockout) once the person has signed in at least once (before that there is no account here to count on), and the directory counts it toward its own lockout policy. Active Directory answers a disabled, locked or expired account like a wrong password; the person sees "wrong username or password" either way.
  • What to do: set Ductus's lockout at or below the directory's, so Ductus refuses first and a typo storm doesn't lock the person out of everything else they use. A person locked here is unlocked on the Users page; locked in the directory, by the directory's admins.

Nested groups: Active Directory answers, other directories are walked (level 4)

  • What: nested: true counts members of groups inside the configured groups. On Active Directory (seen in its root entry) one search per configured group uses its transitive-membership rule (member:1.2.840.113556.1.4.1941:=). Other directories (OpenLDAP) have no such rule: the engine walks up from the person's groups, at most 8 levels, searching group_base (default: the directory's naming context) for groups that contain each one. Without nested, only direct members count (member, or uniqueMember for groupOfUniqueNames).
  • Not tested here: real Active Directory isn't available to the tests (OpenLDAP is); the transitive filter is unit-tested and is verified with the first pilot.
  • What to do: prefer direct membership of the Ductus groups: it is one search per group and easy to audit. With deep or wide nesting on OpenLDAP, set group_base to the groups' OU so the walk searches less.

The directory connection is TLS, verified like the rest (level 3)

  • What: url: ldaps://... (TLS from the start, port 636) or ldap://... with starttls: true. The certificate is checked against this server's trusted certificates, the same as HTTPS; a directory signed by the hospital's own CA that this server doesn't trust is refused unless ca: names that CA (a certificates: entry: hospital-ca: { ca: ${secret:HOSPITAL_CA} } or ca_file:), which is then trusted for this directory only. Plain ldap:// is refused unless insecure: true, which ductus validate and the engine warn about: people's passwords would cross the network readable.
  • What to do: use ldaps:// or StartTLS everywhere but a test directory. On Windows servers joined to the domain the CA is usually installed already; on Linux servers it usually isn't (ca:).

Theme and colors follow the account (level 1)

  • What: a signed-in user's theme and custom colors are saved on the server and applied in any browser they sign in to. A copy stays in the browser so the right theme shows before the page has loaded; signing out clears it, so the next person starts from the default. A session made with a token has no account: its choice stays in that browser.
  • What to do: nothing; a person who had picked colors before accounts existed has them sent to their account the first time they sign in.

Nuances: local development

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

ductus dev is a development tool, not a way to run in production (level 1)

  • What: ductus dev starts a Postgres of its own for the project (data in .ductus/postgres, one engine at a time per project) and runs the engine against it. It ignores DUCTUS_DATABASE_URL, uses default Postgres settings, and nobody backs the data up. Postgres is downloaded once per user (about 30 MB).
  • Why: to try an interface without asking anyone for a database. Production needs the DBA's Postgres (backups, monitoring, failover), and a message the engine has acknowledged must survive what the DBA protects against.
  • What to do: use ductus dev on your own machine; everywhere else set DUCTUS_DATABASE_URL and use ductus run. Without a download possible, unpack the Postgres binaries yourself and set DUCTUS_POSTGRES_DIR. ductus dev --reset throws the dev data away.

Your editor's help is the installed version's: run ductus types after an upgrade (level 1)

  • What: completion, descriptions on hover and underlined mistakes in interface.yaml, resources.yaml and ductus.yaml come from the JSON Schemas ductus types writes into .ductus/schema/ (with the SDK's typings in .ductus/types/), and those come from the ductus that wrote them. After an upgrade, a new setting is underlined as unknown and its description is missing until they're written again; on a laptop with an older ductus than the servers, the editor describes the older one. ductus test and ductus validate write them too.
  • Why: the schemas are made from the engine's own types, so they say exactly what that version accepts, offline (an air-gapped site can't fetch them from a website), with no second copy to drift.
  • What to do: run ductus types (or test) after installing a new version; keep developers' ductus at the version the servers run. .ductus/ isn't committed, so each machine writes its own.

Nuances: upgrades

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

Run ductus upgrade --check with the new version before every upgrade (level 1)

  • What: the new version's program (the portable archive: ductus-<version>-linux-x64.tar.gz or -windows-x64.zip, no install needed) reads the database and the project and says what its first start would do: the migrations it applies, with the tables they work through and their size now; the engines on the database, their hosts and versions; the project validated against the database and its tests run with the new SDK and type checker; settings it deprecates. Exit 1 means something would refuse the start or fail the project. It changes nothing, in the database or in the project folder.
  • Why: otherwise the first place you learn that a migration builds an index on a 50 GB table, or that a test fails under the new SDK, is the restart itself, in the change window.
  • What to do: run it from the unpacked archive on the server (or a workstation that reaches the database) before the change window, with the service's settings: ./ductus upgrade --check --env-file /etc/ductus/ductus.env (Windows: C:\ProgramData\Ductus\ductus.env). A read-only login is enough: GRANT pg_read_all_data TO ductus_check. --json gives one document to attach to the change ticket.

A migration marked heavy holds up a table while it runs (level 2)

  • What: most migrations add a table or a column and take milliseconds. One that works through a big table row by row (an index built, rows copied) is shown with ⚠, the table and its size: while it runs, the start waits, and storing or sending waits as it says. The version running before keeps working on the new tables afterwards (migrations only add), so rolling back is installing the previous version.
  • Why: an index is built in one pass over the table; Postgres holds the table's writes until it's done.
  • What to do: plan the window by the size shown; for a large table, time the upgrade on a copy (a restored backup) first. There have been none since v0.5 (0011, the queue index, was the last).

On Linux a restart refuses no connection: senders wait for it (level 2)

  • What: under the package's systemd service, systemctl restart ductus and a crash don't refuse senders (an upgrade doesn't either: it hands over, below). systemd keeps the engine's listening sockets open while it restarts and hands them to the new start; a sender connecting meanwhile waits in the kernel's queue until the engine accepts, then gets its ACK. Each open MLLP connection is closed once by the stop (after its last ACK) and the sender reconnects. Measured with 4 senders at 200 messages/s (Ubuntu 24.04 and Rocky Linux 9): a restart 0 refused, longest wait for an ACK 0.2–0.3 s (the same restart without systemd's help: 4 refused, 0.6 s), a package upgrade 0 refused, 0.2–0.35 s; a kill -9 0 refused, 5.4 s (systemd waits RestartSec, 5 s, before starting it again). Nothing lost, nothing twice, in order.
  • Why: a refused connection is what senders alarm on, and some wait a minute or more before they try again; a wait of a second inside one ACK isn't noticed. A start also has to connect and migrate before it can take messages: the queue covers that.
  • What to do: nothing; systemctl status ductus shows "running N interfaces" once it's up (systemctl start and restart wait for it now). The first upgrade to this version still refuses for a moment (the version before never gave systemd its sockets). A long migration makes senders wait as long: plan it (upgrade --check). Outside the package's service (ductus run by hand, ductus dev) a restart refuses as before.

systemctl reload hands over: receiving never stops, sending pauses briefly (level 2)

  • What: on Linux, systemctl reload ductus (what the package's upgrade runs) starts the program now on disk beside the running engine, on the same listening sockets. The new engine reads the project, migrates the database and loads every interface's code while the running one carries on; from then on both store what arrives. Once it's ready, the running engine stops as a clean stop does (each MLLP connection closed once, after its last ACK: the sender reconnects and lands on the new engine), lets go of its interfaces and exits; the new one takes them and sends. Measured under real systemd (Ubuntu 24.04, Rocky Linux 9), 4 senders at 200 messages/s: a reload 0 refused, longest wait for an ACK 60–67 ms (a restart: 0.2–0.3 s), sending paused ~35 ms; a package upgrade 0 refused, 0.17–0.22 s; nothing lost, twice or out of order (one interface under 300 messages/s in WSL: sending paused 80–170 ms); at 1,001 interfaces sending paused 0.6 s (most of it statements waiting on a database-driver delay that a coming sqlx release removes). The reload returns once the new engine sends, and says so in the log: "handed over to ductus 0.6.1 (pid 5233): receiving never stopped, sending paused 180 ms". The audit log has an engine.handover entry; the Overview says "took over from v0.6.0".
  • Why: a restart, even with systemd holding the sockets, makes senders wait for the whole start (0.5–1 s, longer with a migration) and sends nothing meanwhile; a handover overlaps the start with the running engine.
  • What to do: use systemctl reload ductus for upgrades (the package does) and deploys (below). Run ductus upgrade --check first as before. For a few seconds two engines run, so the server needs room for both (memory roughly doubles while they overlap). The first upgrade to 0.6.6.3 restarts instead: the version before has no handover.

A new version or project that can't start leaves the running one serving (level 2)

  • What: anything that keeps the new engine from getting ready (a broken interface.yaml or code, a failed migration, another engine owning one of its interfaces, a time limit of 5 minutes) stops the new one, not the old: systemctl reload fails, journalctl -u ductus says why ("the new engine can't take over: … This engine (pid 4120, ductus 0.6.0) goes on."), and senders notice nothing. A package upgrade whose new version can't take over leaves the package installed and the old version running, and says so.
  • Why: an upgrade or deploy should never be what takes the engine down.
  • What to do: fix what it names and systemctl reload ductus again (or systemctl restart ductus to start the new version the plain way). A migration longer than 5 minutes: ductus handover --timeout 30m (as the engine's user or root). A drain refuses a handover too: resume, or stop and start instead.

Deploying project changes: systemctl reload (level 1)

  • What: the reload takes the project as it is on disk now, so changed interface code, a new interface or a changed destination is deployed with no refused connection: pull the project (git pull in /var/lib/ductus/project), then systemctl reload ductus. ductus.env is read again too (the database URL, the environment). A change the running engine can't hand over is refused and named: lowering an interface's lanes (ordering) needs a restart, which checks for messages waiting in the lanes that go.
  • Why: the engine never reloads code in place; a new process starting beside the old one gives the same result with nothing to get half-way wrong.
  • What to do: reload after every project change; ductus validate and ductus test first. Changes to the systemd unit itself (systemctl edit ductus) still need systemctl daemon-reload and a restart. Rolling back is the same: put the previous project (or install the previous package) and reload. Outside the package's service (ductus run by hand) set DUCTUS_CONTROL=/some/dir/control and run ductus handover --control /some/dir/control; ductus dev has no handover.

ductus replay shows what a change would do to real traffic, before you make it (level 1)

  • What: ductus replay --since 24h runs the last day's messages (as received: originals are never changed) through this version's code, every destination, and sends nothing. For each message and destination it compares what the code decides (sent and exactly what, filtered and why, quarantined and why) with three things, whichever exist: the installed version run beside it on the same messages (from the new version's archive, before installing it: the default), what the destination recorded (its status and reason, kept for every message), and what it kept (save_output: true). The report groups differences where they are: HL7 by field ("PID-8 differs in 1,204 messages", with examples), JSON by path, other formats by line; --report replay.html writes a page with the versions, the period, the counts and a checksum on top (for a change board to sign), --json the same as one document. Each message and destination counts as identical (compared whole), same decision (outputs not compared: both sent, nothing to compare the output with), different, drift, not comparable or waiting. Exit 1 when anything differs. For a code change, --baseline main (a Git revision, or a folder) compares the project as it is now with that one, same report. ductus upgrade --check --replay 1d adds it to the upgrade check.
  • Why: the usual evidence for an upgrade or a mapping change is weeks of test-system validation by hand; a replay of production's own traffic answers "what changes?" in minutes, message by message, and says where.
  • What to do: run it from the unpacked archive before an upgrade (./ductus replay --since 24h --env-file /etc/ductus/ductus.env --report replay.html), and with --baseline before deploying a code change. A read-only login or a replica is enough. --limit 1000 samples the first thousand per interface; --interface picks some.

A replay compares outputs with the installed version from 0.6.6.4 on (level 2)

  • What: the installed version takes part by running ductus replay --emit itself, so it must be 0.6.6.4 or later. Before that (and on a server with no installed version: /usr/bin/ductus, C:\Program Files\Ductus\ductus.exe), outputs are compared with kept outputs only, decisions with the record, and the report says so first. A message both sides sent with no output to compare is counted same decision (outputs not compared), never identical: a changed field can't show there. When no output was compared at all, the report ends with "⚠ outputs not compared" and the fix (also outputs_not_compared in --json, and on the HTML page), and upgrade --check --replay shows it as a warning. --against PATH names another binary.
  • Why: comparing with a version run now, on the same messages at the same moment, isolates exactly what the upgrade changes, and needs nothing stored. Without it, a pass by decision alone would read as proof the outputs were checked.
  • What to do: run the replay on the server, where the installed version is, or copy that binary and name it with --against. For the upgrade to 0.6.6.4 itself, keep outputs where you need them compared (save_output: true on the destinations that matter, a while before; it costs ~1.15 GB per million deliveries).

A replay doesn't write: those messages can't be compared (level 3)

  • What: nothing leaves a replay. No destination is connected and routes store nothing. db().query runs (Postgres sessions are read-only, so a query that writes is refused by the server), db().execute and fetch() other than GET and HEAD aren't made: the call fails, and the message is reported "not comparable: writes to database billing: not made in a replay" for that destination, even if the code caught the error and went on. SQL Server has no read-only session setting: a db().query that writes there isn't stopped.
  • Why: a replay of a day must never charge a patient twice or insert a day of rows again.
  • What to do: keep writes in their own destination function where you can, so the other destinations of the interface stay comparable. Use db().query only for reads.

A difference from the record can be the data, not the code: drift (level 3)

  • What: lookups run against the data as it is now, not as it was when the message was sent. When the installed version, run beside, gives the same result as the new one but the record or a kept output differs, the report calls it drift: shown, but it doesn't fail the replay (the upgrade didn't cause it). Without an installed version to compare with, every difference counts, and destinations whose code looked something up are marked "a difference may be the data".
  • Why: both versions see the same lookups at the same moment, so only what they share can come from the data.
  • What to do: read drift as "this interface's results depend on data that changes": expected for lookups, worth a look otherwise.

In a replay the clock is the message's arrival, and Math.random repeats (level 3)

  • What: during a replay new Date() and Date.now() give the time the message was received (and the period's start in a module's top-level code), and Math.random() gives the same sequence for the same message and destination, in every version. new Date(x), Date.UTC and instanceof Date work as always.
  • Why: otherwise every output that carries a timestamp or a random id would differ between two runs, and the report would drown in noise.
  • What to do: nothing for comparisons between versions. A kept output that carries the time it was sent (a few milliseconds after it arrived, or hours later after an outage) differs from the replay in that field: use meta.receivedAt when you mean the message's time.

What a replay costs, and where (level 3)

  • What: one thread runs the code, one message and destination at a time (the other version, beside it, its own thread). Measured in WSL (scripts/bench-replay.sh): 100,000 ADT messages through three destinations (an HL7 mapping, a JSON mapping, the message as is) in 7.5 s alone (13,400 messages/s, ~40,000 function calls/s) and 8.6 s with a second version beside it: a day of 1 million messages takes about 1.5 minutes on such an interface. The database reads the period once, in arrival order, 500 messages a statement. Lookups dominate when an interface makes them: at 1 ms a query, a million messages wait 17 minutes on lookups alone (they run one at a time).
  • Why: both versions must produce their results in the same order to be compared as they stream, without holding a day in memory.
  • What to do: for a quick answer use --limit 1000 (the first thousand per interface) or --interface; run the full day where lookups are few, or against a replica (DUCTUS_DATABASE_URL), outside peak hours when lookups hit production systems.

A replay's report holds patient data unless you hide it (level 2)

  • What: examples show field values from real messages ("PID-5: "DOE^JOHN" → "DOE^JON""). --hide-values leaves values out (field names, counts and message ids stay). The replay reads every payload of the period with a database login, outside the API's permissions and its audit log, like ductus audit export.
  • Why: the values are what make a difference understandable; whoever runs the replay can read the database already.
  • What to do: keep reports with values as you keep patient data; for a change board outside the integration team, make it with --hide-values. Give the replay's login only pg_read_all_data.

A stopped service, a failed start and a drain refuse connections, on purpose (level 2)

  • What: systemctl stop ductus closes the ports (systemd lets go of the sockets when the service stops); a start that fails (the database unreachable, a broken project, another engine owning the interfaces) gives them up too, so while it fails and systemd retries every 5 s senders are refused; a drain closes its MLLP listeners for real.
  • Why: a port that takes connections with no engine behind it would leave senders waiting on ACKs that never come, looking connected. Refused is the honest answer when the engine isn't coming back by itself.
  • What to do: watch for refusals at the senders as before; journalctl -u ductus says why a start failed.

Windows and containers: a restart is a short gap (level 2)

  • What: on Windows the service manager runs one process, so a restart has a gap: Restart-Service Ductus measured 0 refused, a few connections reset and 1.1–1.2 s longest wait for an ACK (Windows itself retries a refused connection for ~1–2 s, so a short gap shows as a wait); an MSI upgrade had the engine down 3.1–3.5 s (5 refused, 4 reset, 3.2 s longest wait, nothing lost). msiexec itself took 100–165 s on a machine where Group Policy turns Restart Manager off, but the engine kept running until its last few seconds (see the Event Log nuance). A container's restart closes its published ports, so senders are refused until the new one listens.
  • Why: passing the listening sockets on Windows needs a resident launcher (planned for v1.0); a new container can't share the old one's ports (Kubernetes rolling upgrades are planned for v1.2).
  • What to do: senders resend on their own; for a planned restart, a quiet moment helps. Upgrading Windows: stop the service first where Restart Manager is off, to save the wait.

Nuances: Windows

What could surprise you here, why it is so, and what to do. Each entry has a level, 1 (day one) to 5 (expert): the index lists them all by level.

The installer isn't signed yet: SmartScreen says "unknown publisher" (level 1)

  • What: before v1.0 the MSI and ductus.exe carry no code-signing (Authenticode) signature. Run from a download, Windows SmartScreen shows "Windows protected your PC" and "unknown publisher"; some endpoint protection blocks unsigned installers outright. A silent install (msiexec /i … /qn) from a deployment tool doesn't show the dialog, but the file is the same.
  • Why: a code-signing certificate is a v1.0 step; until then the builds are development builds.
  • What to do: check the file before running it: its SHA-256 (Get-FileHash ductus-0.6.0-x64.msi) against the release's published checksums, and where GitHub attests the build, gh attestation verify ductus-0.6.0-x64.msi -R thorst/ductus. Then "More info" → "Run anyway", or Unblock-File it first. Ask your endpoint team to allow it by hash rather than by publisher.

The service's account: the default, a gMSA, or a named account (level 2)

  • What: the installer runs the service as NT SERVICE\Ductus (a virtual account: no password, and on the network it is the computer, HOSPITAL\SERVER01$). msiexec /i ductus.msi SERVICEACCOUNT=HOSPITAL\svc-ductus$ installs it as a group managed service account (gMSA) instead: Windows keeps its password, so nothing secret is ever typed, and upgrades keep it. An account with a password (HOSPITAL\svc-ductus, or .\name on this computer) works too, but the installer can't take its password (installer properties end up in the install log): give it with ductus service account 'HOSPITAL\svc-ductus' (it asks; --password-stdin for scripts). ductus service account with no name says who the service runs as.
  • Why: the account is what SQL Server, file shares and anything else on the network see. The default is enough when Ductus logs in to nothing with Windows; a gMSA gives the engine its own identity without a password anyone has to keep.
  • What to do: lead with the default, or a gMSA when the engine needs its own identity (a DBA's Windows login, a share): one step, kept across upgrades. Use a password account only where a gMSA isn't possible, and plan for the step after each upgrade (below). Changing the account keeps the data folder's permissions working: the service's own SID (NT SERVICE\Ductus) is in its token whatever account runs it, and that is what the permissions name.

An upgrade can't keep an account's password: the service waits for it (level 2)

  • What: an upgrade removes the service and installs it again, so a password given earlier is gone. The installer keeps the account's name, leaves the service stopped, and writes to the Event Log (event 1005) what to run: ductus service account 'HOSPITAL\svc-ductus' --start. The default account and gMSAs have no password and start again as before.
  • Why: Windows doesn't let anyone read a service's password back, and a password can't be an installer property.
  • What to do: in a scripted upgrade, follow msiexec /i ductus.msi /qn with ductus service account NAME --password-stdin --start (the password from your vault on stdin). Or move to a gMSA.

"Log on as a service": the installer grants it, Group Policy can take it away (level 3)

  • What: an account needs the "Log on as a service" right to run a service (the computer's own accounts have it already). The installer and ductus service account grant it on this computer; ductus service account also checks the password with a service logon before changing anything, and refuses an account Group Policy denies ("Deny log on as a service"). In a domain where Group Policy sets "Log on as a service", the next policy refresh (within about 90 minutes, and at every restart) replaces the local list, and a service whose account isn't in the policy's list fails to start with error 1069, "the service did not start due to a logon failure".
  • Why: that right is security policy, and in many hospitals the domain owns it.
  • What to do: where Group Policy manages the right, ask whoever manages it to add the service's account (or a group it's in) to "Log on as a service" for the Ductus servers before the upgrade or account change. ductus service account shows whether the account has the right itself and whether it is denied.

An upgrade starts the service again, unless it was stopped on purpose (level 2)

  • What: an upgrade stops the service, replaces the program and starts it again when it's configured (ductus.env exists). A service someone stopped (services.msc, Stop-Service) stays stopped: a stop asked of the service leaves a stopped file in C:\ProgramData\Ductus\logs, the installer looks for it, and the next start removes it. Windows shutting down leaves none, so a server restarted before an upgrade still starts.
  • Why: an engine stopped for a reason (a receiver's outage, a migration) shouldn't come back because a new version was installed.
  • What to do: nothing; start it yourself when you're ready. To have an upgrade start a stopped service, start it first (or delete the stopped file). A service killed rather than stopped (Task Manager) leaves no file and an upgrade starts it.

What goes to the Event Log, and what only to the files (level 2)

  • What: the service writes to the Application log, source Ductus, with its own text in Event Viewer: 1000 started (version, project, the database without its login), 1001 stopped cleanly, 1002 stopped before finishing the messages in hand, 1003 could not start (the reason), 1004 the last run didn't stop cleanly, 1005 the account needs its password, 1006 stopped on an error, 1007 the account was changed (by whom), 1010 an interface stopped by the runaway guard, 1020 a critical alert fired, and 1100 any other ERROR line, each kind and target (interface, destination, alert) at most once a minute, the next one saying "and N more like it since 14:02". Warnings and information otherwise stay in the files (C:\ProgramData\Ductus\logs, 14 days).
  • Why: the Event Log is what Windows tools watch (SCOM, a Splunk forwarder, Event Viewer); a flapping receiver logging an error every second would bury everything else there.
  • What to do: point your monitoring at source Ductus, events 1002–1006, 1010, 1020 and 1100; read the files for the detail. Only the service writes events (ductus run in a terminal doesn't). Uninstalling removes the source, so older events then show "the description cannot be found".
  • Upgrades: the text lives in ductus.exe, so a program that has shown our events (Event Viewer left open, a script's Get-WinEvent, a monitoring agent that renders messages) keeps ductus.exe open, and an upgrade or uninstall waits for it: measured 152 s instead of 1 s on a machine where Group Policy turns Restart Manager off (DisableAutomaticApplicationShutdown). On that machine an upgrade over a running service waited as long too (~2.5 min; over a stopped one, none): Windows Installer checks for files in use before it stops services. The upgrade finishes either way. Close Event Viewer before upgrading; to save the wait, stop the service first (Stop-Service Ductus, then Start-Service after: a service you stopped stays stopped). Where Restart Manager is on (Windows's default), there's no wait for the service, but Windows closes any other program running ductus.exe (a ductus sink or gen left open in a terminal), together with the terminal it runs in.

The Windows service can only touch what you give it (level 2)

  • What: the service runs as the virtual account NT SERVICE\Ductus (or the account you gave it), which can read C:\ProgramData\Ductus (settings, project) and write its logs there, and nothing else. A file or SFTP interface that moves, deletes or writes files outside it (an inbox on D:\Interfaces, an archive folder, an output share) fails with "access denied" until you allow it. Settings (ductus.env) hold the database password, so only administrators, SYSTEM and the service can read that folder.
  • Why: a service that can write anywhere turns one bad interface into a problem for the whole server.
  • What to do: grant the service the folders interfaces use, as narrowly as you can: icacls "D:\Interfaces" /grant "NT SERVICE\Ductus:(OI)(CI)M" (that works whatever account runs the service: its own SID is in its token). For a network share, grant the computer account (DOMAIN\SERVER$), since a virtual account authenticates as the machine, or the gMSA when the service runs as one. ductus doctor in a terminal runs as you, not as the service, so it can pass where the service fails: the service's log (C:\ProgramData\Ductus\logs) shows the "access denied".

A refused connection takes about a second on Windows, and localhost may be ::1 first (level 3)

  • What: when nothing listens at an address, Linux says "refused" at once; Windows sends the connection request again, half a second apart, before it says so (~2 s by default). The engine's MLLP destinations, SQL Server connections and doctor's checks ask Windows for two retries instead: refused in ~1 s, and a request lost on the way is still sent twice more (given up after ~7 s). Other connections (HTTP destinations, fetch(), api(), SFTP, LDAP, mail) keep Windows's ~2 s. A name with several addresses is tried one address at a time, and localhost is ::1 before 127.0.0.1: a receiver listening on 127.0.0.1 only costs that second at every new MLLP connection (once per reconnect, since connections stay open).
  • Why: Windows treats a refusal like a lost packet (a busy server may refuse and accept a moment later). No retries at all would make one lost packet a failed connection; measured on Windows 11: refused 2.03 s by default, ~1 s with two retries.
  • What to do: name the receiver by the address it listens on (127.0.0.1, not localhost, for an IPv4-only receiver on the same server). Keep an MLLP destination's timeout_ms above ~2 s on Windows so a reconnect fits in it. Doctor tells a refused port ("refused: ask its owner whether it's up") from dropped traffic ("no answer: a firewall") as on Linux.

Certificate files: write the drive (level 1)

  • What: in resources.yaml, cert_file, key_file and ca_file with a drive or a share (C:\certs\mllp.pem, \\vault\certs\mllp.key) are read as written; anything else is relative to the project folder. A path written for Linux (/etc/ductus/mllp.key) has no drive, so Windows reads it from the project's drive (C:\etc\ductus\mllp.key): ductus validate warns and says where.
  • Why: that is what a path without a drive means on Windows.
  • What to do: write the whole path, or keep the files in the project and write them relative to it. In YAML, put Windows paths in single quotes ('C:\certs\mllp.pem') or none: in double quotes a backslash starts an escape. The service account must be able to read them (above).

Writing interfaces for the shared pool

By default, interfaces run in a shared pool. A few worker threads (one per CPU core) each run one JavaScript engine (V8 isolate), and that engine hosts many interfaces' code. This is how a server can run 1,000+ interfaces without 1,000+ copies of everything.

Interfaces in the same isolate share one JavaScript global scope. Ordinary interface code is already safe; this page explains the three things that are not, and how to avoid them.

ductus validate warns about all three (codes DX1–DX3 below).

What's already safe

Your module's variables are private. Each interface file is an ES module. Top-level const, let, function and class declarations belong to that module only. Two interfaces can both declare let count = 0 and never see each other's value.

// interfaces/adt-in/transform.ts
let processed = 0;                 // private to adt-in: safe
const facilityMap = new Map([["A", "HOSP-A"]]);

export function transform(msg: Message) {
  processed++;
  ...
}

Typos can't create globals. Modules run in strict mode, so assigning to an undeclared name (totl = 1 instead of total = 1) throws an error instead of silently creating a global variable. The message is quarantined with the error, and the bug shows up right away.

Each message gets fresh objects. The msg passed to transform is new for every message. Nothing carries over between messages unless you store it yourself.

What to avoid

DX1: Writing to the global scope

globalThis.lastMrn = msg.PID[3][1];          // ✗ every interface in the pool sees this
(globalThis as any).cache = new Map();       // ✗
Object.assign(globalThis, { helper });       // ✗

Another interface using the same name would read or overwrite your value. Use a module-level variable instead; it's just as persistent and private to you:

let lastMrn = "";                            // ✓ module scope
const cache = new Map<string, string>();     // ✓

DX2: Changing built-in prototypes

String.prototype.shout = function () { ... };   // ✗ changes strings for every interface
Array.prototype.last = ...;                     // ✗

Write a plain function instead:

const shout = (s: string) => s.toUpperCase();   // ✓

DX3: Mutable state in a shared library file

A file outside any interface folder (for example lib/patient.ts) that several interfaces import is loaded once per isolate, and every importing interface shares that one instance. A let or a mutable object at its top level is shared state:

// lib/counters.ts
let total = 0;                               // ✗ shared by every interface that imports this
export const bump = () => ++total;

Keep shared libraries pure: export constants and functions that work only on their arguments.

// lib/patient.ts
export const normalizeMrn = (mrn: string) => mrn.replace(/^0+/, "");   // ✓
export const SEX_CODES = Object.freeze({ M: "male", F: "female" });     // ✓ frozen constant

If an interface needs a cache, create it in the interface's own module and pass it to the library function.

Note: const alone doesn't make an object immutable (const m = new Map() can still be modified). DX3 flags only top-level let/var; mutating a const Map or object in a shared library is still shared state. Treat shared libraries as read-only.

Endless loops and memory blow-ups

Interfaces on one thread take turns, so code that never pauses would hold up all of them. The engine stops it:

  • Time: each run of your code may last timeout without pausing (default 5s; set it in interface.yaml, e.g. timeout: 500ms). Waiting on db() or fetch() doesn't count. Past it, the code is stopped and the message quarantined (transform stopped: the script ran longer than its 5s timeout…). The other interfaces on the thread waited at most that long.
  • Memory: each isolate has a heap limit (isolates.heap_mb in ductus.yaml, default 512). Code that fills it is stopped and its message quarantined; the process keeps running.
  • Stopped interface: three timeouts in a row, or one heap overflow, stop the interface. New messages are still received and stored; they wait (pending) until the code is fixed and the engine restarted. The log says interface stopped with the reason.

A loop in a shared library file, resumed after an await, can't be traced to one interface (no interface file is on the stack): every transform running on that thread is retried. Keep heavy work in the interface's own files, or give the interface its own isolate. All of these, and more, are in the nuances.

When to use a dedicated isolate

Give an interface its own threads and isolate when it:

  • carries very high volume (for example a large Epic ADT feed) and needs parallel lanes,
  • is critical enough that you want it fully separated from other code, or
  • uses third-party code you can't vouch for.
# interface.yaml, in part
ordering: { key: PID-3.1 }   # lanes for the workers to share
isolation: dedicated
workers: 8

Dedicated interfaces never share a global scope with anything else.

Database schema

What Ductus keeps in PostgreSQL, for transparency (and for DBAs tuning the server). This is not an API. Tables and columns can change in any release; read and change data through the REST API (/api/v1) and the ductus commands, which keep working across versions. Queries written against these tables may break on upgrade.

Changes only ever add (migrations in crates/ductus-store/migrations, see architecture.md invariant 4), so an older engine still runs on a newer schema: that's what makes rollback safe. A test checks that every table and column is described here.

Statuses (status columns): 0 pending, 1 sent, 2 filtered, 3 quarantined, 4 duplicate (messages only).

messages

Every message received, as received (the original is never changed). Partitioned by retention tier, then by time (migration 0010): messages_t21 holds the 3-week tier (21 days), and its partitions cover a period each, named by their first day (messages_t21_20261005): a day for the 1w tier, a week (from Monday, midnight UTC) for 3w and 7w, about 24 partitions in all. They're created 3 days ahead; a partition is dropped once its whole period is older than its tier's days (see retention).

The held area (migration 0014) is tier 0: messages_held, partitioned by month (messages_held_202609, bounds at midnight UTC). When a partition expires with messages someone still needs (pending, or quarantined for an interface that keeps them), those move here with their deliveries, and the partition drops. A month that's over is dropped once nothing in it is unfinished. The held tables are created when first needed.

columntype
idbigintFrom a sequence: arrival order within an interface. With received_at, the primary key (a partitioned table's key must include the partition column).
received_attimestamptzWhen it was stored.
interfacetextThe interface's name.
statussmallintOverall status: pending until every destination is done; then quarantined if any destination quarantined it, else sent if any sent it, else filtered.
partition_keyintegerThe lane, from the ordering key (ductus_core::partition_of).
ordering_keytextThe ordering key's value (e.g. the MRN), if the interface has one.
control_idtextMSH-10 for HL7, when present.
payloadbyteaThe message as received.
attemptsintegerUsed before status per destination (v0.5); attempts are now counted per delivery. Kept for older engines; removed when the migrations are squashed before release.
last_errortextWhy it was quarantined or filtered (destination: reason; ... with several destinations).
updated_attimestamptzLast status change.
origin_idbigintRouted from another interface (source: internal): the id of the message there. NULL for messages received from outside. Not indexed.
tiersmallintThe retention tier the row is stored in, in days (7, 21, 49: 1w, 3w, 7w): its interface's retention when it was stored, until a retention change moves it. 49 for rows stored by engines that don't know tiers. 0: in the held area.

Indexes: primary key (id, received_at, tier) (a partitioned table's key must include its partition columns); messages_pending (interface, partition_key, id) WHERE status = 0; messages_by_interface (interface, id) (the message browser, newest first); messages_quarantined (interface, id) WHERE status = 3.

deliveries

One row per queued message and destination: each destination's work queue and its status. Inserted in the same statement as the message. Partitioned like messages (deliveries_t21_20261005, same tier and bounds).

columntype
message_idbigintmessages.id.
received_attimestamptzmessages.received_at (finds the partition).
destination_idintegerdestinations.id.
partition_keyintegerThe message's lane (copied, so the queue index needs no join).
statussmallintThis destination's status.
attemptsintegerFailed attempts so far.
last_errortextLast failure or the reason it was filtered/quarantined.
outputbyteaExactly what was sent, only for destinations with save_output: true.
updated_attimestamptzLast change.
tiersmallintAlways its message's tier (a retention change, or the move to the held area, moves both in one statement).

Indexes: primary key (message_id, received_at, destination_id, tier); deliveries_queue (destination_id, message_id, partition_key) WHERE status = 0, which is each destination's queue in arrival order (the lane is checked inside the index; migration 0011 replaced deliveries_pending, which had the lane second); deliveries_quarantined (destination_id, message_id) WHERE status = 3 (quarantine counts without reading the table).

destinations

Every destination the engine has been configured with. Deliveries refer to it by id (a small integer instead of repeating names in every row).

columntype
idintegerIdentity.
interfacetextThe interface's name.
nametextThe destination's name (default for a single destination:).

Indexes: primary key id; unique (interface, name).

dedupe_keys

Keys of recently seen messages, for interfaces with dedupe:. Claimed in the same transaction as the message insert. Old keys are deleted hourly.

columntype
interfacetextThe interface's name.
keytextThe message's key values, joined.
seen_attimestamptzLast time it was seen (a repeat outside the window renews it).

Indexes: primary key (interface, key); seen_at (for the hourly cleanup).

source_state

What sources remember between runs, always written in the same transaction as the messages it accounts for: file progress (file:<name>: size, time, byte offset, records, done) and schedules (schedule: anchor, previous run).

columntype
interfacetextThe interface's name.
keytextWhat it is (file:lab.csv, schedule).
valuejsonbThe state.
updated_attimestamptzLast change.

Index: primary key (interface, key).

api_tokens

REST API tokens (ductus token create, or POST /api/v1/tokens). Only a hash of each token is kept; the token itself is shown once. Revoked tokens stay, for the record.

columntype
idintegerIdentity.
nametextWho or what uses it (ci, monitoring). One live token per name.
rolesmallint0 viewer (reads), 1 operator (also drain and connection checks), 2 admin (also tokens).
hashbyteaSHA-256 of the token (tokens are 32 random bytes, so a slow password hash adds nothing).
created_attimestamptzWhen it was made.
last_used_attimestamptzLast use, to the minute (written at most once a minute per token).
revoked_attimestamptzWhen it was revoked; it stops working at once.
user_idintegerThe user it belongs to (users.id); none for a service token made by an admin. A user's token stops working when the user is disabled and never goes above the user's role or permissions.
permissionstext[]Extra permissions (payloads, audit); admins have all.

Indexes: primary key id; unique hash; api_tokens_live_name (name) WHERE revoked_at IS NULL (unique); api_tokens_user (user_id).

users

People who sign in (ductus user create, or POST /api/v1/users). Passwords are argon2id hashes; no password is ever stored or logged. Accounts are disabled, never deleted: the audit trail names them.

columntype
idintegerIdentity.
usernametextSign-in name; not case sensitive (unique on lower(username)).
display_nametextFor people reading the UI.
emailtextWhere alerts for this person go.
rolesmallint0 viewer, 1 operator, 2 admin (as tokens).
permissionstext[]payloads (may read message payloads), audit (may read the audit log). Admins have both.
sourcetextWhere the password is checked: local, or ldap (a directory; no hash kept).
password_hashtextargon2id, a self-describing string; null when the password lives elsewhere.
must_change_passwordbooleanSet when an admin made or reset the password: the next sign-in can do nothing but change it.
disabled_attimestamptzWhen it was disabled: sessions and tokens stop at once.
failed_loginsintegerWrong passwords counted within the lockout window.
last_failed_attimestamptzThe latest wrong password (starts a new window when the last one is over).
locked_untiltimestamptzPast the limit, the account is refused until then.
created_attimestamptz
last_sign_in_attimestamptz
directory_checked_attimestamptzldap accounts: when the directory was last asked about them (each sign-in, and every auth.ldap.refresh while they have a session or a token).
directory_refused_attimestamptzldap accounts: when the directory said no (in no Ductus group, disabled or deleted there). The account can't act (sessions ended, tokens stopped), like a disabled one, until a directory check says yes again; separate from disabled_at, which is an admin's decision here.

| directory_id | text | ldap accounts: the person's id in the directory, <attribute>:<value> (entryUUID:…, or objectGUID:hex:… for Active Directory's 16 bytes; auth.ldap.attributes.id). Set at the first sign-in or refresh that reads it (null before 0.6.7.1, and with id: none). A refresh looks the person up by it. |

Directory accounts (source = 'ldap') are made at their first sign-in; their role, permissions, display name and email are the directory's, rewritten at every sign-in and refresh (migration 0022 added the two directory_ columns). An account is the directory's person once its directory_id is known (0.6.7.1, migration 0025): when the directory renames their sign-in name, username takes the new one; when it gives a departed person's sign-in name to someone new, the earlier account is renamed <name>#<id> (its own id) and refused, so its tokens stay dead, and the new person gets an account of their own.

Indexes: primary key id; unique users_username (lower(username)); unique users_directory_id (directory_id) WHERE directory_id IS NOT NULL.

user_names

What an account was called before (0.6.7.1, migration 0025): every change of its username, display name or email (an admin's edit, the directory's at a sign-in or refresh, a name moved aside) keeps the old value here, in the same transaction as the change. Kept as long as the account. Empty old values aren't kept.

columntype
idbigintIdentity.
user_idintegerusers.id (deleted with it).
kindtextusername, email or display_name.
valuetextThe value it had.
untiltimestamptzWhen it changed.

Indexes: primary key id; user_names_value (lower(value)); user_names_user (user_id).

user_settings

A user's own choices, so they follow them to any machine. Today: the theme and the custom colors.

columntype
user_idintegerusers.id. Primary key.
themetextsystem, light, dark, contrast or custom.
colorsjsonbThe five custom colors and the values worked out from them, as the UI saves them.
updated_attimestamptz

api_sessions

Web UI sessions: a browser signs in with a username and password, or a token (POST /api/v1/session), and gets a cookie instead of keeping either. A session acts with its user's or token's role and ends when the user is disabled or the token revoked, when it's been idle too long (12 hours unless auth.session says otherwise), or at most 7 days after sign-in. Expired sessions are deleted when the next one is made.

columntype
idintegerIdentity.
hashbyteaSHA-256 of the cookie's value (32 random bytes).
token_idintegerThe token it signed in with (api_tokens.id); none for a user's session.
user_idintegerThe user it signed in as (users.id); none for a token's session. Exactly one of the two is set.
created_attimestamptzSign-in.
last_seen_attimestamptzLast use, to the minute (written at most once a minute).
expires_attimestamptzWhen it ends at the latest, whatever its use.

Indexes: primary key id; unique hash; api_sessions_expires (expires_at); api_sessions_user (user_id).

message_actions

What people did to messages through the API (POST /api/v1/messages/{id}/resend, /messages/resend, /messages/{id}/edit): one row per message and destination acted on, with the delivery as it was before, so nothing an action changes is lost. A resend puts the delivery back to pending in place (same message, same id); an edit queues a corrected copy (a new message) and marks the original's quarantined delivery to that destination filtered ("replaced by edited copy N"). Not dropped with the messages' partitions: it's the record of who did what (step 8's audit log builds on it; no purge yet).

columntype
idbigintIdentity.
attimestamptzWhen.
actiontextresend, edit or dismiss (a quarantined delivery closed without sending: it became filtered, "dismissed by NAME: reason").
token_idintegerThe API token used (api_tokens.id).
user_idintegerThe person who acted (users.id), when it was one (their token's owner counts).
actortextThe user's or token's name at the time (names can be reused).
reasontextWhy, if given.
message_idbigintThe message acted on (for an edit: the original).
received_attimestamptzIts received_at (with message_id, finds it in its partition while it's kept).
interfacetextIts interface.
destination_idintegerThe destination (destinations.id).
previous_statussmallintThe delivery's status before (as deliveries.status); none if the message had no delivery for that destination.
previous_attemptsintegerIts attempts before (a resend starts them over).
previous_errortextIts last_error before.
previous_outputbyteaWhat was sent before, for destinations with save_output: true (sending again replaces it). A copy of a payload, so it is set to NULL once the action is older than the interface's message retention (the row stays).
copy_idbigintedit: the copy's message id.
audit_idbigintThe audit_log row of the request that made this action (one request, e.g. a bulk resend, makes one audit row and one action per delivery). No foreign key: both are trimmed after audit.keep.

Indexes: primary key id; message_actions_message (message_id); message_actions_copy (copy_id) WHERE copy_id IS NOT NULL; message_actions_audit (audit_id) WHERE audit_id IS NOT NULL; message_actions_outputs (at) WHERE previous_output IS NOT NULL (finds the copies to clear). Rows older than audit.keep (default 6 years) are deleted hourly in batches of 5,000.

audit_log

Who did what, to what, from where, and how it ended. Written in the transaction of the change itself where there is one (a resend, a dismissal, an account change), and before the action where there isn't (a pause, a drain; the action is refused if the row can't be written). Payload reads are written before the payload is returned. Not tied to users or tokens by a foreign key: it must say who acted long after the account is gone. Rows older than audit.keep are deleted hourly in batches of 5,000.

columntype
idbigintIdentity.
attimestamptzWhen.
actor_kindtextuser, token, system (the engine, or someone at the server's command line) or anonymous (a sign-in as a name nobody has; what they typed isn't kept, it may be a password).
actor_idintegerusers.id or api_tokens.id, by actor_kind.
user_idintegerThe person behind it: the user, or the owner of a user's token. What GET /me/activity reads.
actor_nametextThe user's or token's name at the time (a snapshot).
viatextsession (the web UI), token, cli, engine; none for rows made from message_actions when this table was added.
ipinetThe address the connection came from (a proxy's address behind one).
actiontextsession.sign_in, session.failed, session.sign_out, password.change, password.reset, user.create (also a directory account's first sign-in, by directory), user.update, user.disable, user.unlock, user.directory_change (the directory changed an account's role, permissions, name or email, or let it back in), user.directory_refused (the directory said no to an account: its sessions ended), token.create, token.revoke, message.payload.read, message.release (a resend that put back only quarantined deliveries, whether or not it named their destinations), message.replay (one that sent again a delivery that had finished as sent or filtered), message.edit, message.dismiss, destination.pause, destination.resume, engine.drain, engine.resume, retention.change, retention.confirm, doctor.run, audit.export, alert.ack, alert.silence, alert.unsilence, alert.test (a test notification to a channel), engine.start, engine.stop, engine.handover (this engine took over from another: systemctl reload, an upgrade or a deploy; detail.from and detail.to with version, pid and instance, detail.sending_paused_ms), engine.refused (a start refused: another live engine runs some of its interfaces, or its lanes would strand waiting messages, or a handover would lower lanes; detail.reason is owned, lanes or handover_lanes), interface.stopped, interface.partitions_raised (an interface started with more lanes while messages waited: a key's new messages may pass its older ones until those are sent).
subjecttextWhat it was done to: a username, a token name, interface/destination.
interfacetextThe interface it concerns.
message_idbigintThe message, when the request was about exactly one.
outcometextok, denied or failed.
detailjsonbMore, by action: the reason, the messages and destinations of a bulk request, the version and previous run for engine.start. Never a password, token or payload.

Indexes: primary key id; audit_log_at (at); audit_log_actor (actor_kind, actor_id, at); audit_log_user (user_id, at); audit_log_action (action, at); audit_log_interface (interface, at); audit_log_message (message_id).

retention

Each interface's retention as the engine applied it, and the change in progress. Changes run one at a time per interface: a different value configured meanwhile waits in waiting, and so does a shorter one (it deletes messages) until someone confirms it (ductus retention confirm).

columntype
interfacetextThe interface's name.
dayssmallintThe tier its new messages go to (the target of a running change).
changing_fromsmallintSet while a change runs: the tier it's changing from. The interface's rows in other tiers are being moved into days, or deleted when they're past its window, in small batches.
rows_leftbigintRows still to move or delete (counted when the change started, then counted down).
waitingsmallintA configured value not applied yet.
confirmedsmallintA shorter waiting value someone confirmed.
changed_attimestamptzWhen the current value, or the running change, started.
updated_attimestamptzLast change.
purge_quarantinedbooleanquarantined: purge: its quarantined messages go with their expired partition instead of moving to the held area (false, the default: kept until resolved).

Index: primary key interface.

engines

Every run of an engine on this database (migration 0023). While it runs, an engine holds a session advisory lock with lock_key on a connection of its own; that is what "alive" means here (pg_locks), so a crash or power loss needs no timeout to wait out (the server frees the lock with the session; the lock connection asks it to notice a vanished client within ~25 s). A refused start leaves no row. Rows of engines that started more than audit.keep ago, own nothing and aren't alive are deleted by the hourly audit job.

columntype
instancetextThe run's name, as audit_log engine.start/engine.stop carry it in detail.instance.
lock_keybigintIts advisory lock (positive, below 2^62: pg_locks shows it as classid × 2^32 + objid).
hosttextThe server it runs on.
pidintegerIts process id there.
versiontextThe engine's version.
environmenttext--env.
started_attimestamptzWhen it started.
stopped_attimestamptzSet by a clean stop. NULL and not alive: it crashed.

Indexes: primary key instance; unique lock_key.

interface_owners

Which engine runs each interface's workers, file pickup and schedule: one engine per interface across every engine on the database. An engine claims all of its (queued) interfaces at start, before changing anything for them, all or none; a row whose engine isn't alive any more is taken over. Respond-mode HTTP interfaces aren't claimed (no queue). A clean stop deletes its rows.

columntype
interfacetextThe interface's name.
instancetextengines.instance (deleted with it).
sincetimestamptzWhen it was claimed.

Indexes: primary key interface; interface_owners_instance (instance).

interface_applied

The lanes each interface last ran with: ordering.partitions, or 1 without ordering.key. At start, an interface with fewer lanes than recorded (or none recorded) has its waiting deliveries checked: any in lanes it no longer has refuse the start (no worker would read them). More lanes with messages waiting only warn (interface.partitions_raised).

columntype
interfacetextThe interface's name.
lanesintegerIts lanes.
updated_attimestamptzWhen it last changed.

Index: primary key interface.

interface_activity

What monitoring must remember across a restart or a failover: when each interface last received something. Written every few seconds by one task (only the times that changed; a time never moves back).

columntype
interfacetextThe interface's name.
watched_sincetimestamptzWhen an engine first ran the interface: active_by deadlines before it don't count.
last_receivedtimestamptzWhen a message (or a duplicate) last arrived.
updated_attimestamptzLast change.

Index: primary key interface.

destination_activity

Each destination's last send and failure (written like interface_activity), and its pause (written at once).

columntype
destination_idintegerdestinations.id.
last_senttimestamptzWhen a message was last delivered (or routed).
last_errortextThe last failure: a retry's error or a quarantine's reason.
last_error_attimestamptzWhen.
paused_attimestamptzSet while the destination is paused on purpose (ductus pause): nothing is sent or retried.
paused_bytextThe API token that paused it.
pause_reasontextWhy, once someone says (often added after the pause).
reason_bytextThe token that gave the reason.
reason_attimestamptzWhen.
baseline_inflowreal[]What's normal, per hour of the week (168 values, Monday 00:00 local first; NULL for hours not watched yet): messages arriving per minute. Learned at the end of each hour the engine watched for 15 minutes or more (migration 0013).
baseline_send_msreal[]The same for a successful send's mean time, in ms.
updated_attimestamptzLast change.

Index: primary key destination_id.

docs_snapshot

The project's documentation as the company wrote it, for SQL reports and BI tools ("which interfaces have no signed BAA": SELECT name FROM docs_snapshot WHERE kind = 'interface' AND docs->'custom'->>'baa_signed' = 'no'). One row per interface and per resources.yaml entry. The YAML in Git is the source of truth: every engine start makes the table match the project (changed rows are rewritten, entries the project no longer has are removed), so it is as current as the last start. Versioned history comes with versioned releases (v0.6).

columntype
kindtextinterface or resource.
nametextThe interface's name, or the resource as api empi, database patient_db, sftp server lab.
docsjsonbAn interface: {"description", "owner", "custom": {...}, "destinations": {"<name>": {"custom": {...}}}}; a resource: {"custom": {...}}. custom uses the company's field names from docs-schema.yaml; absent values are absent.
updated_attimestamptzWhen the row last changed.

Indexes: primary key (kind, name); docs_snapshot_docs (GIN on docs).

alert_state

One row per thing that can alert: each interface (iface:<interface>), each destination (dest:<interface>/<destination>) and each certificate the engine connects to (cert:<resource>:<host>:<port>) or presents (cert:<name>:<engine's host>:0), made when an engine with alerts: in ductus.yaml first runs it (so it can be silenced before anything fires). Changed by compare-and-set on rev: two engines on one database judge the same state and only one records each change.

columntype
idintegerIdentity; the API's /alerts/{id}.
keytextiface:<interface>, dest:<interface>/<destination> or cert:<resource>:<host>:<port> (unique).
interfacetextThe interface; for a certificate, its resource (as in certificate_status).
destinationtextThe destination; none for the interface's own alert; host:port for a certificate.
statetextok, or what is wrong: error, stalled, behind (a destination), stopped, silent, missed (an interface), certificate (expires within alerts.certificates.warn_days, or has expired).
severitytextwarning or critical (from the first rule naming the state), while open.
sincetimestamptzBad since (when it fired).
ok_sincetimestamptzFine again since then, not yet for alerts.settle: bad again before that reopens the same alert.
last_notified_attimestamptzWhen people were last told, or will be once quiet hours end; reminders count from it.
notify_countintegerNotifications sent since it fired.
summarytextThe health view's words, as of the last check.
ack_bytextWho acknowledged it (no reminders until it escalates or recovers).
ack_attimestamptzWhen.
silenced_untiltimestamptzNothing is sent about it until then.
silenced_bytextWho silenced it.
silence_reasontextWhy.
revbigintEach change adds one (compare-and-set).
updated_attimestamptzLast change.
stageintegerA certificate alert's step: the warn day (30, 14, 7 by default) people were last told about, 0 once expired (migration 0021). None for other alerts.

Indexes: primary key id; unique key.

alert_events

What happened to each alert: fired, escalated, reminded, recovered (the engine) and acknowledged, silenced, unsilenced (people, also in audit_log). Rows older than alerts.keep (default a year) are deleted hourly with their notifications.

columntype
idbigintIdentity.
attimestamptzWhen.
alert_idintegeralert_state.id (no foreign key).
keytextThe alert's key.
kindtextSee above.
statetextThe state then; for recovered, what it recovered from.
severitytextThe severity then.
sincetimestamptzWhen the alert turned bad, for the words of a notification sent later.
summarytextThe health view's words then.
by_nametextWho acknowledged or silenced it.
detailjsonbFor a silence: until and reason.

Indexes: primary key id; alert_events_alert (alert_id, id); alert_events_at (at).

alert_deliveries

The outbox: one row per notification (an event to a channel), stored with the event before anything is sent. Senders claim due rows with FOR UPDATE SKIP LOCKED and a two-minute lease, so two engines send each once; a crash between sending and marking it sent sends it again after the lease (at least once).

columntype
idbigintIdentity.
event_idbigintalert_events.id (deleted with it).
channeltextA channel's name; <name>/email and <name>/webhook for a channel with both; email:<address> for an owner.
statustextpending, sent, failed (given up after 10 tries, about six hours), cancelled (it recovered before quiet hours ended).
attemptsintegerTries so far.
last_errortextWhy the last try failed.
next_try_attimestamptzDue then (after quiet hours, or a back-off of 30 s doubling to an hour); while claimed, the end of the lease.
sent_attimestamptzWhen it went out.

Indexes: primary key id; alert_deliveries_due (next_try_at) WHERE status = 'pending'; alert_deliveries_event (event_id).

certificate_status

The certificate each TLS server the engine connects to presented at its last check (migration 0021): HTTPS APIs and destinations, the alerts' mail server, webhooks and heartbeat. Written by the engine's scheduled checks (doctor.schedule, every 6 hours by default) and by checks someone asks for (POST /api/v1/doctor). Read even when this server doesn't trust it (a private CA). A check that couldn't read a certificate sets error and checked_at and keeps the dates from the check before.

Our own certificates are rows too (migration 0024, side = 'ours'): each certificates: pair the engine presents, to senders on a TLS listener or as a client certificate. They are read from what the engine loaded (no network), written at its start and again only when a renewal is picked up or a renewed pair can't be used.

columntype
sidetextremote (a server the engine connects to) or ours (a pair it presents; migration 0024). Older engines' rows are remote.
resourcetextempi (resources.yaml), alerts.smtp, alerts.<channel> (a webhook), alerts.heartbeat, or <interface>/<destination> for a destination with a URL of its own; ours: the certificates: entry's name.
hosttextThe server's name as configured; ours: the engine's server (its host name).
portintegerIts port; ours: 0 (one pair may be served on several ports).
subjecttextThe server certificate's common name (or organisation).
issuertextWho issued it.
fingerprinttextSHA-256 of the server certificate, hex: changes when it's renewed or replaced.
not_beforetimestamptzValid from.
not_aftertimestamptzThe server certificate's expiry.
chain_min_not_aftertimestamptzThe earliest expiry in the chain the server presents: what alerts count (an intermediate that ends breaks clients as surely).
chain_min_subjecttextWhose that is, when it isn't the server certificate's.
trustedbooleanWhether this server trusts it (the system's trusted certificates); none for ours (whether senders do isn't known here).
errortextWhy the last check failed (couldn't connect, not trusted, expired); none when it passed. Ours: why renewed files can't be used (the pair loaded before is still presented).
checked_attimestamptzThe last check; ours: when the engine last read the pair (its start, or a renewal).
read_attimestamptzWhen the certificate in the row was read: the last check, unless that one couldn't read it (then error says why and the dates are older).

Index: primary key (resource, host, port).

doctor_runs

Each connection check the engine ran on its schedule or was asked for, with its whole report (migration 0021). The last 30 are kept.

columntype
idbigintIdentity.
attimestamptzWhen it ran.
modetextcertificates (DNS, TCP, TLS handshake: nothing logs in) or full (ductus doctor's checks, logins included).
started_bytextschedule, or who asked (a user or token name).
secondsdouble precisionHow long it took.
checksintegerConnections checked.
failedintegerOf those, failed.
reportjsonbThe report, as POST /api/v1/doctor returns it.

Index: primary key id.

Functions

  • ductus_tiers(): the tiers in days, {7,21,49}. ductus_tier_unit(tier): the period one partition of a tier covers (day for 7, else week).
  • ductus_create_tier_partition(table, tier, day): the partition of messages or deliveries holding that tier's rows of that day, created if missing.
  • ductus_create_held_partition(table, day): the held area's partition of messages or deliveries for that day's month (and the held table itself), created if missing.
  • ductus_ensure_tier_partitions(table, tier, days_ahead), ductus_ensure_delivery_partitions(tier): one tier's missing partitions; the engine runs them hourly, one table and tier per transaction, with a short lock timeout (see architecture.md, Storage).
  • ductus_ensure_message_partitions(days_ahead), ductus_ensure_delivery_partitions(), ductus_ensure_partitions(days_ahead): every tier at once, shortest tier first: what older engines call. Removed when the migrations are squashed.

Reference

Everything Ductus takes and gives, listed: every command, API operation, setting, Event Log ID, metric and audit action. These pages are made from the engine itself (ductus reference), so they always match the version they come with. The guides explain how the pieces fit together; the nuances say what could surprise you.

Command line

ductus is one program: the engine (ductus run), the tools around it (validate, test, doctor, replay), and the commands that talk to a running engine through its REST API (status, drain, pause; these need a token, --token or DUCTUS_TOKEN). Every command explains itself with --help; these pages say the same, with every option.

Options can also come from the environment (the variable each one names), or from a file of NAME=value lines given with --env-file, which the Linux packages and the Windows service use (/etc/ductus/ductus.env, C:\ProgramData\Ductus\ductus.env).

CommandWhat it does
ductus initStart a new project: settings, environments, resources and a first interface, ready for ductus test.
ductus runRun every interface in a project.
ductus devRun the project with a Postgres of its own, for local development: nothing to install.
ductus testRun each interface's *.test.ts files.
ductus validateCheck configs, code loading and TypeScript types for an environment (exits 1 on problems).
ductus upgradeBefore an upgrade, run with the new version's binary (from the portable archive, on the server or beside it), reading only: the migrations its start would apply and how big the tables they work through are, the engines on the database and their versions, the project validated against the database and its tests run with the new SDK, deprecated settings in use.
ductus replayRun a period's messages through this version's code again, sending nothing, and compare what each destination's code decides (sent and what, filtered, quarantined) with the installed version run beside it, with what was recorded, and with kept outputs (save_output): the evidence a change board asks for before an upgrade or a code change.
ductus handoverHand the running engine over to the binary now on disk and the project as it is now, refusing no connection (Linux: the package's systemctl reload ductus runs it): an upgrade or a project deploy.
ductus newCreate something new in a project.
ductus portsList the ports and HTTP routes a project listens on, the hosts it connects to, and port conflicts, for an environment (exits 1 on conflicts).
ductus impactWhich interfaces a vendor, application or host touches, how, and who owns them: for a vendor breach or a VPN/IP change.
ductus doctorCheck every connection the project needs, from this server: DNS, TCP (with the address a firewall request needs), TLS (certificate, expiry), then each system's own login.
ductus docsThe company-defined documentation fields (docs-schema.yaml) and their values (custom:) for every interface, destination and resource.
ductus ownersWho owns which interface, destination and resources.yaml entry (about.owner, owner on a destination someone else owns, owner in resources.yaml), and, with a database, whether each owner is an account (username, email or display name), a team in owners: or nobody.
ductus userManage the people who sign in (in the engine's database; that is how the first admin is made).
ductus tokenManage REST API tokens (in the engine's database).
ductus auditThe audit log (who did what), straight from the engine's database.
ductus drainStop taking new messages and deliver what's stored (for upgrades, server moves, config changes that need an empty queue): MLLP listeners close, queued HTTP answers 503, file pickup and schedules pause.
ductus resumeTake new messages again after ductus drain; or, with an interface, resume its paused destination (it sends its backlog in order).
ductus pausePause a destination at once: nothing is sent or retried until ductus resume <interface> [destination]; messages keep queueing.
ductus retentionHow long each interface keeps its messages (retention: in interface.yaml), changes in progress, shorter values waiting for a confirmation, and expired partitions that had to stay.
ductus hotspotsWhere each destination's backlog sits: the lanes messages wait in (the ordering key's hash), the first message of each (key, age, attempts, last error), and which worker a failing message holds up.
ductus alertsAlerts (alerts: in ductus.yaml): what is open, from the running engine's REST API (viewer token); or ductus alerts test <channel>, which sends a test notification straight from ductus.yaml (no engine needed: check the mail server or webhook before going live).
ductus secretManage encrypted secrets (secrets/.json; key in DUCTUS_SECRET_KEY).
ductus certCertificates for tls: on listeners (certificates: in resources.yaml).
ductus serviceThe Windows service (as an administrator).
ductus api-specPrint the REST API's OpenAPI document (the same as GET /api/v1/openapi.json, without a running engine).
ductus typesWrite what an editor needs to help with the project, for the version installed: the SDK's typings (.ductus/types) and a tsconfig.json for the interfaces' code, and the configuration files' schemas (.ductus/schema) with VS Code's settings pointing at them, for completion, descriptions on hover and mistakes underlined in interface.yaml, resources.yaml and ductus.yaml.
ductus statusShow message counts by interface and status (from the database, also while the engine is down); with a token, also each interface's and destination's health from the running engine (silent, behind, stalled, paused, catching up...).
ductus genSend synthetic ADT messages over MLLP.
ductus sinkReceive messages over MLLP, ACK them, and verify per-patient ordering.

Options for every command

What it doesDefaultEnvironment
--env-file <FILE>Read settings from this file (NAME=value lines, the names of the DUCTUS_* variables); variables already set win. Read before anything else, in main.DUCTUS_ENV_FILE

ductus init

Start a new project: settings, environments, resources and a first interface, ready for ductus test. The folder must be empty (a fresh Git clone is fine).

ductus init [OPTIONS] [DIR]

Arguments

What it doesDefaultEnvironment
<DIR>The folder to make the project in (made if missing)..

ductus run

Run every interface in a project.

ductus run [OPTIONS]

Options

What it doesDefaultEnvironment
--root <ROOT>Project folder containing interfaces/..DUCTUS_PROJECT
--database-url <DATABASE_URL>The engine's database, as a PostgreSQL URL: postgres://ductus@db1/ductus?sslmode=verify-full.DUCTUS_DATABASE_URL
--threads <THREADS>Threads in the shared worker pool (default: one per CPU core).DUCTUS_THREADS
--db-connections <DB_CONNECTIONS>Maximum PostgreSQL connections.32DUCTUS_DB_CONNECTIONS
--env <ENV>Environment: variables from environments/.yaml, secrets from secrets/.json.devDUCTUS_ENV
--api-listen <API_LISTEN>Where the REST API listens (host:port, or off), instead of api.listen in ductus.yaml (default 127.0.0.1:7700).DUCTUS_API_LISTEN
--stop-timeout <STOP_TIMEOUT>On Ctrl-C or SIGTERM, how long to finish the messages in hand before exiting anyway (they stay pending for the next start). Service managers should wait longer.30sDUCTUS_STOP_TIMEOUT
--serviceRun as a Windows service, under the service manager (the installer sets this up).

ductus dev

Run the project with a Postgres of its own, for local development: nothing to install. The first run downloads Postgres (once per user); its data lives in .ductus/postgres and it stops with the engine. Prints the connection URL.

ductus dev [OPTIONS]

Options

What it doesDefaultEnvironment
--root <ROOT>Project folder containing interfaces/..DUCTUS_PROJECT
--threads <THREADS>Threads in the shared worker pool (default: one per CPU core).DUCTUS_THREADS
--env <ENV>Environment: variables from environments/.yaml, secrets from secrets/.json.devDUCTUS_ENV
--api-listen <API_LISTEN>Where the REST API listens (host:port, or off).DUCTUS_API_LISTEN
--stop-timeout <STOP_TIMEOUT>On Ctrl-C, how long to finish the messages in hand before exiting anyway.30sDUCTUS_STOP_TIMEOUT
--port <PORT>Postgres's port (default: the one kept from the last run, else a free one).
--resetDelete this project's dev database and start again.
--yesDon't ask before --reset deletes it.
--database-onlyStart only the Postgres, without the engine, and print its URL.

ductus test

Run each interface's *.test.ts files.

ductus test [OPTIONS]

Options

What it doesDefaultEnvironment
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..
--interface <INTERFACE>Only this interface.
--env <ENV>The environment: ${NAME} values come from environments/.yaml, ${secret:NAME} from secrets/.json.devDUCTUS_ENV

ductus validate

Check configs, code loading and TypeScript types for an environment (exits 1 on problems).

ductus validate [OPTIONS]

Options

What it doesDefaultEnvironment
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..
--env <ENV>The environment: ${NAME} values come from environments/.yaml, ${secret:NAME} from secrets/.json.devDUCTUS_ENV
--database-url <DATABASE_URL>Also check the project against this database, reading only: lanes that would strand waiting messages, retention changes still running or waiting for a confirmation.DUCTUS_DATABASE_URL

ductus upgrade

Before an upgrade, run with the new version's binary (from the portable archive, on the server or beside it), reading only: the migrations its start would apply and how big the tables they work through are, the engines on the database and their versions, the project validated against the database and its tests run with the new SDK, deprecated settings in use. Exits 1 on anything that would refuse the start or fail the project. A read-only login is enough (pg_read_all_data).

ductus upgrade [OPTIONS] --check

Options

What it doesDefaultEnvironment
--checkCheck, change nothing (the only mode for now).
--jsonOne JSON document instead of the report.
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..DUCTUS_PROJECT
--env <ENV>The environment: ${NAME} values come from environments/.yaml, ${secret:NAME} from secrets/.json.devDUCTUS_ENV
--database-url <DATABASE_URL>The engine's database, as a PostgreSQL URL: postgres://ductus@db1/ductus?sslmode=verify-full.DUCTUS_DATABASE_URL
--replay <DURATION>Also replay this much of the latest traffic (1d, 12h) through the new version and compare it with the installed one (ductus replay).

ductus replay

Run a period's messages through this version's code again, sending nothing, and compare what each destination's code decides (sent and what, filtered, quarantined) with the installed version run beside it, with what was recorded, and with kept outputs (save_output): the evidence a change board asks for before an upgrade or a code change. Reads only (a read-only login or a replica is enough). Exits 1 when anything differs.

ductus replay [OPTIONS]

Options

What it doesDefaultEnvironment
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..DUCTUS_PROJECT
--env <ENV>The environment: ${NAME} values come from environments/.yaml, ${secret:NAME} from secrets/.json.devDUCTUS_ENV
--database-url <DATABASE_URL>The engine's database, as a PostgreSQL URL: postgres://ductus@db1/ductus?sslmode=verify-full.DUCTUS_DATABASE_URL
--since <SINCE>The period's start: a duration back from its end (24h, 7d) or a time (RFC 3339).24h
--until <UNTIL>The period's end, a time (RFC 3339); default now.
--interface <INTERFACE>Only this interface (repeat it for more).
--limit <LIMIT>At most this many messages per interface, the first in the period: a sample.
--against <AGAINST>What it's compared with besides the record: installed (the version installed on this server, run beside this one: the default), stored (kept outputs only), or the path of another ductus binary.
--baseline <BASELINE>Compare with another version of the project instead: a folder, or a Git revision of this one (main, v12, HEAD~1): the same report for a code change. It runs with this binary unless --against names one.
--report <REPORT>Also write the report to this file: a page a change board can read and sign (.html), or JSON (.json).
--jsonThe report as JSON on stdout instead of text.
--hide-valuesLeave field values out of the examples: the report then holds no patient data.

ductus handover

Hand the running engine over to the binary now on disk and the project as it is now, refusing no connection (Linux: the package's systemctl reload ductus runs it): an upgrade or a project deploy. The new engine starts beside the running one on the same listening sockets; once it's ready, the running one stops and lets go. Exit 0 once the new engine sends; 1 when it couldn't take over (the running engine goes on: the reason is printed).

ductus handover [OPTIONS]

Options

What it doesDefaultEnvironment
--control <CONTROL>The running engine's control socket (default: $RUNTIME_DIRECTORY/control, which systemd sets for the service and its reload, else /run/ductus/control).DUCTUS_CONTROL
--timeout <TIMEOUT>How long the new engine may take to be ready: it migrates the database first.5m
--with-environmentStart the new engine with this command's environment rather than the running engine's: under systemd, ductus.env as it is now (the unit's reload passes this).

ductus new

Create something new in a project.

ductus new [OPTIONS] <COMMAND>

ductus new interface

A working interface (config, code, a test and a sample message) that passes ductus test and ductus validate as generated. MLLP and HTTP get the next free port from the ranges in ductus.yaml (default 6661-6699 and 8080-8099).

ductus new interface [OPTIONS] <NAME>

Arguments

What it doesDefaultEnvironment
<NAME>Folder name and interface name, e.g. adt-to-lab.

Options

What it doesDefaultEnvironment
--source <SOURCE>Where its messages come from.mllp
--format <FORMAT>Default: hl7 (json for http and schedule).
--port <PORT>Listen on this port instead of the next free one.
--server <SERVER>SFTP server from resources.yaml (needed when there are several).
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..

ductus ports

List the ports and HTTP routes a project listens on, the hosts it connects to, and port conflicts, for an environment (exits 1 on conflicts).

ductus ports [OPTIONS]

Options

What it doesDefaultEnvironment
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..
--env <ENV>The environment: ${NAME} values come from environments/.yaml, ${secret:NAME} from secrets/.json.devDUCTUS_ENV

ductus impact

Which interfaces a vendor, application or host touches, how, and who owns them: for a vendor breach or a VPN/IP change. Reads about:, destinations, resources.yaml, and the databases and APIs code uses.

ductus impact [OPTIONS] <--vendor <VENDOR>|--application <APPLICATION>|--host <HOST>>

Options

What it doesDefaultEnvironment
--vendor <VENDOR>e.g. "Epic Systems" (ignores case).
--application <APPLICATION>e.g. "Sunquest Lab" (ignores case).
--host <HOST>An address, subnet (10.20.30.0/24) or host name.
--resolveLook host names up in DNS, so names in the config match addresses (and the other way round).
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..
--env <ENV>The environment: ${NAME} values come from environments/.yaml, ${secret:NAME} from secrets/.json.devDUCTUS_ENV

ductus doctor

Check every connection the project needs, from this server: DNS, TCP (with the address a firewall request needs), TLS (certificate, expiry), then each system's own login. Explains failures in plain words. Never sends anything to a destination. Exits 1 if something failed.

ductus doctor [OPTIONS]

Options

What it doesDefaultEnvironment
--interface <INTERFACE>Only what these interfaces use (repeat for several); the engine's database always.
--timeout <TIMEOUT>Longest wait per step, e.g. 5s or 500ms.5s
--report <REPORT>Also save the report (for a network team or vendor): JSON if the name ends in .json.
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..
--env <ENV>The environment: ${NAME} values come from environments/.yaml, ${secret:NAME} from secrets/.json.devDUCTUS_ENV
--database-url <DATABASE_URL>The engine's database, as a PostgreSQL URL: postgres://ductus@db1/ductus?sslmode=verify-full.DUCTUS_DATABASE_URL

ductus docs

The company-defined documentation fields (docs-schema.yaml) and their values (custom:) for every interface, destination and resource. Exits 1 if a required value is missing.

ductus docs [OPTIONS]

Options

What it doesDefaultEnvironment
--missingOnly what's missing.
--jsonAs JSON, for a report or another tool.
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..
--env <ENV>The environment: ${NAME} values come from environments/.yaml, ${secret:NAME} from secrets/.json.devDUCTUS_ENV

ductus owners

Who owns which interface, destination and resources.yaml entry (about.owner, owner on a destination someone else owns, owner in resources.yaml), and, with a database, whether each owner is an account (username, email or display name), a team in owners: or nobody. ductus owners reassign moves ownership.

ductus owners [OPTIONS]
       ductus owners <COMMAND>

Options

What it doesDefaultEnvironment
--checkFail (exit 1) if an owner is no account, team or address, a disabled account, or names several accounts. Needs the database.
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..
--env <ENV>The environment: ${NAME} values come from environments/.yaml, ${secret:NAME} from secrets/.json.devDUCTUS_ENV
--database-url <DATABASE_URL>Where the accounts are (without it, owners aren't matched to them).DUCTUS_DATABASE_URL

ductus owners reassign

Change an owner in interface.yaml files and resources.yaml (someone left, or work is split up): only the owner: values change, comments and layout stay. To split, run it per group with --interface and --resource.

ductus owners reassign [OPTIONS] --from <FROM> --to <TO>

Options

What it doesDefaultEnvironment
--from <FROM>The current owner (ignores case).
--to <TO>The new owner.
--interface <INTERFACE>Only these interfaces (repeat for several). With neither this nor --resource, everything of --from moves.
--resource <RESOURCE>Only these resources.yaml entries (repeat for several).
--dry-runShow what would change without writing.
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..
--env <ENV>The environment: ${NAME} values come from environments/.yaml, ${secret:NAME} from secrets/.json.devDUCTUS_ENV

ductus user

Manage the people who sign in (in the engine's database; that is how the first admin is made). Roles: viewer reads, operator also drains, resends and runs connection checks, admin also manages users and tokens. Permissions beyond a role: payloads (read message payloads), audit.

ductus user [OPTIONS] <COMMAND>

ductus user create

Make an account. The password is asked for twice (or read from stdin with --password-stdin).

ductus user create [OPTIONS] <USERNAME>

Arguments

What it doesDefaultEnvironment
<USERNAME>The name they sign in with.

Options

What it doesDefaultEnvironment
--role <ROLE>viewer, operator or admin.viewer
--display-name <DISPLAY_NAME>Their name as others see it (in the web UI and the audit log).
--email <EMAIL>Their email address: how owner texts (about.owner) and alerts reach them.
--permission <PERMISSIONS>Beyond the role: payloads, audit. Repeat for both.
--password-stdinRead the password from stdin (one line) instead of asking.
--must-changeThe person must choose their own password at their first sign-in.

ductus user list

List users.

ductus user list [OPTIONS]

ductus user set

Change a user's role and permissions (all of them are set to what is given).

ductus user set [OPTIONS] <USERNAME>

Arguments

What it doesDefaultEnvironment
<USERNAME>The account's sign-in name.

Options

What it doesDefaultEnvironment
--role <ROLE>The new role: viewer, operator or admin (none given: leave it).
--permission <PERMISSIONS>The permissions the user has afterwards (none given: leave them as they are).

ductus user password

Give a user a new temporary password (they must change it at their next sign-in).

ductus user password [OPTIONS] <USERNAME>

Arguments

What it doesDefaultEnvironment
<USERNAME>The account's sign-in name.

Options

What it doesDefaultEnvironment
--password-stdinRead the password from stdin (one line) instead of asking.

ductus user disable

Stop a user signing in; their sessions and tokens stop at once.

ductus user disable [OPTIONS] <USERNAME>

Arguments

What it doesDefaultEnvironment
<USERNAME>The account's sign-in name.

ductus user enable

Let a disabled user sign in again.

ductus user enable [OPTIONS] <USERNAME>

Arguments

What it doesDefaultEnvironment
<USERNAME>The account's sign-in name.

ductus user unlock

Let a user locked out by wrong passwords try again.

ductus user unlock [OPTIONS] <USERNAME>

Arguments

What it doesDefaultEnvironment
<USERNAME>The account's sign-in name.

ductus token

Manage REST API tokens (in the engine's database). Roles: viewer reads, operator also drains and runs connection checks, admin also manages users and tokens. A token can't read message payloads unless it is given --permission payloads.

ductus token [OPTIONS] <COMMAND>

ductus token create

Create a token and print it (only this once: it's stored as a hash).

ductus token create [OPTIONS] <NAME>

Arguments

What it doesDefaultEnvironment
<NAME>Who or what uses it, e.g. ci or monitoring.

Options

What it doesDefaultEnvironment
--role <ROLE>viewer, operator or admin.viewer
--permission <PERMISSIONS>Beyond the role: payloads (read message payloads), audit. Repeat for both.

ductus token list

List tokens (never their secrets).

ductus token list [OPTIONS]

Options

What it doesDefaultEnvironment
--revokedInclude revoked tokens.

ductus token revoke

Revoke a token: it stops working at once.

ductus token revoke [OPTIONS] <NAME>

Arguments

What it doesDefaultEnvironment
<NAME>The token's name, as ductus token list shows it.

ductus audit

The audit log (who did what), straight from the engine's database.

ductus audit [OPTIONS] <COMMAND>

ductus audit export

Print the log as one JSON object per line, oldest first (for a SIEM or an auditor). The export is itself logged.

ductus audit export [OPTIONS]

Options

What it doesDefaultEnvironment
--since <SINCE>From this day (2026-01-31, UTC) or time (2026-01-31T08:00:00Z).
--until <UNTIL>Until this day or time (exclusive).
--actor <ACTOR>A user's or token's name.
--action <ACTION>An action (message.dismiss) or a group (message, user, session).
--interface <INTERFACE>Only what concerns this interface.
--message <MESSAGE>A message id.

ductus drain

Stop taking new messages and deliver what's stored (for upgrades, server moves, config changes that need an empty queue): MLLP listeners close, queued HTTP answers 503, file pickup and schedules pause. Through the running engine's REST API.

ductus drain [OPTIONS]

Options

What it doesDefaultEnvironment
--stopExit the engine once nothing is pending.
--waitWait until drained, showing what's left.
--api <API>The engine's REST API.http://127.0.0.1:7700DUCTUS_API_URL
--token <TOKEN>An operator token (ductus token create <name> --role operator).DUCTUS_API_TOKEN

ductus resume

Take new messages again after ductus drain; or, with an interface, resume its paused destination (it sends its backlog in order).

ductus resume [OPTIONS] [INTERFACE] [DESTINATION]

Arguments

What it doesDefaultEnvironment
<INTERFACE>Resume this interface's paused destination (instead of ending a drain).
<DESTINATION>Which destination (needed when the interface has several).

Options

What it doesDefaultEnvironment
--api <API>The engine's REST API.http://127.0.0.1:7700DUCTUS_API_URL
--token <TOKEN>An operator token (ductus token create <name> --role operator).DUCTUS_API_TOKEN

ductus pause

Pause a destination at once: nothing is sent or retried until ductus resume <interface> [destination]; messages keep queueing. Act first, explain after: the reason is optional, and running it again with --reason adds one. Through the running engine's REST API.

ductus pause [OPTIONS] <INTERFACE> [DESTINATION]

Arguments

What it doesDefaultEnvironment
<INTERFACE>The interface whose destination to pause.
<DESTINATION>Which destination (needed when the interface has several).

Options

What it doesDefaultEnvironment
--reason <REASON>Why (can be added later).
--api <API>The engine's REST API.http://127.0.0.1:7700DUCTUS_API_URL
--token <TOKEN>An operator token (ductus token create <name> --role operator).DUCTUS_API_TOKEN

ductus retention

How long each interface keeps its messages (retention: in interface.yaml), changes in progress, shorter values waiting for a confirmation, and expired partitions that had to stay. Through the running engine's REST API.

ductus retention [OPTIONS] [COMMAND]

Options

What it doesDefaultEnvironment
--api <API>The engine's REST API.http://127.0.0.1:7700DUCTUS_API_URL
--token <TOKEN>An operator token (ductus token create <name> --role operator).DUCTUS_API_TOKEN

ductus retention confirm

Apply an interface's shorter retention: it deletes the messages past it (operator token).

ductus retention confirm [OPTIONS] <INTERFACE>

Arguments

What it doesDefaultEnvironment
<INTERFACE>The interface whose shorter retention to apply.

ductus hotspots

Where each destination's backlog sits: the lanes messages wait in (the ordering key's hash), the first message of each (key, age, attempts, last error), and which worker a failing message holds up. Through the running engine's REST API (viewer token).

ductus hotspots [OPTIONS] [INTERFACE]

Arguments

What it doesDefaultEnvironment
<INTERFACE>Only this interface.

Options

What it doesDefaultEnvironment
--lanes <LANES>Lanes to list per destination, most waiting first (default 10).
--api <API>The engine's REST API.http://127.0.0.1:7700DUCTUS_API_URL
--token <TOKEN>An operator token (ductus token create <name> --role operator).DUCTUS_API_TOKEN

ductus alerts

Alerts (alerts: in ductus.yaml): what is open, from the running engine's REST API (viewer token); or ductus alerts test <channel>, which sends a test notification straight from ductus.yaml (no engine needed: check the mail server or webhook before going live).

ductus alerts [OPTIONS] [COMMAND]

Options

What it doesDefaultEnvironment
--api <API>The engine's REST API.http://127.0.0.1:7700DUCTUS_API_URL
--token <TOKEN>An operator token (ductus token create <name> --role operator).DUCTUS_API_TOKEN

ductus alerts test

Send a test notification to a channel now, from ductus.yaml in this environment.

ductus alerts test [OPTIONS] <CHANNEL>

Arguments

What it doesDefaultEnvironment
<CHANNEL>A channel's name under alerts.channels in ductus.yaml.

Options

What it doesDefaultEnvironment
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..
--env <ENV>The environment: ${NAME} values come from environments/.yaml, ${secret:NAME} from secrets/.json.devDUCTUS_ENV

ductus secret

Manage encrypted secrets (secrets/.json; key in DUCTUS_SECRET_KEY).

ductus secret [OPTIONS] <COMMAND>

ductus secret init

Print a new random key to put in DUCTUS_SECRET_KEY (keep it out of Git).

ductus secret init [OPTIONS]

ductus secret set

Create or update a secret. The value is read from stdin if not given.

ductus secret set [OPTIONS] <NAME> [VALUE]

Arguments

What it doesDefaultEnvironment
<NAME>The secret's name, as config files use it: ${secret:NAME}.
<VALUE>The value (read from stdin when not given, which keeps it out of the shell's history).

Options

What it doesDefaultEnvironment
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..
--env <ENV>The environment: ${NAME} values come from environments/.yaml, ${secret:NAME} from secrets/.json.devDUCTUS_ENV

ductus secret list

List secret names (never values).

ductus secret list [OPTIONS]

Options

What it doesDefaultEnvironment
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..
--env <ENV>The environment: ${NAME} values come from environments/.yaml, ${secret:NAME} from secrets/.json.devDUCTUS_ENV

ductus cert

Certificates for tls: on listeners (certificates: in resources.yaml).

ductus cert [OPTIONS] <COMMAND>

ductus cert request

A new key and a certificate signing request (CSR) for the hospital's CA: writes OUT.key (private, never overwritten) and OUT.csr, and says what to send, what comes back and where it goes.

ductus cert request [OPTIONS] <OUT>

Arguments

What it doesDefaultEnvironment
<OUT>Where to write, without the extension: tls/mllp writes tls/mllp.key and tls/mllp.csr.

Options

What it doesDefaultEnvironment
--dns <DNS>A DNS name senders connect to (repeat for several; the first is the CN).
--ip <IPS>An IP address senders connect to (repeat for several).
--subject <SUBJECT>The rest of the subject, as the CA wants it: "O=General Hospital, OU=Interfaces, C=US".
--key <KEY>The key: rsa (2048, what every CA takes) or ecdsa (P-256).rsa

ductus cert show

What a certificate file or a certificates: entry holds: subject, names, issuer, the chain's order and completeness, days left, and whether the key is the certificate's. Exits 1 when the engine or senders would refuse it.

ductus cert show [OPTIONS] <TARGET>

Arguments

What it doesDefaultEnvironment
<TARGET>A PEM file, or the name of a certificates: entry in resources.yaml.

Options

What it doesDefaultEnvironment
--key <KEY>The key to check (default: the .key file beside a .pem, or the entry's key).
--root <ROOT>The project folder: the one with interfaces/ and ductus.yaml..
--env <ENV>The environment: ${NAME} values come from environments/.yaml, ${secret:NAME} from secrets/.json.devDUCTUS_ENV

ductus cert self-signed

A self-signed certificate and its key, for a lab or a test: writes OUT.pem and OUT.key and prints the resources.yaml entry. Senders must be given OUT.pem to trust. Production certificates come from the hospital's CA.

ductus cert self-signed [OPTIONS] --name <NAMES> <OUT>

Arguments

What it doesDefaultEnvironment
<OUT>Where to write, without the extension: tls/engine writes tls/engine.pem and tls/engine.key.

Options

What it doesDefaultEnvironment
--name <NAMES>A DNS name or IP address senders connect to (repeat for several).
--days <DAYS>How long it is valid.365

ductus service

The Windows service (as an administrator).

ductus service [OPTIONS] <COMMAND>

ductus service account

Show the account the service runs as; with NAME, run it as that account: grants it "Log on as a service", checks its password, and restarts the service if it was running. NAME is DOMAIN\svc-ductus$ (a gMSA: no password), DOMAIN\name or .\name (asks for its password; give it again after every upgrade), or NT SERVICE\Ductus (the default).

ductus service account [OPTIONS] [NAME]

Arguments

What it doesDefaultEnvironment
<NAME>The account: DOMAIN\name, a gMSA (DOMAIN\name$), or none to show the current one.

Options

What it doesDefaultEnvironment
--password-stdinRead the password from stdin (one line) instead of asking.
--startStart the service afterwards even if it was stopped.

ductus api-spec

Print the REST API's OpenAPI document (the same as GET /api/v1/openapi.json, without a running engine).

ductus api-spec [OPTIONS]

ductus types

Write what an editor needs to help with the project, for the version installed: the SDK's typings (.ductus/types) and a tsconfig.json for the interfaces' code, and the configuration files' schemas (.ductus/schema) with VS Code's settings pointing at them, for completion, descriptions on hover and mistakes underlined in interface.yaml, resources.yaml and ductus.yaml. ductus test and validate do it too; run it after an upgrade.

ductus types [OPTIONS]

Options

What it doesDefaultEnvironment
--root <ROOT>Project folder..

ductus status

Show message counts by interface and status (from the database, also while the engine is down); with a token, also each interface's and destination's health from the running engine (silent, behind, stalled, paused, catching up...).

ductus status [OPTIONS]

Options

What it doesDefaultEnvironment
--database-url <DATABASE_URL>The engine's database, as a PostgreSQL URL: postgres://ductus@db1/ductus?sslmode=verify-full.DUCTUS_DATABASE_URL
--api <API>The engine's REST API.http://127.0.0.1:7700DUCTUS_API_URL
--token <TOKEN>An operator token (ductus token create <name> --role operator).DUCTUS_API_TOKEN

ductus gen

Send synthetic ADT messages over MLLP.

ductus gen [OPTIONS]

Options

What it doesDefaultEnvironment
--to <TO>The MLLP listener to send to, host:port.127.0.0.1:6661
--count <COUNT>How many messages to send.1000
--patients <PATIENTS>Distinct patients (MRNs) to spread messages across.200
--rate <RATE>Total messages per second; 0 = as fast as ACKs come back.0
--connections <CONNECTIONS>Concurrent MLLP connections. Each patient always uses the same connection.1
--hot <HOT>Fraction of messages (0..1) for one hot patient, e.g. 0.3.0
--offset <OFFSET>Added to every sequence number and patient number, so several generators can feed one sink without their messages looking like duplicates.0
--tlsSend over TLS (also on with --ca or --cert), checking the receiver's certificate against this system's trusted roots and --ca, as the engine's tls: does.
--ca <CA>A CA (PEM) the receiver's certificate may come from (a self-signed one: that file).
--cert <CERT>Present this client certificate (PEM, ours first), with --key.
--key <KEY>The client certificate's private key (PEM).
--from <FROM>Connect from this address of this machine (127.0.0.2 on Linux, say), to try a receiver's allow: list as a sender elsewhere would.

ductus sink

Receive messages over MLLP, ACK them, and verify per-patient ordering. With --smtp or --http, a mail server or web server that accepts everything and prints it instead (to see what alerts send without a real one).

ductus sink [OPTIONS]

Options

What it doesDefaultEnvironment
--port <PORT>The MLLP port to listen on (127.0.0.1).6662
--smtp <SMTP>Be an SMTP server on this port: print each mail (and keep it in --dir).
--http <HTTP>Be an HTTP server on this port: print each request's body (and keep it in --dir).
--dir <DIR>Also write each mail or request to a file of its own here.
--reject <REJECT>Answer AR (application reject) to messages that contain this text, as a receiver that refuses them would (repeat for several). The engine quarantines them.
--tlsTake MLLP over TLS only, presenting --cert and --key (as the engine's tls: on a listener).
--cert <CERT>The certificate to present (PEM, ours first, then intermediates).
--key <KEY>The certificate's private key (PEM).
--clients-ca <CLIENTS_CA>Ask senders for a client certificate this CA (PEM) issued; others are refused.
--subject <SUBJECTS>Only client certificates whose subject matches (CN=bridges*.hospital.org; repeat).

REST API

Status, messages, drain mode and reports of a running Ductus engine. Every call but health needs Authorization: Bearer <token> (create one with ductus token create). Browsers sign in instead (POST /session) and send the session cookie; their changes also carry X-Ductus: 1.

The API is served by the engine at /api/v1, where api.listen in ductus.yaml says (http://127.0.0.1:7700/api/v1 by default; HTTPS with api.tls). The web UI uses only this API, so anything it does, a script can do. The machine-readable description (OpenAPI 3.1) is at /api/v1/openapi.json on any engine, and ductus api-spec prints it without one.

Who may call what. Each operation says who may call it:

publicanyone, without signing in (/health, for load balancers; signing in)
signed inany person or token
a persona person signed in with a password (their own account's settings)
operatorthe operator or admin role: changes such as resend, pause, drain
adminthe admin role: accounts and tokens
auditthe audit permission (admins have it)

A token acts with its own role, and a person's token never above that person. Reading a message's payload also needs the payloads permission, and is written in the audit log first.

Errors come back as JSON, {"error": "what went wrong, in words"}, with the HTTP status saying what kind: 400 a request that can't be done as asked, 401 not signed in, 403 not allowed, 404 nothing there, 409 not now (it changed meanwhile), 503 the engine can't do it now (the database is unreachable, or it couldn't write the audit row first).

PartWhat it covers
EngineThe engine, its interfaces and live updates.
SessionSigning a browser in and out.
Your accountThe signed-in user's own account: password, sessions, tokens, appearance.
UsersPeople who sign in (admin).
MessagesThe message browser, the quarantine, and resending or correcting messages.
DrainStop taking messages and deliver what's stored.
ReportsImpact, owners and connection checks.
AlertsAlerts: what is wrong, acknowledging and silencing, and where notifications go.
AuditWho did what: the audit log (the audit permission) and your own activity.
RetentionHow long messages are kept, and changes to it.
TokensAPI tokens (admin).

Engine

The engine, its interfaces and live updates.

GET /api/v1/engine

The engine: version, environment, uptime, drain state.

Who may call it: anyone signed in (a person or a token).

Answers

GET /api/v1/events

Live updates (Server-Sent Events): an update event with every interface's queues, health and the drain state, sent at once and then whenever a count or a health state changes or a delivery finishes (checked every 2 s).

Who may call it: anyone signed in (a person or a token).

Answers

  • 200 text/event-stream of update events (text/event-stream): LiveUpdate

GET /api/v1/health

Is the engine up? Needs no token (for load balancers and monitoring).

Who may call it: anyone, without signing in.

Answers

  • 200 (application/json): Health

GET /api/v1/hotspots

Where each destination's backlog sits: the lanes messages wait in (the ordering key's hash), the first message of each, and which worker a failing message holds up. Only destinations with messages waiting are listed.

Who may call it: anyone signed in (a person or a token).

Parameters

InType
interfacequerytextOnly this interface's destinations.
lanesquerynumberLanes to list per destination, most waiting first (default 10, at most 1000). Lanes whose first message is failing, and lanes no worker reads, are always listed.

Answers

GET /api/v1/interfaces

Every interface with its state and each destination's queue.

Who may call it: anyone signed in (a person or a token).

Answers

GET /api/v1/interfaces/{name}

One interface: its state, queues and what it connects.

Who may call it: anyone signed in (a person or a token).

Parameters

InType
name (required)pathtextInterface name.

Answers

POST /api/v1/interfaces/{name}/destinations/{destination}/pause

Pause a destination at once: nothing is sent or retried until it's resumed (a send under way finishes). Messages keep queueing. No reason needed; send one now or later (pausing again with a reason adds it). Survives restarts. Operator.

Who may call it: an operator or admin.

Parameters

InType
name (required)pathtextInterface name.
destination (required)pathtextDestination name (default for one given with destination:).

Body (application/json): PauseRequest

Answers

DELETE /api/v1/interfaces/{name}/destinations/{destination}/pause

Resume a paused destination: it sends its backlog in order. Operator.

Who may call it: an operator or admin.

Parameters

InType
name (required)pathtextInterface name.
destination (required)pathtextDestination name.

Answers

GET /api/v1/metrics

Metrics in Prometheus's text format: messages received and delivered, retries, timings (receipt to sent, sends, transforms, respond-mode requests, database and API calls), queue sizes, stopped interfaces, drain state.

Who may call it: anyone signed in (a person or a token).

Answers

  • 200 Prometheus text format (0.0.4) (text/plain): text

Shapes

The JSON bodies the operations above take and return.

AllowedName

FieldType
addresseslist of textThe addresses let in for it: the last lookup that found some (kept when a later one fails).
checked_at (optional)time (RFC 3339) or nullWhen it was last looked up.
error (optional)text or nullWhy the last lookup failed, if it did.
nametext

DestinationCounts

FieldType
filterednumber
latencyTimingFrom receipt to sent, waiting in the queue included.
quarantinednumber
retriesnumberTemporary failures (each retry counts).
sendTimingEach successful send (the receiver's time).
sentnumber
transformTimingThe destination's function.

DestinationHealth

FieldType
behind (optional)text or nullWhich behind limit is past: messages or age.
catch_up_s (optional)number or nullSeconds to catch up at the last few minutes' rates (only while catching up).
causeslist of textWhy a backlog grows: burst (more arriving than normal for this hour of the week), slow_receiver (each send slower than normal).
last_error (optional)LastError or null
last_sent (optional)time (RFC 3339) or nullWhen it last delivered (kept across restarts).
normalRatesNormal for this hour of the week, learned from the hours watched (none until then).
oldest_waiting_s (optional)number or nullSeconds the oldest waiting message has waited.
recent (optional)Rates or null
statetextok, behind (past its behind limit), error (has quarantined messages), stalled (messages wait, nothing finished for a minute) or paused (best to worst).
summarytextIn operations' words: "behind 1,240 (oldest 12 min), catching up ~6 min; burst: 400/min received (normal 80)".

DestinationHotspots

FieldType
destinationtext
findingslist of FindingInfo
interfacetext
laneslist of LaneInfoThe lanes with the most waiting, plus any blocked or unread one.
lanes_totalnumberLanes the interface has (ordering.partitions; 1 without an ordering key).
lanes_waitingnumber
pausedtrue or false
summarytextIn operations' words: "1,240 waiting in 3 of 64 lanes; worker 2 blocked by message ...".
waitingnumber
workerslist of WorkerInfoWorkers with something waiting.
workers_totalnumberWorkers each destination has (1 in the shared pool).

DestinationStatus

FieldType
connectortextWhere it sends: mllp lab.local:5000, http api billing /charges, file out.
healthDestinationHealthIts state and why: behind, stalled, errors, and how it compares with normal.
nametext
owner (optional)text or nullSet when it isn't the interface's owner.
paused (optional)PauseInfo or null
pendingnumberWaiting to be delivered.
quarantinednumberNeeding a person.
since_startDestinationCountsCounts and timings since the engine started.

DrainStatus

FieldType
pausedlist of textPaused destinations (interface/destination): they keep their messages and don't hold the drain up.
pendingnumberDeliveries still to go, over all interfaces.
since (optional)time (RFC 3339) or nullWhen draining started, or when it finished.
statetextaccepting, draining or drained.
stop_when_drainedtrue or falseThe engine exits once drained.
waiting_forlist of textInterfaces still holding pending deliveries (a destination that's down, or a stopped interface, keeps a drain from finishing).

EngineInfo

FieldType
drainDrainStatus
environmenttext
handover (optional)Handover or null
holds_interfacestrue or falseFalse while the engine has lost the database connection that holds its interfaces: sending, file pickup and schedules wait until it is back (receiving goes on).
hosttextThe server's name.
interfacesnumber
otherslist of OtherEngineOther engines running on this database, oldest first, and the interfaces each runs. An interface runs on one engine at a time.
resourceslist of ResourceCallsCalls from interface code to each database and API in resources.yaml (fetch: URLs called directly), since the engine started.
started_attime (RFC 3339)
stoppednumberInterfaces stopped because their code ran away (their messages wait).
uptime_secondsnumber
versiontext

ErrorBody

FieldType
errortextWhat went wrong, in plain words.

FindingInfo

FieldType
kindtextblocked (a message keeps failing; its worker's lanes wait), all_blocked (every worker is retrying: the receiver looks down), uneven (one worker has most of the backlog), hot_lane (one lane has most of it), unowned (a lane no worker reads).
lane (optional)number or null
share (optional)number or nullIts share of the backlog (0–1), for uneven and hot_lane.
texttext
worker (optional)number or null

Handover

FieldType
attime (RFC 3339)When it held the interfaces.
from_pidnumber
from_versiontextThe version it took over from.
sending_paused_msnumberFrom the old engine's stop until this one sent (receiving never stopped).

HeadInfo

The first message waiting in a lane: the next one its worker handles.

FieldType
attemptsnumberFailed attempts: above zero, its worker is retrying it and its lanes wait.
idnumberThe message (GET /messages/{id}).
last_attempt_at (optional)time (RFC 3339) or null
last_error (optional)text or null
ordering_key (optional)text or null
received_attime (RFC 3339)
waiting_snumber

Health

FieldType
draintextaccepting, draining or drained: a load balancer can stop sending when it isn't accepting.
statustextAlways ok when the engine answers.

InterfaceCounts

FieldType
duplicatesnumberStored as duplicates, not queued.
receivednumberStored and queued.
request_errors (optional)number or null
requests (optional)Timing or null
store_failuresnumberCouldn't be stored (the sender was told to retry).

InterfaceDetail

InterfaceStatus or object

InterfaceHealth

FieldType
last_received (optional)time (RFC 3339) or nullWhen something last arrived (kept across restarts).
statetextok, silent (nothing within active_within), missed (nothing by active_by) or stopped (best to worst).
summarytextIn operations' words: "silent: nothing received for 47 min".

InterfaceStatus

FieldType
acceptingtrue or falseFalse while draining.
description (optional)text or null
destinationslist of DestinationStatus
healthInterfaceHealthWhat it should have received (monitor: in interface.yaml), checked now.
isolationtextshared or dedicated.
nametext
owner (optional)text or null
retention_days (optional)number or nullDays its messages are kept, as configured (none in respond mode: it stores nothing). GET /retention has changes in progress.
since_startInterfaceCountsCounts since the engine started (they start at zero with it).
sourcetextWhere messages come from: mllp 0.0.0.0:6661, http POST /orders on 0.0.0.0:8080 (queue).
statetextrunning, or stopped (its code ran away: its messages wait until it's fixed and the engine restarted).
stopped_reason (optional)text or null
workersnumberParallel workers (dedicated) or 1 (shared).

LaneInfo

FieldType
headHeadInfo
lanenumber
waitingnumber
worker (optional)number or nullNone: no worker reads it (ordering.partitions was lowered while it had messages).

LastError

FieldType
attime (RFC 3339)
errortext

LiveDestination

FieldType
healthLiveHealthAs in GET /interfaces (an update is sent when a state changes, not as a summary's minutes tick on).
nametext
pausedtrue or false
pendingnumber
quarantinednumber

LiveHealth

FieldType
statetext
summarytext

LiveInterface

FieldType
acceptingtrue or false
destinationslist of LiveDestination
healthLiveHealthWhat it receives, as in GET /interfaces.
nametext
statetext

LiveUpdate

FieldType
attime (RFC 3339)
drainDrainStatus
interfaceslist of LiveInterface

OtherEngine

FieldType
environmenttext
hosttext
interfaceslist of textThe interfaces it runs (respond-mode HTTP interfaces aren't claimed, so not listed).
pidnumber
started_attime (RFC 3339)
versiontext

PauseInfo

FieldType
attime (RFC 3339)
bytextThe token that paused it.
reason (optional)text or nullWhy, once someone says (it can be added after the pause).
reason_at (optional)time (RFC 3339) or null
reason_by (optional)text or null

PauseRequest

FieldType
reason (optional)text or nullWhy (optional: pause first, explain after; pausing again with a reason adds it).

PauseStatus

FieldType
destinationtext
interfacetext
paused (optional)PauseInfo or null
pendingnumberMessages waiting for it.

Rates

FieldType
done_per_min (optional)number or nullMessages finished per minute (sent, filtered or quarantined).
received_per_min (optional)number or nullMessages arriving per minute.
send_ms (optional)number or nullA successful send's mean time.

ResourceCalls

FieldType
callsTiming
errorsnumberCalls that failed (network, SQL, timeout; an HTTP error status is an answer, not a failure).
kindtextdb, api or fetch.
nametext

SourceAllow

A listener's allow: list and how it's doing.

FieldType
entrieslist of textThe list as written: addresses, subnets and host names.
last_refused (optional)text or nullThe last refused connection's address and time.
last_refused_at (optional)time (RFC 3339) or null
nameslist of AllowedNameEach host name in it with what it resolves to now.
refusednumberConnections refused since the engine started (0 when this engine doesn't hold the listener).

SourceTls

TLS on an interface's listener.

FieldType
addresstextWhere: 0.0.0.0:6661, POST /orders on 0.0.0.0:8443 (queue).
certificatetextThe certificates: pair it presents.
clients_ca (optional)text or nullSenders need a certificate this CA (a certificates: entry) issued; none: any sender.
protocoltextMLLP over TLS or HTTPS.
subjectslist of textAnd one of these subjects (none: any the CA issued).

SystemInfo

FieldType
application (optional)text or null
vendor (optional)text or null

Timing

How long something took, over every time since the engine started (estimated from histogram buckets, like Prometheus does: good to a bucket's width).

FieldType
countnumber
mean_ms (optional)number or null
p50_ms (optional)number or null
p95_ms (optional)number or null

WorkerInfo

FieldType
blocked_by (optional)number or nullThe lane whose first message it keeps retrying.
lanes_waitingnumber
waitingnumber
workernumber

Session

Signing a browser in and out.

GET /api/v1/session

Who is calling, and as what. Works while a password change is still required.

Who may call it: anyone signed in (a person or a token).

Answers

POST /api/v1/session

Sign a browser in with a username and password, or a token: answers with a session cookie (ductus_session), which then stands in for them. Needs the X-Ductus: 1 header. After too many wrong passwords for one username the account refuses sign-in for a while (429).

Who may call it: anyone, without signing in.

Body (application/json): SignIn

Answers

DELETE /api/v1/session

Sign out: the session ends at once and the cookie is cleared.

Who may call it: anyone, without signing in.

Answers

  • 204

Shapes

The JSON bodies the operations above take and return.

ErrorBody

FieldType
errortextWhat went wrong, in plain words.

Session

FieldType
ends_at (optional)time (RFC 3339) or nullWhen the session ends at the latest (it also ends when unused for too long). None when the call came with a token instead of a session.
kindtextuser (signed in with a password) or token.
must_change_passwordtrue or falseAn admin chose this password: nothing else works until it is changed (POST /me/password).
nametextThe username, or the token's name.
permissionslist of textWhat the role doesn't say: payloads, audit. Admins have both.
roletextviewer, operator or admin.

SignIn

FieldType
password (optional)text or null
token (optional)text or null...or an API token (dux_...), unless auth.token_sign_in is off.
username (optional)text or nullA user's name (with password)...

Your account

The signed-in user's own account: password, sessions, tokens, appearance.

GET /api/v1/me

The signed-in user's account.

Who may call it: a person signed in with a password.

Answers

PATCH /api/v1/me

Change your own name and email (a directory account's come from the directory).

Who may call it: a person signed in with a password.

Body (application/json): ProfileUpdate

Answers

GET /api/v1/me/activity

What you did yourself (and what your tokens did): the same entries, only yours. Anyone signed in with a username and password.

Who may call it: a person signed in with a password.

Parameters

InType
beforequerynumberEntries older than this id.
limitquerynumberAt most 500 (default 50).

Answers

POST /api/v1/me/password

Change your own password. Your other sessions end. Works while a change is required.

Who may call it: a person signed in with a password.

Body (application/json): PasswordChange

Answers

GET /api/v1/me/sessions

Your signed-in browsers.

Who may call it: a person signed in with a password.

Answers

DELETE /api/v1/me/sessions

Sign out everywhere else: every session of yours but this one ends.

Who may call it: a person signed in with a password.

Answers

DELETE /api/v1/me/sessions/{id}

End one of your sessions.

Who may call it: a person signed in with a password.

Parameters

InType
id (required)pathnumberSession id.

Answers

GET /api/v1/me/settings

Your saved appearance (theme and custom colors), which follows you to any browser.

Who may call it: a person signed in with a password.

Answers

PUT /api/v1/me/settings

Save your appearance.

Who may call it: a person signed in with a password.

Body (application/json): Appearance

Answers

GET /api/v1/me/tokens

Your own tokens (live ones).

Who may call it: a person signed in with a password.

Answers

POST /api/v1/me/tokens

Make a token of your own, for scripts. It acts as you at most: never above your role or permissions, and it stops working if you are disabled. Its secret is in this answer only.

Who may call it: a person signed in with a password.

Body (application/json): NewMyToken

Answers

DELETE /api/v1/me/tokens/{name}

Revoke one of your tokens (the name as listed).

Who may call it: a person signed in with a password.

Parameters

InType
name (required)pathtextToken name.

Answers

Shapes

The JSON bodies the operations above take and return.

Appearance

FieldType
colors (optional)object or nullThe custom colors, as the UI saves them (a JSON object).
saved (optional)true or falseWhether this was saved before: false means the UI may offer the browser's own choice.
themetextsystem, light, dark, contrast or custom.

AuditEntry

FieldType
actiontextmessage.payload.read, message.dismiss, user.create, ...
actortextThe user's or token's name when it happened.
actor_kindtextuser, token, system (the engine, or someone at the server's command line) or anonymous (a sign-in as a name nobody has).
attime (RFC 3339)
detail (optional)object or nullMore about it, by action (never a password, token or payload).
idnumber
interface (optional)text or null
ip (optional)text or nullThe address the request came from (a proxy's, if there is one).
message (optional)number or null
outcometextok, denied or failed.
subject (optional)text or nullWhat it was done to: a username, a token, interface/destination.
via (optional)text or nullsession (the web UI), token, cli or engine; none for entries from before the log.

AuditPage

FieldType
entrieslist of AuditEntryNewest first.
next_before (optional)number or nullPass as before for the next (older) page; absent on the last page.

CreatedToken

FieldType
secrettextThe token itself: shown this once, never stored (only its hash is).
tokenTokenInfo

ErrorBody

FieldType
errortextWhat went wrong, in plain words.

NewMyToken

FieldType
nametextWhat it is for, e.g. deploy-script; it is listed as <you>/<name>.
permissions (optional)list of text or nullDefaults to none; never more than you have.
role (optional)text or nullDefaults to your own role; never above it.

PasswordChange

FieldType
currenttextThe password you have now.
newtext

ProfileUpdate

FieldType
display_name (optional)text or null
email (optional)text or null

SessionInfo

FieldType
created_attime (RFC 3339)
currenttrue or falseThe one making this request.
expires_attime (RFC 3339)
idnumber
last_seen_attime (RFC 3339)

TokenInfo

FieldType
created_attime (RFC 3339)
last_used_at (optional)time (RFC 3339) or nullTo the minute.
nametext
owner (optional)text or nullThe user it belongs to; none for a service token made by an admin.
permissionslist of textpayloads, audit (admins' tokens have all).
roletextviewer, operator or admin.

UserInfo

FieldType
created_attime (RFC 3339)
directory_checked_at (optional)time (RFC 3339) or nullA directory account: when the directory was last asked about them (sign-in or refresh).
directory_refusedtrue or falseA directory account the directory refused at its last check (no group, disabled or gone there): it can't sign in or act until the directory says yes again.
disabledtrue or false
display_nametext
emailtext
idnumber
last_sign_in_at (optional)time (RFC 3339) or null
locked_until (optional)time (RFC 3339) or nullRefused after too many wrong passwords, until then.
made_for_ownertrue or falseA directory account the engine made because an owner text names them, before their first sign-in (directory accounts are otherwise made at one).
must_change_passwordtrue or falseAn admin chose the password; the next sign-in can only change it.
permissionslist of textpayloads, audit (admins have both whatever is listed).
roletextviewer, operator or admin.
sourcetextWhere the password is checked: local, or a directory.
usernametext

Users

People who sign in (admin).

GET /api/v1/users

All users. Admin.

Who may call it: an admin.

Answers

POST /api/v1/users

Make an account with a temporary password (the person changes it at their first sign-in). Admin.

Who may call it: an admin.

Body (application/json): NewUserRequest

Answers

PATCH /api/v1/users/{id}

Change a user's details, role, permissions, or disable them. Refused when it would leave no admin who can sign in. Admin.

Who may call it: an admin.

Parameters

InType
id (required)pathnumberUser id.

Body (application/json): UserUpdate

Answers

POST /api/v1/users/{id}/password

Give a user a new temporary password (their sessions end; they must change it when they sign in). Admin.

Who may call it: an admin.

Parameters

InType
id (required)pathnumberUser id.

Body (application/json): PasswordReset

Answers

POST /api/v1/users/{id}/unlock

Let a user who was locked out after wrong passwords try again. Admin.

Who may call it: an admin.

Parameters

InType
id (required)pathnumberUser id.

Answers

Shapes

The JSON bodies the operations above take and return.

ErrorBody

FieldType
errortextWhat went wrong, in plain words.

NewUserRequest

FieldType
display_name (optional)text or null
email (optional)text or null
passwordtextA temporary password: the person must change it at their first sign-in.
permissions (optional)list of text or nullpayloads and/or audit.
role (optional)text or nullviewer (default), operator or admin.
usernametext

PasswordReset

FieldType
passwordtextThe temporary password; the person must change it at their next sign-in.

UserInfo

FieldType
created_attime (RFC 3339)
directory_checked_at (optional)time (RFC 3339) or nullA directory account: when the directory was last asked about them (sign-in or refresh).
directory_refusedtrue or falseA directory account the directory refused at its last check (no group, disabled or gone there): it can't sign in or act until the directory says yes again.
disabledtrue or false
display_nametext
emailtext
idnumber
last_sign_in_at (optional)time (RFC 3339) or null
locked_until (optional)time (RFC 3339) or nullRefused after too many wrong passwords, until then.
made_for_ownertrue or falseA directory account the engine made because an owner text names them, before their first sign-in (directory accounts are otherwise made at one).
must_change_passwordtrue or falseAn admin chose the password; the next sign-in can only change it.
permissionslist of textpayloads, audit (admins have both whatever is listed).
roletextviewer, operator or admin.
sourcetextWhere the password is checked: local, or a directory.
usernametext

UserUpdate

FieldType
disabled (optional)true or false or nullDisabled users can't sign in, and their sessions and tokens stop at once.
display_name (optional)text or null
email (optional)text or null
permissions (optional)list of text or null
role (optional)text or null

Messages

The message browser, the quarantine, and resending or correcting messages.

GET /api/v1/interfaces/{name}/messages

An interface's messages, newest first, a page at a time.

Who may call it: anyone signed in (a person or a token).

Parameters

InType
name (required)pathtextInterface name.
statusquerytextOnly messages with this overall status.
beforequerynumberOnly messages older than this id (the previous page's next_before).
limitquerynumberPage size, at most 500 (default 50).

Answers

POST /api/v1/messages/dismiss

Dismiss many messages' quarantined deliveries at once (the quarantine screen's "dismiss selected"), in one transaction, with one reason. Operator.

Who may call it: an operator or admin.

Body (application/json): BulkDismissRequest

Answers

POST /api/v1/messages/resend

Send many messages again at once (the quarantine screen's "resend selected"): as POST /messages/{id}/resend for each, in one transaction. Operator.

Who may call it: an operator or admin.

Body (application/json): BulkResendRequest

Answers

GET /api/v1/messages/{id}

One message: what was received, and what each destination did with it.

Who may call it: anyone signed in (a person or a token).

Parameters

InType
id (required)pathnumberMessage id.

Answers

POST /api/v1/messages/{id}/dismiss

Dismiss a message's quarantined deliveries: nobody will fix or resend them, so they leave the quarantine (the delivery becomes filtered, "dismissed by NAME: reason"). Nothing is deleted: the message and what happened to it stay, and the dismissal is in the audit log with the reason. Works the same in the held area. Operator.

Who may call it: an operator or admin.

Parameters

InType
id (required)pathnumberMessage id.

Body (application/json): DismissRequest

Answers

POST /api/v1/messages/{id}/edit

Queue a corrected copy of a message. The original stays as it was received; its quarantined deliveries to the copy's destinations are marked replaced. The copy is a new message (new id, so a new meta.id), never deduplicated, with the original's origin. Refused for a destination where the original is still pending (it may be being sent right now). Operator.

Who may call it: an operator or admin.

Parameters

InType
id (required)pathnumberThe message to correct.

Body (application/json): EditRequest

Answers

POST /api/v1/messages/{id}/resend

Send a message again: its quarantined deliveries, or those to the destinations named (whatever they ended as: a replay). Each goes back to pending in place and is sent next in its lane. Pending deliveries are left alone. Operator.

Who may call it: an operator or admin.

Parameters

InType
id (required)pathnumberMessage id.

Body (application/json): ResendRequest

Answers

GET /api/v1/quarantine

Quarantined deliveries across this engine's interfaces (or one interface or destination), newest message first: what needs a person. Counts per destination are in GET /interfaces.

Who may call it: anyone signed in (a person or a token).

Parameters

InType
interfacequerytextOnly this interface's.
destinationquerytextOnly this destination's (with interface, that interface's; without, any interface's destination of that name).
beforequerytextThe previous page's next_before.
limitquerynumberPage size, at most 500 (default 100).

Answers

Shapes

The JSON bodies the operations above take and return.

ActionInfo

FieldType
actiontextresend (the delivery went back to pending), edit (a corrected copy was queued) or dismiss (a quarantined delivery was closed without sending).
attime (RFC 3339)
bytextThe user or token that did it.
copy (optional)number or nulledit: the copy's id.
destinationtext
previous_attempts (optional)number or null
previous_error (optional)text or null
previous_status (optional)text or nullThe delivery before: sent, filtered or quarantined (none: there was none).
reason (optional)text or null

BulkDismissRequest

FieldType
destination (optional)text or nullOnly the destination with this name in each message's interface. Without it: all quarantined deliveries of each message.
idslist of numberMessage ids (at most 500), from any interfaces.
reasontextWhy. Required; kept with every dismissal and in the audit log.

BulkResendRequest

FieldType
destination (optional)text or nullResend to the destination with this name in each message's interface, whatever its delivery ended as (a replay). Without it: every destination that quarantined each message.
idslist of numberMessage ids (at most 500), from any interfaces.
quarantined_only (optional)true or false or nullOnly deliveries still quarantined, even with destination named (what the quarantine screen sends: a delivery someone resolved meanwhile is skipped, not replayed). Default: true without destination, false with it.
reason (optional)text or nullWhy (kept with each action).

BulkResendResult

FieldType
resentlist of ResentMessageThe messages sent again, by id.
skippedlist of numberIds with nothing to resend: not found, not run by this engine, a duplicate, or no delivery that matched (pending already, or not quarantined).

DeliveryInfo

FieldType
attemptsnumber
destinationtext
last_error (optional)text or null
output (optional)text or nullWhat was sent, for destinations with save_output: true.
statustextpending, sent, filtered or quarantined.

DismissRequest

FieldType
destinations (optional)list of text or nullDismiss only these destinations' quarantined deliveries. Without it: all of the message's.
reasontextWhy nobody will fix or resend these. Required: it is kept in the audit log.

DismissResult

FieldType
dismissedlist of ResentMessageThe messages with a delivery dismissed (previous_status is quarantined; previous_error is why it was).
skippedlist of numberIds with nothing to dismiss: not found, not run by this engine, or no delivery still quarantined (someone resolved it meanwhile).

EditRequest

FieldType
destinations (optional)list of text or nullWhere the copy goes. Without it: the destinations that quarantined the original.
payloadtextThe corrected message, in full (HL7 segments separated by \r).
reason (optional)text or nullWhy (kept with the action).

EditResult

FieldType
destinationslist of textWhere the copy goes.
idnumberThe copy's id (a new message, queued at the back of its lane).
interfacetext
originalnumberThe message it corrects.
replacedlist of textDestinations whose quarantined delivery of the original is now filtered ("replaced by edited copy N").

ErrorBody

FieldType
errortextWhat went wrong, in plain words.

MessageDetail

MessageSummary or object

MessagePage

FieldType
messageslist of MessageSummaryNewest first.
next_before (optional)number or nullPass as before for the next (older) page; absent on the last page.

MessageSummary

FieldType
control_id (optional)text or nullMSH-10 for HL7.
idnumber
interfacetext
last_error (optional)text or nullWhy it was quarantined or filtered (per destination when there are several).
ordering_key (optional)text or null
origin (optional)number or nullRouted from another interface: the id of the message there (GET /messages/{origin}).
received_attime (RFC 3339)
sizenumberPayload size in bytes.
statustextpending, sent, filtered, quarantined or duplicate: the overall status (pending until every destination is done).
updated_attime (RFC 3339)

QuarantinePage

FieldType
deliverieslist of QuarantinedDeliveryNewest message first; a message quarantined by two destinations is listed twice.
next_before (optional)text or nullPass as before for the next page; absent on the last page.

QuarantinedDelivery

FieldType
attemptsnumber
control_id (optional)text or nullMSH-10 for HL7.
destinationtext
idnumberThe message's id.
interfacetext
last_error (optional)text or nullWhy.
ordering_key (optional)text or null
quarantined_attime (RFC 3339)
received_attime (RFC 3339)
sizenumberPayload size in bytes.

ResendRequest

FieldType
destinations (optional)list of text or nullResend to these destinations, whatever their delivery ended as (sent and filtered too: a replay). Without it: every destination that quarantined the message.
reason (optional)text or nullWhy (kept with the action).

ResentDelivery

FieldType
destinationtext
previous_attemptsnumber
previous_error (optional)text or null
previous_statustextWhat the delivery was before: sent, filtered or quarantined.

ResentMessage

FieldType
destinationslist of ResentDeliveryThe deliveries now pending again.
idnumber
interfacetext

Drain

Stop taking messages and deliver what's stored.

GET /api/v1/drain

Drain mode: accepting, draining or drained, and what's still pending.

Who may call it: anyone signed in (a person or a token).

Answers

POST /api/v1/drain

Start draining: stop taking new messages (MLLP listeners close, queued HTTP answers 503, file pickup and schedules pause) and keep delivering what's stored. Operator.

Who may call it: an operator or admin.

Body (application/json): DrainRequest

Answers

DELETE /api/v1/drain

Stop draining: take messages again. Operator.

Who may call it: an operator or admin.

Answers

Shapes

The JSON bodies the operations above take and return.

DrainRequest

FieldType
stop (optional)true or falseExit the engine once drained (for an upgrade or a server move).

DrainStatus

FieldType
pausedlist of textPaused destinations (interface/destination): they keep their messages and don't hold the drain up.
pendingnumberDeliveries still to go, over all interfaces.
since (optional)time (RFC 3339) or nullWhen draining started, or when it finished.
statetextaccepting, draining or drained.
stop_when_drainedtrue or falseThe engine exits once drained.
waiting_forlist of textInterfaces still holding pending deliveries (a destination that's down, or a stopped interface, keeps a drain from finishing).

ErrorBody

FieldType
errortextWhat went wrong, in plain words.

Reports

Impact, owners and connection checks.

GET /api/v1/certificates

Every certificate the engine connects to, as its last check saw it (read even when this server doesn't trust them), every one it presents (side: ours, as loaded), and when the engine checks next by itself.

Who may call it: anyone signed in (a person or a token).

Answers

GET /api/v1/docs

The company-defined documentation fields and every interface's, destination's and resource's values. Read-only: the project's files are the source of truth.

Who may call it: anyone signed in (a person or a token).

Answers

POST /api/v1/doctor

Check every connection the engine needs, from its server: DNS, TCP (with the address a firewall request needs), TLS (certificate, expiry), then each system's own login; also the alerts' mail server, webhooks and heartbeat. Never sends anything to a destination. Listening ports aren't checked (the engine holds them). The run is kept (/doctor/runs), and the certificates it saw (/certificates). Operator.

Who may call it: an operator or admin.

Body (application/json): DoctorRequest

Answers

GET /api/v1/doctor/runs

The connection checks kept (the last 30), newest first. Operator.

Who may call it: an operator or admin.

Answers

GET /api/v1/doctor/runs/{id}

One kept connection check's report, as POST /doctor returned it. Operator.

Who may call it: an operator or admin.

Parameters

InType
id (required)pathnumberThe run's id.

Answers

GET /api/v1/impact

Which interfaces a vendor, application or host touches, how, and who owns them (for a vendor breach or a VPN/IP change). Exactly one of vendor, application, host.

Who may call it: anyone signed in (a person or a token).

Parameters

InType
vendorquerytexte.g. Epic Systems (ignores case).
applicationquerytexte.g. Sunquest Lab (ignores case).
hostquerytextAn address, subnet (10.20.30.0/24) or host name.
resolvequerytrue or falseLook host names up in DNS (from the engine's server), so names and addresses match.

Answers

GET /api/v1/owners

Who owns which interface (about.owner), destination (owner on one someone else owns) and resources.yaml entry, and whether each owner is an account (username, email or display name, ignoring case, or one it had before), a team in owners:, or nobody the engine knows.

Who may call it: anyone signed in (a person or a token).

Answers

GET /api/v1/owners/people

The accounts that can own things (enabled ones), for choosing a new owner: name and email.

Who may call it: anyone signed in (a person or a token).

Answers

POST /api/v1/owners/reassign/preview

What moving ownership from one owner to another would change, as ductus owners reassign --dry-run shows it, plus a patch to apply in the project's repository. Writes nothing: the interfaces' files are the project's (in git), so the change is made there and deployed. Reads the files as they are on the engine's server now.

Who may call it: anyone signed in (a person or a token).

Body (application/json): ReassignRequest

Answers

GET /api/v1/ports

What the engine listens on and connects to, with TLS in and out: the same report as ductus ports.

Who may call it: anyone signed in (a person or a token).

Answers

Shapes

The JSON bodies the operations above take and return.

AllowedName

FieldType
addresseslist of textThe addresses let in for it: the last lookup that found some (kept when a later one fails).
checked_at (optional)time (RFC 3339) or nullWhen it was last looked up.
error (optional)text or nullWhy the last lookup failed, if it did.
nametext

CertificateInfo

FieldType
alert (optional)number or nullIts alert (/alerts/{id}), when alerts are set up.
chain_earliest (optional)text or nullWhose that is, when it isn't the server certificate's (an intermediate).
chain_not_after (optional)time (RFC 3339) or nullThe earliest expiry in its chain (what counts).
checked_at (optional)time (RFC 3339) or nullThe last check; ours: now (read from memory).
days_left (optional)number or nullWhole days left (UTC dates), counted to the earliest expiry in the chain; negative once expired.
error (optional)text or nullWhy the last check failed, if it did; ours: why renewed files can't be used (the pair loaded before is still presented).
fingerprint (optional)text or nullSHA-256 of the server certificate, hex.
hosttextOurs: this server's name.
issuer (optional)text or null
not_after (optional)time (RFC 3339) or nullThe server certificate's own expiry.
not_before (optional)time (RFC 3339) or null
portnumberOurs: 0 (see presented).
presentedlist of textOurs: where it is presented (0.0.0.0:6661 MLLP over TLS (adt), client certificate to hie.example.org:7443 (adt → hie)).
read_at (optional)time (RFC 3339) or nullWhen the certificate shown was read (older than checked_at when the last check couldn't).
resourcetextempi (resources.yaml), alerts.smtp, alerts.<channel>, alerts.heartbeat, or <interface>/<destination>; ours: the certificates: entry.
sidetextremote (a server the engine connects to, as its last check saw it) or ours (a pair the engine presents, as it loaded it).
stage (optional)number or nullThe step of the countdown it is within (a warn day; 0 once expired).
statetextok, expiring (within the first of alerts.certificates.warn_days), expired, or unknown (never read: not checked yet, or never reachable).
subject (optional)text or null
trusted (optional)true or false or nullThis server trusts it (none for ours: whether senders do isn't known here).

CertificatesOverview

FieldType
alertingtrue or falseA rule names certificate (alerts are sent about them).
certificateslist of CertificateInfoEarliest expiry first; never read ones last.
last_run (optional)DoctorRun or null
last_scheduled (optional)DoctorRun or null
modetextWhat those checks cover: certificates or full.
schedule_seconds (optional)number or nullHow often the engine checks by itself, in seconds; none when doctor.schedule: off.
warn_dayslist of numberThe steps of the countdown (alerts.certificates.warn_days).

Check

The result for one thing the project connects to.

FieldType
advicelist of textWhat a failure or warning most likely means, and what to do about it.
certificate (optional)SeenCertificate or null
endpointtextepic.local:5000 MLLP, a folder, 0.0.0.0:6661 MLLP.
outcomeOutcomeThe worst step.
stepslist of Step
used_bylist of text
whattextdatabase patient_db, api empi, destination adt → lab, listener.

DocsEntry

One documented thing and the values it has.

FieldType
custommap of name to any JSONValues by field key, as written in custom:.
interface (optional)text or nullThe interface it belongs to; none for a resource.
leveltextinterface, destination or resource.
missinglist of textRequired fields that have no value here.
nametextadt-lab, adt-lab.default, api empi.

DocsField

A field the company declared in docs-schema.yaml.

FieldType
applies_tolist of textinterface, destination, resource.
help (optional)text or null
keytext
requiredtrue or false
titletextThe label, else the key.
typetexttext, enum, date, url, person or list.
valueslist of textWhat an enum (or each item of a list) may be.

DocsReport

FieldType
entrieslist of DocsEntry
fieldslist of DocsFieldIn the order the file lists them.
problemslist of textWhat ductus validate would reject (wrong or undeclared values, missing required ones).

DoctorRequest

FieldType
interfaces (optional)list of textOnly what these interfaces use (the engine's database is always checked).
mode (optional)DoctorRunModefull (the default: logins included) or certificates (DNS, TCP and the TLS handshake of what has a certificate; nothing logs in).
timeout_ms (optional)number or nullLongest wait per step, in milliseconds (default 5000, at most 60000).

DoctorRun

FieldType
attime (RFC 3339)
checksnumber
failednumber
idnumber
modetextcertificates or full.
secondsnumber
started_bytextschedule, or who asked.

DoctorRunMode

full, certificates

ErrorBody

FieldType
errortextWhat went wrong, in plain words.

ImpactHit

FieldType
endpointslist of textHosts and ports, listening ports, folders to look at.
interfacetext
owner (optional)text or null
reasontextWhy it matched.
roletextsource, destination lab, database patient_db, api empi.

ImpactReport

FieldType
affectednumberInterfaces affected, of interfaces.
hitslist of ImpactHit
incompletelist of textInterfaces that don't name every system they connect (they may be missing here).
interfacesnumber
knownlist of textWith no hits for a vendor or application: the names that are known.
querytextWhat was looked for, e.g. vendor "Epic Systems".

ListeningPort

FieldType
allowtextWho may connect: the allow: list (10.1.20.0/24, epic-bridge01), or anyone.
allow_detail (optional)SourceAllow or null
hosttext
interfacetextThe interface, or (engine REST API).
portnumber
protocoltextMLLP, MLLP over TLS, HTTP or HTTPS.
route (optional)text or nullHTTP only: GET /patients/{mrn} (respond).
tlstextnone, or what it presents and asks for (presents ductus-mllp, clients need a certificate from hospital-ca).

OutboundPort

FieldType
hosttext
portnumber
protocoltextMLLP, MLLP over TLS, HTTP, HTTPS, SFTP, LDAP, LDAPS, Postgres, SQL Server, SQL Browser (UDP).
tlstextFor MLLP, HTTP and directories: none, TLS (the platform's trusted certificates), or TLS with what it trusts and presents (TLS: hie-ca trusted, client certificate ductus-client). Empty for SFTP (SSH) and databases.
used_bytextThe interface (adt → lab), or resources.yaml: <name>.

Outcome

ok, warn, fail

Owned

FieldType
destination (optional)text or nullSet for a destination owned by someone other than its interface's owner.
interfacetext

OwnedResource

FieldType
kindtextdatabase, api, sftp server or directory.
nametext

OwnerGroup

FieldType
advice (optional)text or nullHow the owner should be written instead, when it reaches someone but isn't written as their email (renamed, reused, a username or display name), in words.
owner (optional)text or nullAbsent for what nobody owns (listed last).
ownslist of OwnedInterfaces, and destinations owned by someone other than their interface's owner.
person (optional)OwnerPerson or null
reach (optional)text or nullWho the owner is: user (an account, in person), renamed (an account by a name it had before, which nobody has now: in person, the former name in was), reused (the account that has the name now, in person; another had it before, in was), disabled (an account that is disabled or refused by the directory, in person), several (names more than one account: none is chosen), team (in owners: in ductus.yaml), address (an email address, not an account) or nobody. Absent for the unowned. renamed and reused still reach person (alerts too); disabled, several and nobody reach no one.
resourceslist of OwnedResourceresources.yaml entries with this owner.
suggest (optional)text or nullWhat to write instead (the account's email), for the move's to.
was (optional)OwnerWas or null
youtrue or falseThe owner is the caller (or the person whose token calls).

OwnerPerson

An account, as an owner.

FieldType
disabledtrue or falseDisabled, or refused by the directory.
display_name (optional)text or null
email (optional)text or null
usernametext

OwnerWas

A name an account had before.

FieldType
kindtextusername, email or display_name.
untiltime (RFC 3339)When it stopped being theirs.
valuetext
whosetextThe account that had it (its username now).

PortsReport

FieldType
connects_tolist of OutboundPortWhat it connects to (destinations and resources.yaml), by host: what a firewall request lists.
listeninglist of ListeningPortWhat the engine listens on (its interfaces and the API), by port.

ReassignFile

FieldType
interface (optional)text or nullThe interface whose interface.yaml this is; absent for resources.yaml.
lineslist of ReassignLine
pathtextRelative to the project, /-separated.
resourceslist of textresources.yaml entries whose owner changes.

ReassignLine

FieldType
aftertext
beforetext
linenumber1-based.

ReassignPreview

FieldType
changednumberOwner values that would change, over all files.
commandtextThe same change made with the CLI in the project's folder.
fileslist of ReassignFileFiles that would change, each with its changed lines.
knownlist of textThe owners there are, when nothing matched from.
patchtextThe whole change as a unified diff, for git apply in the project's repository.
refusedlist of ReassignRefusedFiles with owner: <from> that can't be changed safely by a program (an unusual layout): change them by hand. While any is listed, patch and command are empty.
to_person (optional)OwnerPerson or null
to_reachtextThe new owner, as reach in /owners says it.
warning (optional)text or nullSaid when the new owner isn't an account (or names a disabled one, or several).

ReassignRefused

FieldType
errortext
interface (optional)text or nullAbsent for resources.yaml.
pathtext

ReassignRequest

FieldType
fromtextThe current owner (ignores case).
interfaces (optional)list of textOnly these interfaces. With neither interfaces nor resources, everything moves.
resources (optional)list of textOnly these resources.yaml entries (by name).
totextThe new owner.

Report

The whole report: what ductus doctor prints and --report saves.

FieldType
checkslist of Check
environmenttext
generated_attext
hosttext
notes (optional)list of textLines about this machine said before the checks (on Windows: the service's account).
secondsnumber
versiontext

SeenCertificate

A server's certificate as a check saw it.

FieldType
chain_earliest (optional)text or nullWhose that is, when it isn't the server certificate's (an intermediate).
chain_not_after (optional)time (RFC 3339) or nullThe earliest expiry in the chain the server presented.
error (optional)text or nullWhy the check failed: couldn't connect, not trusted, expired.
fingerprint (optional)text or nullSHA-256 of the server certificate (DER), hex.
hosttext
issuer (optional)text or null
not_after (optional)time (RFC 3339) or null
not_before (optional)time (RFC 3339) or null
portnumber
resourcetextWhat it belongs to: empi (resources.yaml), alerts.smtp, alerts.<channel>, alerts.heartbeat, <interface>/<destination>.
subject (optional)text or nullThe server certificate's common name (or organisation); none if none was read.
trustedtrue or falseThis server trusts it (the system's trusted certificates).

SourceAllow

A listener's allow: list and how it's doing.

FieldType
entrieslist of textThe list as written: addresses, subnets and host names.
last_refused (optional)text or nullThe last refused connection's address and time.
last_refused_at (optional)time (RFC 3339) or null
nameslist of AllowedNameEach host name in it with what it resolves to now.
refusednumberConnections refused since the engine started (0 when this engine doesn't hold the listener).

Step

One step of a check: DNS, connect, TLS, login, HTTP, commits, folder, port.

FieldType
detailtext
nametext
outcomeOutcome

Alerts

Alerts: what is wrong, acknowledging and silencing, and where notifications go.

GET /api/v1/alerts

Alerts: open ones first, and what happened lately.

Who may call it: anyone signed in (a person or a token).

Answers

GET /api/v1/alerts/channels

Where alerts go, and how sending has gone lately. Secrets are never shown. Operator.

Who may call it: an operator or admin.

Answers

POST /api/v1/alerts/channels/{name}/test

Send a test notification to a channel now (it says it is a test). Answers 502 with the reason when it can't be sent. Operator; logged.

Who may call it: an operator or admin.

Parameters

InType
name (required)pathtextChannel name.

Answers

GET /api/v1/alerts/{id}

One alert and its history.

Who may call it: anyone signed in (a person or a token).

Parameters

InType
id (required)pathnumberAlert id.

Answers

POST /api/v1/alerts/{id}/ack

Acknowledge an open alert: someone is on it. No reminders until it gets worse or recovers. Operator.

Who may call it: an operator or admin.

Parameters

InType
id (required)pathnumberAlert id.

Answers

POST /api/v1/alerts/{id}/silence

Silence an alert: nothing is sent about it (fired, worse, reminders, recovery) until the time is up. It still shows. Works before anything fires (planned work). A silence is not a pause: messages still wait. Operator.

Who may call it: an operator or admin.

Parameters

InType
id (required)pathnumberAlert id.

Body (application/json): SilenceRequest

Answers

DELETE /api/v1/alerts/{id}/silence

End a silence early. Operator.

Who may call it: an operator or admin.

Parameters

InType
id (required)pathnumberAlert id.

Answers

Shapes

The JSON bodies the operations above take and return.

AlertDetail

FieldType
alertAlertInfo
eventslist of AlertEventNewest first (the last 100).

AlertEvent

FieldType
alertnumber
attime (RFC 3339)
by (optional)text or nullWho acknowledged or silenced it.
failednumber
idnumber
keytext
kindtextfired, escalated, reminded, recovered, acknowledged, silenced, unsilenced.
sentnumberIts notifications: sent, still to send (waiting for quiet hours to end, or being retried), and given up on.
severity (optional)text or null
state (optional)text or nullThe state then (for recovered: what it recovered from).
summary (optional)text or null
waitingnumber

AlertInfo

FieldType
acknowledged_at (optional)time (RFC 3339) or null
acknowledged_by (optional)text or null
destination (optional)text or nullThe destination; for a certificate, host:port.
idnumber
interfacetextThe interface; for a certificate, its resource.
keytextiface:<interface>, dest:<interface>/<destination> or cert:<resource>:<host>:<port>.
last_notified_at (optional)time (RFC 3339) or nullWhen people were last told (or will be, once quiet hours end).
mutedtrue or falseIts interface says alert: { mute: true }: nothing is ever sent about it.
notificationsnumberNotifications sent since it fired.
recovering_since (optional)time (RFC 3339) or nullFine again since then, but not for alerts.settle yet (it may still reopen).
severity (optional)text or nullwarning or critical while open.
silence_reason (optional)text or null
silenced_by (optional)text or null
silenced_until (optional)time (RFC 3339) or nullSet while silenced: nothing is sent until then.
since (optional)time (RFC 3339) or nullBad since.
statetextok, or what is wrong: error, stalled, behind (a destination), stopped, silent, missed (an interface), certificate (expiring or expired).
summary (optional)text or nullThe health view's words, as of the last check.

AlertsOverview

FieldType
alertslist of AlertInfoEvery interface and destination, open alerts first (critical, then oldest).
configuredtrue or falsealerts: is in ductus.yaml. Without it nothing is judged or sent.
recentlist of AlertEventThe latest events, newest first.

ChannelCounts

FieldType
failednumber
last_error (optional)text or null
sentnumber
waitingnumber

ChannelInfo

FieldType
emaillist of text
failednumber
last_error (optional)text or null
last_sent (optional)time (RFC 3339) or null
nametext
sentnumberNotifications in the last 7 days: sent, still to send, given up on.
waitingnumber
webhook (optional)text or nullScheme and host only: the rest of a webhook URL is often its secret.
webhook_format (optional)text or nulljson, slack or teams.

ChannelsOverview

FieldType
channelslist of ChannelInfo
configuredtrue or false
heartbeat (optional)text or nullThe heartbeat URL (scheme and host) and how often.
owner_notifications (optional)ChannelCounts or null
ownerslist of OwnerReachOwners named in interface.yaml, when a rule sends to owner.
quiet (optional)text or null"22:00-06:00" and which severities wait, if set.
repeat (optional)text or nullReminders: 4h, or none.
smtp (optional)SmtpInfo or null

ErrorBody

FieldType
errortextWhat went wrong, in plain words.

OwnerReach

FieldType
addresseslist of textWhere alerts for this owner go; empty: nowhere (no user with that name and an email, not in owners:, not an address).
ownertext

SilenceRequest

FieldType
fortextHow long: 30m, 4h, 2d (at most 30 days).
reason (optional)text or nullWhy (planned work, a known outage): shown to everyone who sees the alert.

SmtpInfo

FieldType
fromtext
hosttext
portnumber
tlstextstarttls, tls or none.
username (optional)text or null

TestResult

FieldType
channeltext
senttrue or false

Audit

Who did what: the audit log (the audit permission) and your own activity.

GET /api/v1/audit

The audit log, filtered. Needs the audit permission.

Who may call it: anyone with the audit permission.

Parameters

InType
actorquerytextA user's or token's name (a user's tokens count as theirs).
actionquerytextAn action (message.dismiss) or a group of them (message, user, session).
interfacequerytext
messagequerynumberA message id.
fromquerytime (RFC 3339)From this time (inclusive).
toquerytime (RFC 3339)Until this time (exclusive).
beforequerynumberEntries older than this id (the previous page's next_before).
afterquerynumberEntries newer than this id, oldest first (what an export follows).
formatquerytextjson (default: a page, newest first) or ndjson (one entry per line, oldest first, up to limit, default 10,000, at most 100,000: continue with after the last id). An export is itself logged.
limitquerynumberPage size, at most 500 (default 100).

Answers

  • 200 With format=ndjson: application/x-ndjson, one entry (as in entries) per line (application/json): AuditPage
  • 400 (application/json): ErrorBody
  • 403 (application/json): ErrorBody

Shapes

The JSON bodies the operations above take and return.

AuditEntry

FieldType
actiontextmessage.payload.read, message.dismiss, user.create, ...
actortextThe user's or token's name when it happened.
actor_kindtextuser, token, system (the engine, or someone at the server's command line) or anonymous (a sign-in as a name nobody has).
attime (RFC 3339)
detail (optional)object or nullMore about it, by action (never a password, token or payload).
idnumber
interface (optional)text or null
ip (optional)text or nullThe address the request came from (a proxy's, if there is one).
message (optional)number or null
outcometextok, denied or failed.
subject (optional)text or nullWhat it was done to: a username, a token, interface/destination.
via (optional)text or nullsession (the web UI), token, cli or engine; none for entries from before the log.

AuditPage

FieldType
entrieslist of AuditEntryNewest first.
next_before (optional)number or nullPass as before for the next (older) page; absent on the last page.

ErrorBody

FieldType
errortextWhat went wrong, in plain words.

Retention

How long messages are kept, and changes to it.

GET /api/v1/retention

Every interface's retention, expired partitions that had to stay, and the held area.

Who may call it: anyone signed in (a person or a token).

Answers

POST /api/v1/retention/{name}/confirm

Confirm an interface's shorter retention, which deletes its messages past it. It's applied at once (or when the running change finishes). Operator.

Who may call it: an operator or admin.

Parameters

InType
name (required)pathtextInterface name.

Answers

Shapes

The JSON bodies the operations above take and return.

Change

FieldType
fromnumber
rows_left (optional)number or nullMessages still to move or delete (an estimate).
started_attime (RFC 3339)
tonumber

ErrorBody

FieldType
errortextWhat went wrong, in plain words.

Held

FieldType
bytesnumberDisk it uses, indexes included.
interfaceslist of HeldInterfaceInterfaces with unfinished messages in it.
messagesnumberMessages in it, finished ones included (they go when their month drops).
monthslist of HeldMonthBy month: a month is dropped once nothing in it is unfinished.
pendingnumber
quarantinednumber

HeldInterface

FieldType
nametext
pendingnumber
quarantinednumber

HeldMonth

FieldType
bytesnumber
messagesnumber
monthtextThe month, 2026-09.
partitiontext
pendingnumber
quarantinednumber

InterfaceRetention

FieldType
change (optional)Change or null
configurednumberWhat interface.yaml (or ductus.yaml) says, in days.
daysnumberThe retention in force: its new messages are kept this many days.
nametext
needs_confirmationtrue or falseThe waiting value deletes messages: confirm it (POST /retention/{name}/confirm).
waiting (optional)number or nullA configured value not applied yet: a change is running, or it's shorter and needs a confirmation.
would_delete (optional)number or nullHow many messages the waiting value would delete now, when it needs a confirmation.

KeptPartition

FieldType
kept_forlist of textInterfaces that now keep their messages longer, with messages still in it.
partitiontext
pendingnumber
quarantinednumber
tiernumberIts retention tier, in days.

RetentionReport

FieldType
heldHeldThe held area as of the last drop: messages past their retention that someone still needs (pending for a destination, or quarantined and kept until resolved).
interfaceslist of InterfaceRetention
keptlist of KeptPartitionExpired partitions kept at the last drop, and why.
last_drop_at (optional)time (RFC 3339) or nullWhen expired partitions were last dropped (hourly).

Tokens

API tokens (admin).

GET /api/v1/tokens

Live tokens, everyone's (never their secrets). Admin.

Who may call it: an admin.

Answers

POST /api/v1/tokens

Create a service token (it belongs to nobody). Its secret is in this answer only. Admin.

Who may call it: an admin.

Body (application/json): NewToken

Answers

DELETE /api/v1/tokens/{name}

Revoke a token: it stops working at once. Admin.

Who may call it: an admin.

Parameters

InType
name (required)pathtextToken name.

Answers

Shapes

The JSON bodies the operations above take and return.

CreatedToken

FieldType
secrettextThe token itself: shown this once, never stored (only its hash is).
tokenTokenInfo

ErrorBody

FieldType
errortextWhat went wrong, in plain words.

NewToken

FieldType
nametextWho or what uses it, e.g. ci or monitoring.
permissions (optional)list of text or nullpayloads and/or audit. None by default: a token can't read message payloads unless it is given the permission.
role (optional)text or nullviewer (default), operator or admin.

TokenInfo

FieldType
created_attime (RFC 3339)
last_used_at (optional)time (RFC 3339) or nullTo the minute.
nametext
owner (optional)text or nullThe user it belongs to; none for a service token made by an admin.
permissionslist of textpayloads, audit (admins' tokens have all).
roletextviewer, operator or admin.

Configuration files

A project is a folder of plain files, kept in Git: what the engine runs is what's in them. These pages list every setting each file takes, with what it does, why and when you'd use it, its default and an example.

FileWhat it holds
interface.yamlinterface.yaml: one interface's settings. Where its messages come from (source), how they're read (format), where they go (destination, or destinations for several), and how they're delivered, kept, watched and documented. Its code is in the script beside it.
resources.yamlresources.yaml: what many interfaces share, each defined once by a name: databases, SFTP servers, HTTP APIs, directories for signing in, and certificates.
ductus.yamlductus.yaml: the project's settings, for every interface in it and the engine that runs them: how long messages are kept, the REST API and web UI, how people sign in, the audit log, alerts, and the engine's own connection checks.
environments/*.yamlenvironments/<name>.yaml: one environment's values (dev.yaml, test.yaml, prod.yaml). The engine reads the one it's started with (--env prod, or DUCTUS_ENV), and every ${NAME} in interface.yaml, resources.yaml and ductus.yaml's alerts: takes its value from here. The same project then runs anywhere, with only this file differing.
docs-schema.yamldocs-schema.yaml: the documentation fields your company keeps about its interfaces, destinations and resources, beyond what Ductus asks for (a BAA reference, a change ticket, a runbook link, the analyst). Values go in custom: beside each one; ductus validate checks them, and the web UI shows and filters by them. The engine itself ignores them.

Values from the environment. In interface.yaml, resources.yaml and the alerts: section of ductus.yaml, any value may be ${NAME} (from environments/<env>.yaml) or ${secret:NAME} (from the encrypted secrets/<env>.json), filled in for the environment the engine runs in. A value that is only a reference keeps the variable's type: port: ${EPIC_PORT} is a number.

In your editor. ductus types (and ductus test, ductus validate) writes these files' JSON Schemas into the project (.ductus/schema/) and tells VS Code where they are, so with the YAML extension (Red Hat's) you get completion, these descriptions on hover, and mistakes underlined as you type. The schemas come with the engine you have installed, so they match it; run ductus types again after an upgrade. They're also in the repository, docs/schema/.

Durations are written with their unit: 500ms, 90s, 15m, 24h, 7d.

interface.yaml

interface.yaml: one interface's settings. Where its messages come from (source), how they're read (format), where they go (destination, or destinations for several), and how they're delivered, kept, watched and documented. Its code is in the script beside it.

Each interface has a folder of its own under interfaces/, holding this file, its code, its tests and sample messages. Only name, source and a destination must be given; everything else has a default that's safe to start with.

Schema for editors: docs/schema/interface.json (written into a project by ductus types). Files: interfaces/**/interface.yaml.

name

Type: text · Required

The interface's name, as the database, the web UI, logs, alerts and routes (destination: { interface: <name> }) know it. Use the folder's name, as ductus new interface does, and keep it short and stable: lower-case words joined by -.

Messages are stored under the name, so a renamed interface is a new one: the old name's history stays under the old name, and messages still waiting there are sent by nobody. Rename only when nothing is waiting. Required. Example: name: adt-epic-to-lab.

source

Type: settings, below, or text · Required

Where messages come from: exactly one of mllp, http, file, sftp, schedule or internal, each with its own settings. Required. Example: source: { mllp: { port: 6661 } }.

source.mllp

Type: settings, below

HL7 v2 over MLLP: the engine listens on port, and senders (an EHR, a lab system) connect and send. Each message is stored before its ACK goes back, so a sender that has its ACK never has to send it again. Example: source: { mllp: { port: 6661 } }.

source.mllp.host

Type: text

The address to listen on: 0.0.0.0 (every IPv4 address of the server), :: (every address, IPv6 too), one of the server's addresses, or 127.0.0.1 (this machine only, for testing). Default: 0.0.0.0. Example: host: 10.20.0.15.

source.mllp.port

Type: number · Required

The port senders connect to. Each MLLP source needs a port of its own; ductus new interface picks a free one from ports.mllp in ductus.yaml, and ductus validate reports two interfaces on one port. Required. Example: port: 6661.

source.mllp.tls

Type: settings, below

Accept only TLS on this port: the engine presents the certificate named here, and with clients, senders must present one too. Senders on plain MLLP can't connect then, so agree on a date with them. Default: plain MLLP. Example: tls: { certificate: ductus-mllp }.

source.mllp.tls.certificate

Type: text · Required

The certificate this listener presents: a certificates: entry in resources.yaml with the certificate and its key, for the name senders connect to. A certificate the engine can't use (expired, its key doesn't match) stops the start with the reason; renewed files are picked up within a minute, without a restart. Required. Example: certificate: ductus-mllp.

source.mllp.tls.clients

Type: settings, below

Ask senders for a certificate of their own, and refuse any connection without one that ca issued and, with subjects, that names an allowed system. A client certificate says which system is connecting, beside allow:'s where from. Default: no client certificates asked for. Example: clients: { ca: hospital-ca, subjects: ["CN=*.lab.hospital.org"] }.

source.mllp.tls.clients.ca

Type: text · Required

The CA that issues senders' certificates: a certificates: entry holding the CA's certificate (ca or ca_file). Only certificates it issued are taken. Required. Example: ca: hospital-ca.

source.mllp.tls.clients.subjects

Type: list of text

Which of that CA's certificates are taken: a certificate matching any one pattern. A pattern is ATTR=value parts separated by commas, all of which must match, with * and ? wildcards and case ignored: CN, O, OU, C, L, ST, DC, or DNS for the certificate's DNS names. Needed when the CA issues certificates to more than your senders (a hospital-wide CA). Default: any certificate the CA issued. Example: subjects: ["CN=bridges*.hospital.org", "O=Quest, OU=Lab"].

source.mllp.allow

Type: list of text

Who may connect: addresses (10.1.30.15), subnets (10.1.20.0/24) and host names (looked up at start and every 5 minutes). Anyone else is disconnected before a byte is read and logged once a minute per address. A second line of defence beside the firewall, and the way to say "only the lab's interface engine". Default: anyone who can reach the port. Example: allow: [10.1.20.0/24, epic-bridge01.hospital.org].

source.http

Type: settings, below

An HTTP endpoint: the engine listens on port for requests to path. In queue mode the body is stored as a message and answered 202 Accepted; in respond mode the code answers the request itself (a REST API, a lookup). Several HTTP interfaces can share a port with different paths, if they have the same tls and allow. Example: source: { http: { port: 8080, path: /orders } }.

source.http.host

Type: text

The address to listen on: 0.0.0.0 (every IPv4 address of the server), :: (every address, IPv6 too), one of the server's addresses, or 127.0.0.1 (this machine only). Default: 0.0.0.0. Example: host: 10.20.0.15.

source.http.port

Type: number · Required

The port to listen on. HTTP interfaces may share one (each with its own path); ductus new interface picks a free one from ports.http in ductus.yaml. Required. Example: port: 8080.

source.http.tls

Type: settings, below

HTTPS only, presenting the certificate named here; with clients, callers must present one too. Interfaces sharing a port must name the same. Default: plain HTTP. Example: tls: { certificate: ductus-api }.

Its settings are those of source.mllp.tls.

source.http.allow

Type: list of text

Who may connect: addresses, subnets and host names, as for MLLP. Checked before the request is read, so interfaces sharing a port must have the same list. Default: anyone who can reach the port. Example: allow: [10.1.20.0/24].

source.http.path

Type: text · Required

The path requests come to, starting with /. {name} parts match one path segment and are given to the code (/patients/{mrn} takes /patients/12345). Two interfaces on one port can't have the same method and path. Required. Example: path: /patients/{mrn}.

source.http.method

Type: text

The HTTP method this interface answers: GET, POST, PUT, PATCH or DELETE. Default: POST. Example: method: GET.

source.http.mode

Type: queue, respond

  • queue: store the request body as a message, answer 202 Accepted once it's stored, and deliver it like any other message. For senders that push data.
  • respond: run the code on the request and send its result back as the response, at once. Nothing is stored or queued, and there's no destination; if something fails, the caller gets an error status and decides whether to try again. For REST APIs and lookups that other systems call and wait on.

Default: queue. Example: mode: respond.

source.file

Type: settings, below

Files dropped in a folder on this server (or a share it mounts): each file, or each record in it with split: records, becomes a message. A file is moved or deleted only after everything in it is stored. Example: source: { file: { dir: in, pattern: "*.hl7" } }.

source.file.server

Type: text

The SFTP server to read from: an sftp: entry in resources.yaml, which holds its address and login. Only for sftp sources, and required there. Example: server: lab_server.

source.file.dir

Type: text · Required

The folder to watch. A local folder is relative to the interface's folder unless written whole (/data/inbound/lab, or on Windows D:\inbound\lab, in single quotes); an SFTP folder is relative to the folder the login starts in. Only files directly in it are read, not subfolders. Required. Example: dir: inbound.

source.file.pattern

Type: text

Which file names to pick up, with * (any characters) and ? (one character). Names starting with . are never picked up, since writers often use them for files still being written. Default: * (every file). Example: pattern: "ADT_*.hl7".

source.file.poll

Type: duration

How often the folder is looked at, at least 100ms. A file is picked up once its size and time are the same at two looks in a row, so a file still being written is never read half-done: a file appears one to two polls after its writer finishes. Lower it for quicker pickup; on SFTP each look is a listing on the server. Default: 10s. Example: poll: 30s.

source.file.marker

Type: text

Pick a file up only once a marker file next to it exists: with marker: .done, a.csv is read once a.csv.done appears. For writers that say when they're finished (many mainframe and batch jobs do); the file is then read at the next look, without waiting for it to stop changing. The marker goes with the file. Default: none (wait until the file stops changing). Example: marker: .done.

source.file.after

Type: move, delete

What happens to a file once everything in it is stored: move it to archive_dir, or delete it. Moving keeps a copy to look at or drop in again; the engine never deletes what it moved, so clean the archive yourself. Default: move. Example: after: delete.

source.file.archive_dir

Type: text

Where after: move puts files, relative to the interface's folder (or the SFTP login folder) unless written whole. Default: archive inside dir. Example: archive_dir: /data/archive/lab.

source.file.split

Type: none, records

  • none: each file is one message.
  • records: each record in a file is a message, read as a stream, so a file of any size works: an HL7 batch file is split at each MSH, CSV at each row (the header line kept with every row), fixed-length, text and JSON Lines at each line. If the engine stops halfway, it carries on from the last record stored.

A file as one message is limited to 256 MB; split bigger ones. XML files are always one message each. Default: none. Example: split: records.

source.sftp

Type: settings, below

Files on an SFTP server, named by server (an sftp: entry in resources.yaml); the same options as file. The engine logs in, picks up what's ready, and moves or deletes it on the server. Example: source: { sftp: { server: lab_server, dir: outbound } }.

Its settings are those of source.file.

source.schedule

Type: settings, below

No input: the code runs on a timer (every or cron) and what it returns becomes the message, for jobs that pull (a nightly query, an API polled every few minutes). A run is skipped while the previous one is still waiting. Needs a script. Example: source: { schedule: { cron: "0 2 * * *" } }.

source.schedule.every

Type: duration

Run at start, then at this interval: for polling (an API every 5m). Give this or cron, not both. Example: every: 15m.

source.schedule.cron

Type: text

Run at the times a five-field cron expression gives (minute, hour, day of month, month, day of week), in the server's local time: for jobs at a time of day. Runs missed while the engine was down are made up by one run at start, not one per missed time. Give this or every. Example: cron: "0 2 * * *" (02:00 every day).

script

Type: text

The TypeScript file with the interface's code, relative to its folder. It exports transform(msg, meta), plus any function a destination names with transform:.

ductus validate type-checks it and the engine loads it at start. Without a script, messages go to the destinations as they came. A schedule source and an HTTP source in respond mode need one: there, the code makes the message or the answer. Example: script: transform.ts.

format

Type: hl7, json, xml, text, csv, or { csv | fixed: options }

What the messages are, so the code gets them parsed and keys can be read: hl7, json, xml, text, csv or fixed (fixed-length records, with their fields' widths).

hl7 gives the code segments and fields (msg.get("PID-3.1")); json and xml a document; csv rows (objects by column name with a header line); fixed an object per record; text the payload as a string. An MLLP source always carries HL7. What the code returns is written in this format too, unless output says otherwise. Default: hl7. Example: format: { csv: { delimiter: "|" } }.

format.csv

Type: settings, below

Delimited text with its options (delimiter, header). format: csv alone means commas and a header line. Example: format: { csv: { delimiter: "|", header: false } }.

format.csv.delimiter

Type: text

The one character between fields: ,, |, ; or a tab ("\t"). Quote it in YAML. Default: ",". Example: delimiter: "|".

format.csv.header

Type: true or false

Whether the first line holds the column names. With it, each record is an object by column name (row.mrn) and keys name columns; without it, each record is a list of fields and keys are column numbers from 1. Default: true. Example: header: false.

format.fixed

Type: settings, below

Fixed-length records: each field at a set column and width, listed in fields. Each line of a file is a record. Example: format: { fixed: { fields: [{ name: mrn, width: 10 }, { name: name, width: 30 }] } }.

format.fixed.fields

Type: list of settings, below · Required

The record's fields, in order, each with its name and width: what the code sees as the record's properties, and what's written back in the same columns. At least one. Example: fields: [{ name: mrn, width: 10 }, { name: last_name, width: 20 }].

format.fixed.fields.name

Type: text · Required

The field's name, as the code reads it (record.mrn) and as keys name it. Required. Example: name: mrn.

format.fixed.fields.width

Type: number · Required

How many characters the field takes, at least 1. Required. Example: width: 10.

format.fixed.fields.start

Type: number

The column the field starts at, counting from 1, for layouts with gaps or fields out of order. Default: right after the previous field (1 for the first). Example: start: 41.

format.fixed.fields.align

Type: left, right

When writing: left pads after the value (names), right before it (amounts, numbers). Default: left. Example: align: right.

format.fixed.fields.pad

Type: text

When writing: the character that fills the field's unused width. Default: a space. Example: pad: "0" (amounts as 0000012.50).

format.fixed.trim

Type: true or false

Remove the padding around each field's value when reading ("SMITH " reads as "SMITH"). Turn it off where spaces are part of the value. Default: true. Example: trim: false.

output

Type: hl7, json, xml, text, csv, or { csv | fixed: options }

The format the code's results are written in, when it isn't format: an HL7 feed turned into JSON for an API, a CSV file turned into HL7. Same values as format. A destination can set its own output. Default: the same as format. Example: output: json.

Its settings are those of format.

ordering

Type: settings, below

Which messages must be delivered in the order they arrived: those with the same key (one patient's), in lanes spread over the workers. Without it, the whole interface is one lane, delivered strictly in arrival order. Default: no key (one lane). Example: ordering: { key: PID-3.1 }.

ordering.key

Type: text

The field whose value decides which messages stay in order with each other, as a key path in the interface's format: usually the patient (PID-3.1, the MRN), or an order or account. Messages with the same value are delivered in arrival order; different values can overtake each other, so one patient's failing message doesn't hold up everyone.

Choose the narrowest value correctness allows: by patient for ADT, by order for results. A message whose field is empty goes to the lane of the empty value. Without a key, the whole interface is delivered in one strict order, one message at a time per destination. Default: none. Example: key: PID-3.1.

ordering.partitions

Type: number

How many lanes messages are spread over by their key. Messages with the same key value always go to the same lane, and each lane is delivered strictly in arrival order, so more lanes let more patients' messages move at once while each patient's stay in order. Without a key this is ignored: the whole interface is one lane.

The lanes are divided among the workers (one per destination in the shared pool; workers with isolation: dedicated), and a worker with no lane has nothing to do. The default suits up to 64 workers; to give N workers 64 lanes each, use 64 * N.

Change it only when nothing is waiting (ductus drain --wait first). Lowered with messages waiting, the engine refuses to start: they'd be in lanes no worker reads. Raised, it starts, but a patient's waiting messages and their new ones can go out of order until the backlog drains. Default: 64. Example: ordering: { key: PID-3.1, partitions: 128 }.

isolation

Type: shared, dedicated

Where the interface's code runs.

  • shared: on the engine's shared pool, one thread per CPU core, each running many interfaces. Cheap: most interfaces belong here.
  • dedicated: on threads of its own (workers of them, each with its own copy of the JavaScript engine), so its load and its runaways touch no other interface, and it can have database connections of its own (dedicated_pools).

Use dedicated for a busy interface that needs more than one worker (with an ordering key, so there are lanes to share out), or for heavy or untrusted code. Each dedicated worker costs a thread and up to isolates.heap_mb of memory. Default: shared. Example: isolation: dedicated.

workers

Type: number

How many workers a dedicated interface has, each a thread with its own JavaScript engine, taking a share of the lanes (ordering.partitions). Only with isolation: dedicated.

More workers help only with an ordering key (without one there's a single lane, and one worker does everything). Up to about the number of cores helps; past that they wait for each other. Default: 4. Example: workers: 8.

dedicated_pools

Type: names, each with number

Database connections of the interface's own, by resources.yaml database name: { patient_db: 4 } gives it 4 connections to patient_db that no other interface uses. Only with isolation: dedicated.

Its db("patient_db") calls use these first, so a busy interface can't take every connection of the shared pool (max_connections) from the others, nor theirs from it. Add them when a dedicated interface's lookups queue for connections, or when it must keep working while other interfaces are busy. Default: none. Example: dedicated_pools: { patient_db: 4 }.

durability

Type: strict, relaxed

When a sender gets its acknowledgement (ACK, or HTTP 202): after the message is safely on the database's disk, or as soon as the database has it.

  • strict: the ACK waits until the database has flushed the message to disk. Once a sender has its ACK, the message survives a crash or power loss of any machine.
  • relaxed: the ACK goes out without waiting for the flush. One sender connection can go much faster: with strict it can't send faster than one message per disk flush (about 400 a second at 2.4 ms a flush; ductus doctor measures yours). The cost: if the database server crashes or loses power, the last fraction of a second of acknowledged messages is lost, and the sender won't resend them because it has its ACK. An engine crash alone loses nothing in either mode.

Use relaxed only where one connection's peak is more than the disk allows and a lost message can be recovered another way (a resend from the source system, a nightly reconciliation). Before that, try a faster disk or more sender connections, which share flushes. Default: strict. Example: durability: relaxed.

destination

Type: a connector (mllp, file, sftp, http, interface) and options

Where messages go, when they go to one place: one connector (mllp, file, sftp, http or interface) and its options. Its name is default (in the web UI, alerts and ductus pause adt-in default).

Use destinations instead to send to several places. An HTTP source in respond mode has none: its answer is the result. Required otherwise (this or destinations). Example: destination: { mllp: { host: lab.hospital.org, port: 7001 } }.

destination.transform

Type: text

The function in the interface's script that prepares messages for this destination: its exported name. Each destination runs its function on the message as it was received, not on another destination's result, so toLab and toBilling can each shape it for their receiver. Default: transform. Example: transform: toBilling.

destination.output

Type: hl7, json, xml, text, csv, or { csv | fixed: options }

destination.output.csv

Type: settings, below

Delimited text with its options (delimiter, header). format: csv alone means commas and a header line. Example: format: { csv: { delimiter: "|", header: false } }.

Its settings are those of format.csv.

destination.output.fixed

Type: settings, below

Fixed-length records: each field at a set column and width, listed in fields. Each line of a file is a record. Example: format: { fixed: { fields: [{ name: mrn, width: 10 }, { name: name, width: 30 }] } }.

Its settings are those of format.fixed.

destination.retry

Type: settings, below

destination.retry.max_attempts

Type: number

Give up after this many failed delivery attempts and quarantine the message ("gave up after N attempts: …"). While a message is being retried it holds up its lane, so set this where a person looking at it beats waiting for the receiver (a receiver that refuses some messages for good with errors that look temporary). Leave it unset where every message must get there once the receiver is back. The code's own temporary errors (a database down) are always retried. Default: no limit. Example: max_attempts: 10.

destination.retry.initial_backoff_ms

Type: number

The first wait after a failure, in milliseconds; each later one doubles, up to max_backoff_ms. Default: 1000. Example: initial_backoff_ms: 5000.

destination.retry.max_backoff_ms

Type: number

The longest wait between tries, in milliseconds: how soon after a receiver comes back the waiting messages start moving, at worst. Default: 60000. Example: max_backoff_ms: 300000.

destination.rate_limit

Type: settings, below

destination.rate_limit.per_minute

Type: number · Required

How many messages a minute the destination may be sent, at least 1: what the receiver (or its vendor's quota) allows. Required. Example: per_minute: 600 (10 a second).

destination.rate_limit.mode

Type: average, hard

  • average: messages go at per_minute on average, and up to burst may go at once after a quiet spell (a token bucket).
  • hard: never more than per_minute in any one calendar minute, for quotas counted that way.

Default: average. Example: mode: hard.

destination.rate_limit.burst

Type: number

With mode: average: how many may go at once after a quiet spell. Lower it for a receiver that can't take a burst at all. Default: per_minute. Example: burst: 20.

destination.rate_limit.over_limit

Type: wait, drop

What happens to messages over the limit: wait (they're held, in order, until they're allowed) or drop (they're filtered, with the reason, and never sent). Drop only what is worthless late, such as frequent status updates where the next one replaces the last. Default: wait. Example: over_limit: drop.

destination.save_output

Type: true or false

destination.application

Type: text

The receiving application, when this destination's differs from about.to (an interface that sends to several systems). ductus impact and the web UI use it. Default: about.to.application. Example: application: Sunquest Lab.

destination.vendor

Type: text

The receiving application's vendor, when this destination's differs from about.to. Default: about.to.vendor. Example: vendor: Sunquest.

destination.owner

Type: text

Who to ask about this destination, when it isn't the interface's about.owner (its code or its receiver is another team's): best written as an email address. Alert rules that send to owner reach them for this destination's alerts. Default: about.owner. Example: owner: billing-it@hospital.org.

destination.monitor

Type: settings, below

A destination's own expectations (monitor: on the destination).

destination.monitor.behind

Type: messages, age, or { messages, age }

When this destination's queue counts as behind, in place of the interface's monitor.behind: 500, 10m, or { messages: 500, age: 10m }. Default: the interface's. Example: behind: 2h.

destination.monitor.behind.messages

Type: number

Behind once this many messages wait for the destination, at least 1. Default: no count limit. Example: messages: 500.

destination.monitor.behind.age

Type: text

Behind once the oldest waiting message has waited this long, at least 1s. Default: no age limit. Example: age: 10m.

destination.custom

Type: names and values of your own

Your company's documentation fields for this destination (those whose applies_to in docs-schema.yaml includes destination). Default: none. Example: custom: { analyst: ana@hospital.org }.

destination.mllp

Type: settings, below

Send each message over MLLP to a receiver (a lab system, another interface engine) and wait for its ACK: AA is delivered, AE is tried again, AR follows on_reject. One connection, kept open between messages. Example: mllp: { host: lab.hospital.org, port: 7001 }.

destination.mllp.host

Type: text · Required

The receiver's host name or address. With tls, its certificate must be for this name. Required. Example: host: lab.hospital.org.

destination.mllp.port

Type: number · Required

The receiver's port. Required. Example: port: 7001.

destination.mllp.timeout_ms

Type: number

How long one send may take, in milliseconds: connecting if needed, sending, and waiting for the ACK. Past it the connection is dropped and the message tried again later (it may have arrived: receivers that dedupe on MSH-10 take care of the repeat). Raise it for a receiver that is slow to answer; on Windows keep it above 2000. Default: 10000. Example: timeout_ms: 30000.

destination.mllp.ack

Type: wait, none

  • wait: wait for the receiver's ACK before the next message; the message counts as delivered only on AA.
  • none: send and carry on without reading an answer, for the rare receiver that never sends ACKs. Nothing then tells the engine a message was refused or lost.

Default: wait. Example: ack: none.

destination.mllp.on_reject

Type: quarantine, retry

What an AR (the receiver rejects the message) does: quarantine it for a person to look at, or retry it like an AE, for receivers that answer AR when they're busy or half-down. A message that keeps being refused holds up its lane while it's retried. AE is always tried again. Default: quarantine. Example: on_reject: retry.

destination.mllp.tls

Type: true, or { ca, certificate }

Send over TLS. true trusts the certificates this server trusts (the system's store); { ca, certificate } also trusts the CA named by ca, and presents the client certificate named by certificate, both certificates: entries in resources.yaml. A receiver whose certificate isn't trusted or isn't for host is refused and tried again later, with the reason in the log and on the destination. Default: plain MLLP. Example: tls: { ca: hie-ca, certificate: ductus-client }.

destination.mllp.tls.ca

Type: text

A CA to trust for this receiver besides the ones this server trusts: a certificates: entry in resources.yaml holding the CA's certificate, for a receiver whose certificate comes from a private CA. Default: the system's trusted CAs only. Example: ca: hie-ca.

destination.mllp.tls.certificate

Type: text

The client certificate to present, for receivers that ask for one (mutual TLS): a certificates: entry with its key. Renewing its files takes effect without a restart. Default: none. Example: certificate: ductus-client.

destination.file

Type: settings, below

Write each message as a file in a folder of this server (or a share it mounts), named by the message's id and the output format (1234.hl7). Files are written under a hidden temporary name and renamed when complete, so a reader never sees half a file. Example: file: { dir: outbound }.

destination.file.dir

Type: text · Required

The folder to write to, made if missing. Relative to the interface's folder unless written whole. Required. Example: dir: /data/outbound/lab.

destination.sftp

Type: settings, below

Write each message as a file on an SFTP server, named like the file destination's. Files are written under a temporary name and renamed when complete, so a reader on the server never sees half a file. Example: sftp: { server: lab_server, dir: inbound }.

destination.sftp.server

Type: text · Required

The SFTP server: an sftp: entry in resources.yaml, which holds its address and login. Required. Example: server: lab_server.

destination.sftp.dir

Type: text · Required

The folder on the server to write to, relative to the folder the login starts in unless written whole. Required. Example: dir: inbound/adt.

destination.http

Type: settings, below

Send each message as an HTTP request (POST unless method says otherwise) to a url, or to a path under an API from resources.yaml (api: billing, which brings its URL, headers, timeout and TLS). A 2xx answer is delivered; 408, 429, 5xx and network errors are tried again; any other 4xx is quarantined (the receiver refused this message). Example: http: { api: billing, path: /charges }.

destination.http.url

Type: text

The address to send to, https:// or http://. Give this or api, not both. A token in it, or in headers, belongs in a secret: ${secret:NAME}. Example: url: "https://lab.example.org/api/orders".

destination.http.api

Type: text

An apis: entry in resources.yaml to send through: it brings the base URL, headers (an Authorization token) and timeout, so the same API is set up once for every interface and each environment. Give this or url. Example: api: billing.

destination.http.path

Type: text

With api: the path under its URL (/charges under https://billing/api/v2). Default: the API's URL itself. Example: path: /charges.

destination.http.method

Type: text

The HTTP method: POST, PUT, PATCH, or any other the receiver expects. Default: POST. Example: method: PUT.

destination.http.headers

Type: names, each with text

Headers sent with every request, added to the API's (and replacing an API header of the same name). Put tokens in secrets. Default: none (the API's, with api). Example: headers: { X-Facility: north }.

destination.http.timeout_ms

Type: number

How long a request may take, in milliseconds, before it's given up and tried again later. Default: the API's timeout, else 10000. Example: timeout_ms: 30000.

destination.http.ca

Type: text

With an https:// url: a CA to trust for this server besides the system's (a certificates: entry), when the receiver's certificate comes from a private CA. With api, the API's own ca applies instead. Example: ca: lab-ca.

destination.http.certificate

Type: text

With an https:// url: the client certificate to present (a certificates: entry with its key), for receivers that ask for one (mutual TLS). With api, the API's own applies instead. Example: certificate: ductus-client.

destination.interface

Type: text

Put each message into another interface's queue: name an interface that has source: internal. It's stored there in the same transaction that marks it sent here: no network hop, nothing lost or sent twice between the two. The other interface's format must be what this destination outputs, and routes can't form a loop (ductus validate checks both). Example: interface: adt-to-billing.

destinations

Type: names, each with a connector (mllp, file, sftp, http, interface) and options

Several places messages go, by a name you choose (lower-case letters, digits, - and _). Each gets every message and is delivered independently: its own queue, order, retries and pause, so one that's down holds up only itself. Each runs its own function on the original message (transform:, default transform).

A message is finished once every destination is done with it. Adding a destination later gives it only the messages still in flight, not older ones; removing one filters what was waiting for it. Use instead of destination, not with it. Example: destinations: { lab: { mllp: { host: lab.hospital.org, port: 7001 } }, archive: { file: { dir: archive } } }.

Its settings are those of destination.

retry

Type: settings, below

How a delivery that fails for a reason that may pass (a receiver down, a timeout, a database unreachable from the code) is tried again: waits doubling from initial_backoff_ms to max_backoff_ms, and given up only after max_attempts, if set. A destination can set its own. Default: retry forever, 1 s doubling to 60 s.

retry.max_attempts

Type: number

Give up after this many failed delivery attempts and quarantine the message ("gave up after N attempts: …"). While a message is being retried it holds up its lane, so set this where a person looking at it beats waiting for the receiver (a receiver that refuses some messages for good with errors that look temporary). Leave it unset where every message must get there once the receiver is back. The code's own temporary errors (a database down) are always retried. Default: no limit. Example: max_attempts: 10.

retry.initial_backoff_ms

Type: number

The first wait after a failure, in milliseconds; each later one doubles, up to max_backoff_ms. Default: 1000. Example: initial_backoff_ms: 5000.

retry.max_backoff_ms

Type: number

The longest wait between tries, in milliseconds: how soon after a receiver comes back the waiting messages start moving, at worst. Default: 60000. Example: max_backoff_ms: 300000.

dedupe

Type: settings, below

Recognize a message sent again within a window (a sender resending after a lost ACK, a file dropped twice) by the values of keys, and don't deliver it twice.

A repeat is stored with the status duplicate (so it can be found) but never queued. Use it when the receiver can't tolerate repeats and the sender is known to resend; most HL7 receivers already ignore a repeated control ID (MSH-10). Default: off. Example: dedupe: { keys: [MSH-10], window: 24h }.

dedupe.keys

Type: list of text

The fields whose values, together, make a message the same as one seen before, as key paths in the interface's format: HL7 MSH-10, JSON order.id, XML Order/@id, a CSV column or a fixed-length field's name. Choose fields a resend repeats and a new message never does: the sender's control ID, or an order number with its status. Default: [MSH-10] (HL7's message control ID). Example: keys: [ORC-2, ORC-5].

dedupe.window

Type: duration

How long a message's keys are remembered: a repeat within this long is a duplicate, after it a new message. Cover the longest a sender may take to resend (after a restart, a weekend). Default: 24h. Example: window: 7d.

rate_limit

Type: settings, below

The most messages a minute each destination is sent, for receivers that can't take bursts (a device, a vendor API with a quota). Messages over it wait, in order, or are dropped (over_limit: drop). A destination can set its own; either way each destination has its own allowance. Default: no limit. Example: rate_limit: { per_minute: 600 }.

rate_limit.per_minute

Type: number · Required

How many messages a minute the destination may be sent, at least 1: what the receiver (or its vendor's quota) allows. Required. Example: per_minute: 600 (10 a second).

rate_limit.mode

Type: average, hard

  • average: messages go at per_minute on average, and up to burst may go at once after a quiet spell (a token bucket).
  • hard: never more than per_minute in any one calendar minute, for quotas counted that way.

Default: average. Example: mode: hard.

rate_limit.burst

Type: number

With mode: average: how many may go at once after a quiet spell. Lower it for a receiver that can't take a burst at all. Default: per_minute. Example: burst: 20.

rate_limit.over_limit

Type: wait, drop

What happens to messages over the limit: wait (they're held, in order, until they're allowed) or drop (they're filtered, with the reason, and never sent). Drop only what is worthless late, such as frequent status updates where the next one replaces the last. Default: wait. Example: over_limit: drop.

timeout

Type: duration

The longest the interface's code may run for one message without pausing, from 10ms to 10m. Time spent waiting on db(), fetch() or api() doesn't count, only running code.

It's the guard against code that never ends, such as a loop over segments that an unusual message never lets finish. Past it, the code is stopped, its message is quarantined ("transform stopped: …") and the interface carries on; three in a row, or one that runs out of memory, stop the interface until the engine restarts. Interfaces in the shared pool wait while code on their thread runs, so a generous limit lets one runaway hold them all up that long.

Set it close to what the code needs: 500ms is plenty for most HL7 transforms. Raise it only for code that computes for seconds on purpose, and give that interface isolation: dedicated so no one else waits for it. Default: 5s. Example: timeout: 500ms.

about

Type: settings, below

What the interface is for, who owns it, and the systems on each side. ductus impact reads it to answer "which interfaces does this vendor's outage, breach or VPN change touch?", and the web UI and alerts show it. Nothing here changes how messages flow. Default: empty (require_about: true in ductus.yaml makes ductus validate insist). Example: about: { description: ADT from Epic to the lab, owner: lab-it@hospital.org }.

about.description

Type: text

What the interface does, in a sentence someone on call understands: shown in the web UI, ductus impact and alerts. Example: description: ADT from Epic to the Sunquest lab.

about.owner

Type: text

Who to ask about the interface: a person or a team, best written as an email address (lab-it@hospital.org), which matches their account and survives a change of name. An owner that names an account shows as that person in the web UI (and their "Mine" list); alert rules with to: owner mail them. ductus owners lists who owns what. Default: none. Example: owner: lab-it@hospital.org.

about.from

Type: settings, below

The system that sends (for an HTTP source in respond mode: the one that calls). Example: from: { application: Epic, vendor: Epic Systems, hosts: [10.20.30.0/24] }.

about.from.application

Type: text

The sending application, as people name it. ductus impact Epic finds every interface that names it here or as a receiver. Example: application: Epic.

about.from.vendor

Type: text

The sending application's vendor: ductus impact by vendor answers "what does this vendor's breach or VPN change touch?". Example: vendor: Epic Systems.

about.from.hosts

Type: list of text

Where the sender's connections come from: addresses, subnets (10.20.30.0/24) or host names. The engine can't learn these from the configuration, so a network change (a new subnet, a firewall move) can only be traced to interfaces that say. ductus validate warns when an allow: list doesn't cover them. Default: none. Example: hosts: [10.20.30.0/24, epic-bridge01.hospital.org].

about.to

Type: settings, below

The system messages go to. A destination's own application and vendor take precedence for that destination. Example: to: { application: Sunquest Lab, vendor: Sunquest }.

about.to.application

Type: text

The application, as people name it. ductus impact finds interfaces by it. Example: application: Sunquest Lab.

about.to.vendor

Type: text

The application's vendor. ductus impact finds interfaces by it. Example: vendor: Sunquest.

retention

Type: 1w, 3w or 7w

How long this interface's messages are kept: 1w, 3w or 7w (or 7d, 21d, 49d), when it should differ from retention in ductus.yaml.

A message is kept at least its window and is gone within a day (1w) or a week after. Waiting and quarantined messages are never deleted: past the window they move to the held area. A shorter value deletes messages, so it waits for ductus retention confirm <interface>; a longer one applies at once. Use 1w for busy feeds nobody looks back on, 7w where problems surface weeks later. Not for HTTP respond mode (it keeps nothing). Default: retention in ductus.yaml (7w). Example: retention: 1w.

quarantined

Type: keep, purge

What happens to quarantined messages once the retention window has passed: keep them in the held area until someone resends, corrects or dismisses them, or purge them with the rest. A quarantined message is a question nobody has answered yet, so keep is the safe choice; purge suits a feed whose sender resends anyway. Not for HTTP respond mode. Default: quarantined in ductus.yaml (keep). Example: quarantined: purge.

monitor

Type: settings, below

What operations expects of this interface, in the terms its teams use: something received within a time (active_within) or by a deadline (active_by), and queues not too far behind (behind). Breaking one shows as a health state (silent, missed, behind) in the web UI, ductus status and Prometheus, and alerts when a rule names it.

Nothing is expected by default: the engine can't know that a quiet feed is a problem, so these are operations' decisions. Example: monitor: { active_within: 30m, between: "06:00-22:00", behind: 10m }.

monitor.active_within

Type: duration

Something must be received at least this often (at least 1m): longer than this with nothing, and the interface is silent. For feeds that never stop in normal operation, such as ADT from the EHR: set it a little above the longest normal gap, so a dead sender is noticed before the users call. Only receiving counts, not delivering. Default: none. Example: active_within: 30m.

monitor.between

Type: hours (06:00-22:00)

The hours active_within applies, in the server's local time: "06:00-22:00", or across midnight, "22:00-06:00". The quiet time is counted from when the hours start, so a feed that sleeps at night isn't silent at 06:01. Only with active_within. Default: all day. Example: between: "06:00-22:00".

monitor.active_by

Type: time (09:00, Mon-Fri 09:00, or cron)

Something must be received by this time, or the interface has missed its deadline: for feeds that come once a day or week (a nightly file, a Monday batch). "09:00" every day, "Mon 09:00", "Mon-Fri 09:00", "Mon,Thu 09:00", or a five-field cron expression, in the server's local time. Only receiving counts. Default: none. Example: active_by: "Mon-Fri 07:00".

monitor.from

Type: time (09:00, Mon-Fri 09:00, or cron)

With active_by: when the window for each deadline opens. Only what arrives after it counts, so yesterday's late file can't hide today's missing one. "18:00" opens it the evening before; "Fri 18:00" the Friday before. Default: midnight of the deadline's day. Example: from: "18:00".

monitor.behind

Type: messages, age, or { messages, age }

When a destination's queue counts as behind: 500 (that many messages waiting), 10m (the oldest has waited that long), or { messages: 500, age: 10m } (either one).

A count alone misses an overnight stall when little arrives; an age alone misses a daytime pile-up that still moves, so give both for busy feeds. Set it at what the receiving department would call late. A destination can set its own. Default: none (a destination that stops sending still shows as stalled after a minute). Example: behind: { messages: 1000, age: 15m }.

monitor.behind.messages

Type: number

Behind once this many messages wait for the destination, at least 1. Default: no count limit. Example: messages: 500.

monitor.behind.age

Type: text

Behind once the oldest waiting message has waited this long, at least 1s. Default: no age limit. Example: age: 10m.

alert

Type: settings, below

This interface's alerts: more recipients on top of the rules' (to), or none at all (mute). The rules in ductus.yaml decide which states alert. Default: the rules' recipients only. Example: alert: { to: [lab-team] }.

alert.to

Type: list of text

More recipients for this interface's alerts, on top of the matching rule's: channel names from alerts.channels in ductus.yaml, or owner (whoever owns the interface or the destination). For a team that wants to hear about its own feeds only. Default: none. Example: to: [lab-team].

alert.mute

Type: true or false

Send no notifications about this interface: for a test or half-built interface. Its alerts still open and show in the web UI, so nothing is hidden from people who look. Default: false. Example: mute: true.

custom

Type: names and values of your own

Your company's own documentation fields (a BAA reference, a ticket, a runbook link), declared in docs-schema.yaml and checked by ductus validate; the web UI shows them on the interface's page. The engine itself ignores them. Default: none. Example: custom: { baa_signed: "yes", runbook: "https://wiki/adt" }.

resources.yaml

resources.yaml: what many interfaces share, each defined once by a name: databases, SFTP servers, HTTP APIs, directories for signing in, and certificates.

Interfaces and their code refer to them by name (db("patient_db"), api("empi"), server: lab_server), so no address or credential is ever written in an interface or its code, and each environment fills in its own with ${NAME} and ${secret:NAME}. ductus doctor checks every entry from the server, and ductus impact finds the interfaces behind each one.

Schema for editors: docs/schema/resources.json (written into a project by ductus types). Files: resources.yaml.

databases

Type: names, each with settings, below

Databases the interfaces' code queries with db("<name>"): each a postgres or a sqlserver entry, by a name you choose. The engine keeps a pool of connections to each and logs in with the entry's credentials, so code never sees them. Default: none. Example: databases: { patient_db: { postgres: { host: db1, database: patients, user: ductus, password: "${secret:PATIENT_DB_PASSWORD}" } } }.

databases.<name>.postgres

Type: settings, below

A PostgreSQL database. Example: postgres: { host: db1.hospital.local, database: patients, user: ductus, password: "${secret:PATIENT_DB_PASSWORD}", tls: verify-full }.

databases.<name>.postgres.host

Type: text · Required

The database server's host name or address. With tls: verify-full, its certificate must be for this name. Usually from the environment (${PATIENT_DB_HOST}). Required. Example: host: db1.hospital.local.

databases.<name>.postgres.port

Type: number

The server's port. Default: 5432. Example: port: 5433.

databases.<name>.postgres.database

Type: text · Required

The database to connect to on that server. Required. Example: database: patients.

databases.<name>.postgres.user

Type: text · Required

The login. Give it only what the interfaces' queries need (often read-only on a few tables or views). Required. Example: user: ductus.

databases.<name>.postgres.password

Type: text

The login's password, from a secret (${secret:PATIENT_DB_PASSWORD}). A wrong one makes messages wait and retry rather than quarantine, since fixing it fixes them all. Default: none (for certificate or trust logins). Example: password: "${secret:PATIENT_DB_PASSWORD}".

databases.<name>.postgres.max_connections

Type: number

The most connections the engine opens to this database, shared by every interface's db("<name>") calls, opened as they're needed.

A call that finds them all busy waits up to 10 seconds for one, then fails as a temporary error: its message waits and is tried again, nothing is quarantined. Raise it when calls queue for a connection while the database itself is quick, up to what the database's own limit and its DBA allow for every engine that connects (each engine has its own pool). Don't raise it to cure slow queries: more of the same slow query at once makes the database slower. An interface with isolation: dedicated can have connections of its own instead (dedicated_pools), so one busy interface can't take them all. Default: 5. Example: max_connections: 10.

databases.<name>.postgres.tls

Type: disable, prefer, require, verify-ca, verify-full

How the connection is protected, with the names and meanings of libpq's sslmode, as DBAs write them:

  • disable: no TLS.
  • prefer: TLS when the server offers it, its certificate not checked (anyone answering at that address is believed).
  • require: TLS or no connection, the certificate not checked.
  • verify-ca: TLS, the certificate issued by a trusted CA; the name in it not checked.
  • verify-full: TLS, a trusted CA's certificate for host. The only one that stops a server pretending to be this one.

Use verify-full for any database on another machine (cloud Postgres requires TLS), with ca for a private CA. The engine's start, ductus validate and ductus doctor say how each connection is protected. Default: verify-full when ca is given, else prefer. Example: tls: verify-full.

databases.<name>.postgres.ca

Type: text

A CA to trust for this server besides the ones this server trusts (a certificates: entry), for a database whose certificate comes from a private CA. Naming one means tls: verify-full unless tls says otherwise. Default: the system's trusted CAs only. Example: ca: hospital-ca.

databases.<name>.postgres.certificate

Type: text

A client certificate to present (a certificates: entry with its key): for a login by certificate (cert in the server's pg_hba.conf), or alongside the password where the DBA asks for both. A renewed certificate is used by new connections without a restart. Default: none. Example: certificate: ductus-db.

databases.<name>.postgres.application

Type: text

The application whose database this is, for ductus impact. Default: none. Example: application: Patient index.

databases.<name>.postgres.vendor

Type: text

The database's (or application's) vendor, for ductus impact. Default: none. Example: vendor: In-house.

databases.<name>.postgres.owner

Type: text

Who to ask about this database, best as an email address (its certificate's expiry alerts reach them with to: owner). Default: none. Example: owner: dba@hospital.org.

databases.<name>.postgres.custom

Type: names and values of your own

Your company's documentation fields for this entry (applies_to: [resource] in docs-schema.yaml). Default: none. Example: custom: { ticket: CHG-1234 }.

databases.<name>.sqlserver

Type: settings, below

A Microsoft SQL Server database, with a SQL login or a Windows one. Always over TLS, its certificate checked. Example: sqlserver: { host: billing.hospital.local, database: Billing, user: ductus, password: "${secret:BILLING_DB_PASSWORD}" }.

databases.<name>.sqlserver.host

Type: text · Required

The SQL Server's host name or address (without \INSTANCE: that goes in instance). Its certificate must be for this name, unless name_in_certificate says another. Required. Example: host: billing.hospital.local.

databases.<name>.sqlserver.port

Type: number

The server's port. Default: 1433, or, with instance, the port the SQL Server Browser gives for it (UDP 1434: the firewall must let that through too). Example: port: 1435.

databases.<name>.sqlserver.instance

Type: text

A named instance, as SQL Server tools write host\INSTANCE: SQLEXPRESS, PROD. Its port is asked of the SQL Server Browser, unless port is given. Default: the default instance. Example: instance: PROD.

databases.<name>.sqlserver.database

Type: text · Required

The database to use on that server. Required. Example: database: Billing.

databases.<name>.sqlserver.auth

Type: sql, windows

How the engine logs in:

  • sql: a SQL Server login, user and password.
  • windows: a Windows login. Without user, as the account the engine runs as (the Windows service's account; Windows only, Kerberos where the domain allows it); with user and password, as that domain account (from Linux this is NTLM, which some servers refuse: Extended Protection set to Required).

Use windows where the DBA allows only Windows logins; on Windows prefer the service's own account (a gMSA), which has no password to keep. Default: sql. Example: auth: windows.

databases.<name>.sqlserver.user

Type: text

The login: a SQL login's name with auth: sql, or a domain account (HOSPITAL\svc-lab) with auth: windows, where leaving it out means the account the engine runs as. Give it only what the queries need. Example: user: ductus.

databases.<name>.sqlserver.password

Type: text

The login's password, from a secret (${secret:BILLING_DB_PASSWORD}). Not with auth: windows and no user. Example: password: "${secret:BILLING_DB_PASSWORD}".

databases.<name>.sqlserver.max_connections

Type: number

The most connections the engine opens to this database, shared by every interface's db("<name>") calls, opened as they're needed. A call that finds them all busy waits for one, then fails as a temporary error (the message waits and is tried again). Raise it when calls queue while the server is quick, within what its DBA allows for every engine. Default: 5. Example: max_connections: 10.

databases.<name>.sqlserver.ca

Type: text

A CA to trust for this server besides the ones this server trusts (a certificates: entry), for a SQL Server whose certificate comes from a private CA (common: the domain's CA, or a certificate the DBA made). Default: the system's trusted CAs only. Example: ca: hospital-ca.

databases.<name>.sqlserver.encrypt

Type: mandatory or strict

How the connection is encrypted, always with the server's certificate checked:

  • mandatory: TLS for the whole session, begun after SQL Server's first exchange, as every SQL Server client does it.
  • strict: TDS 8, TLS before anything else, for servers set to force it (SQL Server 2022 and later; 2022 on Linux refuses it).

There's no false: SQL Server would encrypt the login only and send queries and results readable. Default: mandatory. Example: encrypt: strict.

databases.<name>.sqlserver.name_in_certificate

Type: text

The name the server's certificate is checked for, when it isn't host: connecting to an availability group listener, an alias or an address whose certificate names the server itself (ADO.NET's HostNameInCertificate). Default: host. Example: name_in_certificate: sql1.hospital.org.

databases.<name>.sqlserver.trust_server_certificate

Type: true or false

Accept the server's certificate without checking it: anyone answering at its address would be believed, and could read the credentials and data. For a lab server only; the start and ductus validate warn about it on another machine. For a server with a private CA, name the CA in ca instead. Not with ca or name_in_certificate. Default: false. Example: trust_server_certificate: true.

databases.<name>.sqlserver.application

Type: text

The application whose database this is, for ductus impact. Default: none. Example: application: Billing.

databases.<name>.sqlserver.vendor

Type: text

The application's vendor, for ductus impact. Default: none. Example: vendor: Cerner.

databases.<name>.sqlserver.owner

Type: text

Who to ask about this database, best as an email address (its certificate's expiry alerts reach them with to: owner). Default: none. Example: owner: dba@hospital.org.

databases.<name>.sqlserver.custom

Type: names and values of your own

Your company's documentation fields for this entry (applies_to: [resource] in docs-schema.yaml). Default: none. Example: custom: { ticket: CHG-1234 }.

sftp

Type: names, each with settings, below

SFTP servers that sftp sources read from and sftp destinations write to (server: <name>), with their login and host key. Default: none. Example: sftp: { lab_server: { host: sftp.lab.example.org, user: hospital, private_key: "${secret:LAB_SFTP_KEY}", host_key: "SHA256:…" } }.

sftp.<name>.host

Type: text · Required

The SFTP server's host name or address. Required. Example: host: sftp.lab.example.org.

sftp.<name>.port

Type: number

The server's SSH port. Default: 22. Example: port: 2222.

sftp.<name>.user

Type: text · Required

The account to log in as, given by the server's owner. Required. Example: user: hospital.

sftp.<name>.password

Type: text

The account's password, from a secret (${secret:LAB_SFTP_PASSWORD}). Give this or private_key; a key is better where the server takes one (nothing reusable crosses the network). Example: password: "${secret:LAB_SFTP_PASSWORD}".

sftp.<name>.private_key

Type: text

The private key to log in with, as OpenSSH key text from a secret (${secret:LAB_SFTP_KEY}); the server's owner adds its public half to the account. ssh-keygen -t ed25519 makes a pair. Example: private_key: "${secret:LAB_SFTP_KEY}".

sftp.<name>.passphrase

Type: text

The passphrase of an encrypted private_key, from a secret. Default: none (an unencrypted key, protected by the secrets file). Example: passphrase: "${secret:LAB_SFTP_PASSPHRASE}".

sftp.<name>.host_key

Type: text

The server's host key fingerprint, as SHA256:… (what ssh-keygen -lf prints for the key file), so a server pretending to be this one is refused before any password or key is sent to it.

Leave it out the first time: connecting then fails, and the error gives the fingerprint the server presented, to check with the server's administrator and paste in. When the server's key changes (a rebuilt server, another host behind the name), connections fail until it's updated: on purpose, since that's what an impostor looks like too. any turns the check off, for a test server on your own machine only. No default. Example: host_key: "SHA256:nThbg6kXUpJWGl7E1IGOCspRomTxdCARLviKw6E5SY8".

sftp.<name>.application

Type: text

The application behind the server, for ductus impact. Default: none. Example: application: Quest Lab Results.

sftp.<name>.vendor

Type: text

The server's vendor (often the partner whose server it is), for ductus impact: "what does Quest's outage touch?". Default: none. Example: vendor: Quest.

sftp.<name>.owner

Type: text

Who to ask about this server, best as an email address. Default: none. Example: owner: lab-it@hospital.org.

sftp.<name>.custom

Type: names and values of your own

Your company's documentation fields for this entry (applies_to: [resource] in docs-schema.yaml). Default: none. Example: custom: { contract: QUEST-2025 }.

apis

Type: names, each with settings, below

HTTP APIs the code calls with api("<name>") and http destinations send through (api: <name>): a base URL, headers such as the token, a timeout and TLS, set once per environment. Default: none. Example: apis: { empi: { url: "https://empi.hospital.local/api/v2", headers: { Authorization: "Bearer ${secret:EMPI_TOKEN}" } } }.

apis.<name>.url

Type: text · Required

The API's base URL, https:// (or http:// on a trusted network): paths in code (api("empi").get("/patients/1")) and path on destinations go under it. Usually from the environment (${EMPI_URL}), since test and production differ. Required. Example: url: "https://empi.hospital.local/api/v2".

apis.<name>.headers

Type: names, each with text

Headers sent with every request, typically the credential: Authorization: "Bearer ${secret:EMPI_TOKEN}", or an API key header. A header the code or a destination sets itself replaces the one here. Tokens stay out of interface code and differ per environment. Default: none. Example: headers: { Authorization: "Bearer ${secret:EMPI_TOKEN}" }.

apis.<name>.timeout

Type: duration

How long one request may take, for calls and destinations that don't set their own; a call past it fails as a temporary error, so its message waits and is tried again. Default: 30s for calls from code, 10s for destinations. Example: timeout: 10s.

apis.<name>.ca

Type: text

A CA to trust for this API's server besides the ones this server trusts (a certificates: entry), for an API whose certificate comes from a private CA. Default: the system's trusted CAs only. Example: ca: hospital-ca.

apis.<name>.certificate

Type: text

The client certificate to present to this API (a certificates: entry with its key), for APIs that ask for one (mutual TLS). Default: none. Example: certificate: ductus-client.

apis.<name>.application

Type: text

The application behind the API, for ductus impact. Default: none. Example: application: EMPI.

apis.<name>.vendor

Type: text

The API's vendor, for ductus impact. Default: none. Example: vendor: Rhapsody.

apis.<name>.owner

Type: text

Who to ask about this API, best as an email address (its certificate's expiry alerts reach them with to: owner). Default: none. Example: owner: empi-team@hospital.org.

apis.<name>.custom

Type: names and values of your own

Your company's documentation fields for this entry (applies_to: [resource] in docs-schema.yaml). Default: none. Example: custom: { contract: MSA-2024-17 }.

ldap

Type: names, each with settings, below

Directories (Active Directory, OpenLDAP) people sign in to the web UI with: auth.ldap in ductus.yaml names the one to use and how its groups map to roles. Default: none. Example: ldap: { corp-ad: { url: "ldaps://dc1.hospital.org", bind_dn: "CN=svc-ductus,OU=Service,DC=hospital,DC=org", bind_password: "${secret:LDAP_BIND_PASSWORD}" } }.

ldap.<name>.url

Type: text · Required

The directory server: ldaps://host (TLS from the start, port 636 unless given) or ldap://host (port 389) with starttls: true. For Active Directory, a domain controller's name or the domain's (ldaps://hospital.org reaches any of them, through DNS). Its certificate must be for this name. Required. Example: url: "ldaps://dc1.hospital.org".

ldap.<name>.starttls

Type: true or false

With an ldap:// URL: turn the connection into TLS (StartTLS) before anything else is sent, for directories that don't offer ldaps://. Refused if the server can't. Default: false. Example: starttls: true.

ldap.<name>.insecure

Type: true or false

Allow plain ldap:// with no TLS at all: people's passwords then cross the network readable to anyone in between. Only for a test directory on this machine; ductus validate and the engine's start warn about it. Default: false. Example: insecure: true.

ldap.<name>.bind_dn

Type: text

The service account the engine binds as to find people and read their groups, as a DN. Give it read access to people and groups and nothing else. Without it the directory is searched anonymously, which Active Directory refuses. Default: none. Example: bind_dn: "CN=svc-ductus,OU=Service Accounts,DC=hospital,DC=org".

ldap.<name>.bind_password

Type: text

The service account's password: always from a secret (${secret:LDAP_BIND_PASSWORD}). When it expires, sign-ins through the directory fail (local accounts still work), so give the account a password that doesn't expire, or note when it does. Example: bind_password: "${secret:LDAP_BIND_PASSWORD}".

ldap.<name>.ca

Type: text

A CA to trust for this directory besides the ones this server trusts (a certificates: entry), for a domain CA this server doesn't have installed (common on Linux; Windows servers in the domain usually have it). Default: the system's trusted CAs only. Example: ca: hospital-ca.

ldap.<name>.timeout

Type: duration

How long connecting, binding or one search may take before a sign-in gives up (and says the directory can't be reached). Default: 10s. Example: timeout: 5s.

ldap.<name>.application

Type: text

The application the directory is, for ductus impact. Default: none. Example: application: Active Directory.

ldap.<name>.vendor

Type: text

The directory's vendor, for ductus impact. Default: none. Example: vendor: Microsoft.

ldap.<name>.owner

Type: text

Who looks after the directory, best as an email address (its certificate's expiry alerts reach them with to: owner). Default: none. Example: owner: directory@hospital.org.

ldap.<name>.custom

Type: names and values of your own

Your company's documentation fields for this entry (applies_to: [resource] in docs-schema.yaml). Default: none. Example: custom: { ticket: CHG-1234 }.

certificates

Type: names, each with settings, below

Certificates the engine presents (tls: { certificate: <name> } on listeners and the API, certificate: <name> on connections it makes), and CAs it trusts or checks clients against (ca: <name>). Each entry is a certificate with its key (from files or secrets) or a CA alone. Their expiry is watched and alerts at 30, 14 and 7 days. Default: none. Example: certificates: { ductus-mllp: { cert_file: /etc/ductus/tls/mllp.pem, key_file: /etc/ductus/tls/mllp.key } }.

certificates.<name>.cert_file

Type: text

A PEM file with the certificate to present, followed by the intermediate certificates that issued it (not the root): the "full chain" a CA sends. Goes with key_file.

The engine reads both files at start (a certificate it can't use stops the start, saying why) and again whenever they change, checked every minute: to renew, replace both files, with no restart. Senders that don't have the intermediates can't check a certificate without them, so leave none out. A relative path is read from the project folder; on Windows, write the drive. To keep the PEM in a secret instead, use cert and key. Example: cert_file: /etc/ductus/tls/mllp.pem.

certificates.<name>.key_file

Type: text

The PEM file with the certificate's private key, unencrypted, readable by the account the engine runs as and nobody else (chmod 600, or the service's account only on Windows). Goes with cert_file, renewed with it. ductus cert request makes one with its CSR. Example: key_file: /etc/ductus/tls/mllp.key.

certificates.<name>.cert

Type: text

The certificate (then its intermediates) as PEM text, usually from a secret (${secret:MLLP_CERT}), for servers where certificates live in a vault rather than in files. Goes with key. Read at start only: a renewal needs a restart, unlike files. Example: cert: "${secret:MLLP_CERT}".

certificates.<name>.key

Type: text

The private key as PEM text, from a secret (${secret:MLLP_KEY}). Goes with cert. Example: key: "${secret:MLLP_KEY}".

certificates.<name>.ca

Type: text

A CA's certificate (or several) as PEM text, often from a secret. An entry with only this is a CA: what ca: <name> trusts for a server the engine connects to, and what clients: { ca: <name> } checks senders' certificates against. Example: ca: "${secret:HOSPITAL_CA}".

certificates.<name>.ca_file

Type: text

A CA's certificate (or several) in a PEM file; the same as ca, read from a file (relative to the project folder unless written whole). Example: ca_file: /etc/ductus/tls/hospital-ca.pem.

certificates.<name>.application

Type: text

The application the certificate belongs to, for ductus impact. Default: none. Example: application: Ductus MLLP listener.

certificates.<name>.vendor

Type: text

The vendor the certificate belongs to (the CA's, for a CA entry), for ductus impact. Default: none. Example: vendor: DigiCert.

certificates.<name>.owner

Type: text

Who renews this certificate, best as an email address: alert rules with to: owner mail them as it nears expiry, and the web UI shows them on its row. Default: the owner of the one interface that uses it, if just one does. Example: owner: infrastructure@hospital.org.

certificates.<name>.custom

Type: names and values of your own

Your company's documentation fields for this entry (those whose applies_to in docs-schema.yaml includes resource). Default: none. Example: custom: { ticket: CHG-1234 }.

ductus.yaml

ductus.yaml: the project's settings, for every interface in it and the engine that runs them: how long messages are kept, the REST API and web UI, how people sign in, the audit log, alerts, and the engine's own connection checks.

The file sits at the top of the project and is optional: every setting has a default, which ductus init writes out for the main ones. Only alerts: may use the environment's ${NAME} and ${secret:NAME}; everything else is read as written.

Schema for editors: docs/schema/ductus.json (written into a project by ductus types). Files: ductus.yaml.

ports

Type: settings, below

The port ranges ductus new interface picks a free port from for a new interface's listener (each interface's own port is in its interface.yaml). Set them to the ranges your firewall team opened, so new interfaces land inside. Default: MLLP 6661-6699, HTTP 8080-8099. Example: ports: { mllp: 7000-7099 }.

ports.mllp

Type: port range (6661-6699)

The range a new MLLP interface's port is picked from, as first-last. Default: 6661-6699. Example: mllp: 7000-7099.

ports.http

Type: port range (6661-6699)

The range a new HTTP interface's port is picked from, as first-last (HTTP interfaces can also share one port). Default: 8080-8099. Example: http: 9080-9099.

isolates

Type: settings, below

Limits of the JavaScript engine that runs interface code (heap_mb). Default: heap_mb: 512.

isolates.heap_mb

Type: number

The memory limit, in MB, of each isolate: one copy of the JavaScript engine (V8) that runs interface code. At least 64.

Every thread of the shared pool has an isolate (one thread per CPU core, or --threads), and so does every worker of an isolation: dedicated interface, so the code can use up to about heap_mb × (threads + dedicated workers) in all: size the server with that. Code that fills its isolate is stopped, its message quarantined and its interface stopped until a restart; the engine and every other interface keep running. In the shared pool the limit is shared by every interface on the thread, so the one that runs out isn't always the one that filled it (a cache in another interface that only grows).

Raise it for code that holds a lot on purpose (a large lookup table loaded once), or give that interface isolation: dedicated; lower it on a small server with many cores. Default: 512. Example: isolates: { heap_mb: 1024 }.

require_about

Type: true or false

Make ductus validate fail (not only warn) when an interface doesn't name the application or vendor on each side in about:, so ductus impact never misses one. Turn it on once the existing interfaces are documented, and every new one must be. Default: false. Example: require_about: true.

api

Type: settings, below

The REST API and the web UI: where they listen, HTTPS, and who may connect. Default: 127.0.0.1:7700, plain HTTP, this machine only.

api.listen

Type: text

Where the REST API and the web UI listen, as host:port, or off to run without them (then ductus status --token, drain, pause and the web UI can't reach this engine).

The default listens on this machine only, so opening it to the network is a choice you make. For people to use the web UI from their own computers, listen on the server's address (or 0.0.0.0 for all of them) and add tls (the UI's sign-in needs HTTPS anywhere but on the server itself) and allow. Every call needs a token or a signed-in person either way. ductus run --api-listen (or DUCTUS_API_LISTEN) overrides it for one engine. Default: 127.0.0.1:7700. Example: listen: 0.0.0.0:7700.

api.tls

Type: settings, below

HTTPS only, presenting the certificate named here (a certificates: entry in resources.yaml); with clients, callers must present a certificate too. Needed for the web UI anywhere but on the server itself (its sign-in cookie is only sent over HTTPS), and for tokens not to cross the network readable; the start warns without it when listen isn't this machine only. Default: plain HTTP. Example: tls: { certificate: ductus-api }.

api.tls.certificate

Type: text · Required

The certificate this listener presents: a certificates: entry in resources.yaml with the certificate and its key, for the name senders connect to. A certificate the engine can't use (expired, its key doesn't match) stops the start with the reason; renewed files are picked up within a minute, without a restart. Required. Example: certificate: ductus-mllp.

api.tls.clients

Type: settings, below

Ask senders for a certificate of their own, and refuse any connection without one that ca issued and, with subjects, that names an allowed system. A client certificate says which system is connecting, beside allow:'s where from. Default: no client certificates asked for. Example: clients: { ca: hospital-ca, subjects: ["CN=*.lab.hospital.org"] }.

api.tls.clients.ca

Type: text · Required

The CA that issues senders' certificates: a certificates: entry holding the CA's certificate (ca or ca_file). Only certificates it issued are taken. Required. Example: ca: hospital-ca.

api.tls.clients.subjects

Type: list of text

Which of that CA's certificates are taken: a certificate matching any one pattern. A pattern is ATTR=value parts separated by commas, all of which must match, with * and ? wildcards and case ignored: CN, O, OU, C, L, ST, DC, or DNS for the certificate's DNS names. Needed when the CA issues certificates to more than your senders (a hospital-wide CA). Default: any certificate the CA issued. Example: subjects: ["CN=bridges*.hospital.org", "O=Quest, OU=Lab"].

api.allow

Type: list of text

Who may connect: addresses, subnets and host names (the operators' and developers' networks, the monitoring server). Anyone else is disconnected before anything is read. List 127.0.0.1 too, or commands run on the server itself (ductus status --token, drain) are refused. Default: anyone who can reach listen. Example: allow: [127.0.0.1, 10.1.50.0/24].

retention

Type: 1w, 3w or 7w

How long messages are kept, for every interface whose interface.yaml has no retention: 1w, 3w or 7w (or the same in days: 7d, 21d, 49d).

Only these three, on purpose: each is a set of partitions of the message tables, and old messages go by dropping a whole partition at once, which costs nothing however busy the feed (deleting rows one by one would slow the busiest tables). A message is kept at least its window and is gone within a day (1w) or a week (3w, 7w) after it. Messages still waiting for a destination, and quarantined ones (see quarantined), are never deleted: past the window they move to the held area until they're delivered or resolved.

Pick the window that covers how far back you look into problems; longer history belongs in the source system or an archive. Shortening it later deletes messages, so it waits for ductus retention confirm <interface>. Default: 7w. Example: retention: 3w.

quarantined

Type: keep, purge

What happens to quarantined messages once the retention window has passed, for every interface whose interface.yaml doesn't say: keep them in the held area until someone resends, corrects or dismisses them, or purge them with the rest.

In most engines unresolved quarantines pile up for years; here they're kept until a person decides, with no second time limit. Watch them with ductus retention and the ductus_held_messages metric. purge suits feeds whose senders resend anyway. Default: keep. Example: quarantined: purge.

auth

Type: settings, below

How people sign in to the web UI and the API: sessions, passwords, lockout, API tokens, and a directory (Active Directory, OpenLDAP). Local accounts are made with ductus user on the server. Default: local accounts and tokens, with the session and password rules below.

auth.session

Type: settings, below

How long a browser session lasts: idle (unused) and max (in all). Default: 12 hours idle, 7 days at most.

auth.session.idle

Type: duration

A session ends after this long unused, and the person signs in again: a shift, with room to spare, so an analyst isn't signed out mid-task. Shorten it where screens are shared or left unlocked. No longer than max. Default: 12h. Example: idle: 30m.

auth.session.max

Type: duration

A session ends this long after sign-in however much it's used, so a stolen session cookie doesn't last forever. Default: 7d. Example: max: 1d.

auth.password

Type: settings, below

What a local account's password must be (min_length). Directory accounts follow the directory's rules. Default: at least 12 characters.

auth.password.min_length

Type: number

The shortest password a local account may have, at least 8. Length matters more than mixing kinds of characters, so there's no rule for those (a few ordinary words together work well); a password can't contain the username, be one character repeated, or be one of the most common ones. Applies to new passwords; existing ones keep working. Default: 12. Example: min_length: 16.

auth.lockout

Type: settings, below

How many wrong passwords lock a username, and for how long. Default: 5 within 15 minutes lock it for 15 minutes.

auth.lockout.attempts

Type: number

How many wrong passwords for one username, within window, lock it, at least 1. It slows anyone guessing; a person who forgot waits for, or an admin unlocks them (ductus user unlock). Directory accounts count here and in the directory. Default: 5. Example: attempts: 10.

auth.lockout.window

Type: duration

How far back wrong passwords are counted. Default: 15m. Example: window: 1h.

auth.lockout.for

Type: duration

How long a locked username stays locked. Default: 15m. Example: for: 30m.

auth.token_sign_in

Type: true or false

Whether the sign-in page also takes an API token instead of a username and password: how the first people get in before accounts exist. Turn it off once everyone has an account; ductus user on the server always works. Default: true. Example: token_sign_in: false.

auth.user_tokens

Type: true or false

Whether people may make API tokens of their own (for scripts, under Account in the web UI). A person's token can do no more than they can, and stops when their account is disabled. Turn it off to keep tokens to the ones admins make. Default: true. Example: user_tokens: false.

auth.ldap

Type: settings, below

Sign-in with a directory (Active Directory, OpenLDAP), beside local accounts: which ldap: entry in resources.yaml, where people are, and which groups give which role. People in none of the groups can't sign in. Default: local accounts only. Example: ldap: { resource: corp-ad, user_base: "OU=Staff,DC=hospital,DC=org", groups: { admin: "CN=Ductus Admins,OU=Groups,DC=hospital,DC=org" } }.

auth.ldap.resource

Type: text · Required

The directory to use: the name of an ldap: entry in resources.yaml (its address, TLS and service account). Required. Example: resource: corp-ad.

auth.ldap.user_base

Type: text · Required

Where people are searched for, as a DN: the whole subtree under it. Narrow it to the branch your staff are in, so service accounts and contacts can't sign in by accident. Required. Example: user_base: "OU=Staff,DC=hospital,DC=org".

auth.ldap.user_filter

Type: text

How a person is found from the name they type: an LDAP filter where {username} stands for it, escaped so it can't change the filter. More than one entry found is refused. (sAMAccountName={username}) is Active Directory's sign-in name; OpenLDAP and FreeIPA use (uid={username}); (|(sAMAccountName={username})(mail={username})) takes either. Default: (sAMAccountName={username}). Example: user_filter: "(uid={username})".

auth.ldap.attributes

Type: settings, below

Which of the person's attributes hold their name, email, sign-in name and lasting id. The defaults suit Active Directory. Default: displayName, mail, the filter's attribute, and auto for the id.

auth.ldap.attributes.display_name

Type: text

The attribute with the person's name as others see it in the web UI and the audit log. Default: displayName. Example: display_name: cn.

auth.ldap.attributes.email

Type: text

The attribute with the person's email address: shown in the web UI, matched against owner texts (about.owner: anna@hospital.org), and used to find owners in the directory before their first sign-in. Default: mail. Example: email: userPrincipalName.

auth.ldap.attributes.username

Type: text

The attribute with the sign-in name, so a refresh notices it change and renames the account. Needed only when user_filter doesn't match {username} against one attribute plainly (ductus validate warns then). Default: the attribute the filter first matches {username} against (sAMAccountName for the default filter). Example: username: uid.

auth.ldap.attributes.id

Type: text

The attribute that tells one person from another through any rename: auto (Active Directory's objectGUID, else the standard entryUUID), another attribute's name (nsUniqueId), or none. With an id, a renamed person keeps their account, and a sign-in name given to someone new gets a new account instead of the old one's role and history. none matches by sign-in name only, so those two can't be told apart (validate warns). Default: auto. Example: id: nsUniqueId.

auth.ldap.groups

Type: settings, below · Required

Which directory groups give which role (admin, operator, viewer) and which add a permission (permissions.payloads, permissions.audit). The highest role a person's groups give wins; a person in none can't sign in. At least one role group. Required. Example: groups: { operator: "CN=Interface Team,OU=Groups,DC=hospital,DC=org" }.

auth.ldap.groups.admin

Type: text, or a list

The group (a DN), or groups, whose members are admins: everything operators can do, plus accounts, tokens and every permission. Write several one per line in a YAML list, not as [a, b] (DNs have commas). Default: none. Example: admin: "CN=Ductus Admins,OU=Groups,DC=hospital,DC=org".

auth.ldap.groups.operator

Type: text, or a list

The group or groups whose members are operators: what viewers can do, plus resend, edit, dismiss, pause, drain and run connection checks. Default: none. Example: operator: "CN=Interface Team,OU=Groups,DC=hospital,DC=org".

auth.ldap.groups.viewer

Type: text, or a list

The group or groups whose members are viewers: they see interfaces, health, queues and messages' details, but change nothing (and see payloads only with payloads). Default: none. Example: viewer: "CN=Help Desk,OU=Groups,DC=hospital,DC=org".

auth.ldap.groups.permissions

Type: settings, below

Groups that add a permission on top of the role: payloads (read message contents, usually patient data) and audit (read the audit log). Admins have both anyway. Default: none.

auth.ldap.groups.permissions.payloads

Type: text, or a list

The group or groups whose members may read message payloads (and edit them, as operators): the people who need to see patient data to do their work. Every read is in the audit log. Default: none. Example: payloads: "CN=Ductus PHI,OU=Groups,DC=hospital,DC=org".

auth.ldap.groups.permissions.audit

Type: text, or a list

The group or groups whose members may read and export the audit log: compliance and security staff. Default: none. Example: audit: "CN=Ductus Auditors,OU=Groups,DC=hospital,DC=org".

auth.ldap.nested

Type: true or false

Count members of groups inside the configured groups too (a department group placed in "Ductus Operators"). Active Directory answers this itself; other directories are walked group by group, at most 8 levels deep. Default: false (direct members only). Example: nested: true.

auth.ldap.group_base

Type: text

Where groups are searched for when nested groups are walked (not Active Directory, which answers by itself), as a DN. Default: the directory's own top (its naming context). Example: group_base: "ou=groups,dc=example,dc=org".

auth.ldap.refresh

Type: duration

How often the engine asks the directory again about everyone with a session or a token, and about directory people who own something (at least 1m). Someone removed from the groups, disabled or deleted there loses access within this long, sessions and tokens alike; a renamed person keeps their account. Shorter means quicker revocation and more directory searches. Default: 15m. Example: refresh: 5m.

audit

Type: settings, below

The audit log of who did what (sign-ins, resends, edits, pauses, account changes, payload reads): how long it's kept and whether reading a message's payload is logged. Default: kept 6 years, payload reads logged.

audit.keep

Type: duration (or years: 6y)

How long audit rows, and the record of what was done to messages, are kept: at least a year, in years (6y, 365 days each) or any duration (2190d). Older rows are deleted hourly. Six years is the usual HIPAA documentation period; ask your compliance office for yours. The log is the record of who did what, so it can't be shorter than a year. Default: 6y. Example: keep: 7y.

audit.payload_reads

Type: true or false

Log who reads a message's payload (in the web UI or through the API), before it's shown: with patient data, who looked matters as much as who changed. Turn it off only where messages hold no personal data. Reading needs the payloads permission either way. Default: true. Example: payload_reads: false.

audit.payload_read_window

Type: duration

One person reading the same message again within this long counts as one read, one row (a page that reloads itself would otherwise write a row each time). 0s writes a row for every read. Default: 10m. Example: payload_read_window: 0s.

alerts

Type: settings, below

Where notifications go and when: a mail server, channels (email lists, chat webhooks), and rules saying which health states alert, how badly and to whom. The engine judges every interface and destination every 15 seconds and tells people once, then reminds, and says when it's fine again.

The only section that may use ${NAME} and ${secret:NAME}, read for the environment the engine runs in (webhook URLs and mail passwords are secrets). Default: no alerts (states show in the web UI only). Example: alerts: { channels: { oncall: { email: oncall@hospital.org } }, rules: [{ when: [error, stalled], severity: critical, to: [oncall] }] }.

alerts.smtp

Type: settings, below

The mail server that email channels (and owners' email) go through. Needed only for email; webhooks need none. Default: none. Example: smtp: { host: smtp.hospital.org, from: ductus@hospital.org }.

alerts.smtp.host

Type: text · Required

The mail server's host name: your organization's relay or submission server. Its certificate must be for this name. Required. Example: host: smtp.hospital.org.

alerts.smtp.port

Type: number

The mail server's port. Default: 587 with tls: starttls, 465 with tls: tls, 25 with tls: none. Example: port: 2525.

alerts.smtp.tls

Type: starttls, tls, none

How the connection is protected:

  • starttls: a plain connection turned into TLS before anything else (submission, port 587); refused if the server can't.
  • tls: TLS from the first byte (port 465).
  • none: no encryption, for a relay on this machine or its network segment only; mail and any password cross readable.

Default: starttls. Example: tls: tls.

alerts.smtp.username

Type: text

The account to log in as, when the server asks for one (most submission servers do; an internal relay often doesn't). Goes with password. Default: none (no login). Example: username: ductus@hospital.org.

alerts.smtp.password

Type: text

The account's password, from a secret (${secret:SMTP_PASSWORD}). Goes with username. Example: password: "${secret:SMTP_PASSWORD}".

alerts.smtp.from

Type: text · Required

The address notifications come from. Use one your mail server lets this account send as, and that recipients' filters let through (ask the mail team to add it to their allow list). Required. Example: from: ductus@hospital.org.

alerts.smtp.ca

Type: text

A CA to trust for the mail server besides the ones this server trusts (a certificates: entry in resources.yaml), for an internal relay with a private CA's certificate. Not with tls: none. Default: the system's trusted CAs only. Example: ca: hospital-ca.

alerts.smtp.certificate

Type: text

A client certificate to present to the mail server (a certificates: entry with its key), for servers that admit senders by certificate. Not with tls: none. Default: none. Example: certificate: ductus-client.

alerts.channels

Type: names, each with settings, below

Where alerts can go, by a name rules use in to:: each an email list, a chat webhook, or both. Send to teams (a mailing list, a chat channel) rather than to one person: people change and sleep; a team's list doesn't. ductus alerts test <channel> sends a test from the command line, no engine needed. Default: none. Example: channels: { oncall: { email: interfaces-oncall@hospital.org }, chat: { webhook: { url: "${secret:CHAT_WEBHOOK}", format: teams } } }.

alerts.channels.<name>.email

Type: text, or a list

The address, or addresses, notifications are mailed to: best a team's list (interfaces-oncall@hospital.org), or a pager's email gateway. Needs alerts.smtp. Default: none. Example: email: [interfaces-oncall@hospital.org, lab-it@hospital.org].

alerts.channels.<name>.webhook

Type: settings, below

A chat or incident tool's incoming webhook that notifications are posted to (Teams, Slack, or your own tool with format: json). Default: none. Example: webhook: { url: "${secret:CHAT_WEBHOOK}", format: teams }.

alerts.channels.<name>.webhook.url

Type: text · Required

The webhook's URL. A chat webhook's URL is its own password (anyone with it can post to the channel), so keep it in a secret: ${secret:CHAT_WEBHOOK}. The engine shows only its scheme and host anywhere. Required. Example: url: "${secret:CHAT_WEBHOOK}".

alerts.channels.<name>.webhook.format

Type: json, slack, teams

What the posted body looks like:

  • json: Ductus's own JSON, every field, for scripts and incident tools.
  • slack: Slack incoming webhooks (also Mattermost and Rocket.Chat).
  • teams: Microsoft Teams Workflows webhooks (an Adaptive Card; the old Office 365 connectors are being retired).

Default: json. Example: format: teams.

alerts.channels.<name>.webhook.ca

Type: text

A CA to trust for the webhook's server besides the ones this server trusts (a certificates: entry in resources.yaml), for an internal tool with a private CA's certificate. Default: the system's trusted CAs only. Example: ca: hospital-ca.

alerts.channels.<name>.webhook.certificate

Type: text

A client certificate to present to the webhook's server (a certificates: entry with its key), for tools that admit callers by certificate. Default: none. Example: certificate: ductus-client.

alerts.rules

Type: list of settings, below

Which health states alert, how badly, and to whom. Each state is matched by the first rule that names it; a state no rule names doesn't alert (ductus validate says which). Default: none (nothing alerts). Example: rules: [{ when: [error, stalled, stopped], severity: critical, to: [oncall, chat] }, { when: [behind, silent, missed, certificate], severity: warning, to: [chat] }].

alerts.rules.when

Type: list of error, stalled, behind, stopped, silent, missed, certificate · Required

The health states this rule covers:

  • error: a destination has quarantined messages someone must look at.
  • stalled: a destination has had messages waiting for over a minute and sent none (its receiver is down, or a message keeps failing).
  • behind: a destination's queue is past its monitor.behind.
  • stopped: an interface's code was stopped (it ran away or ran out of memory).
  • silent: nothing received within monitor.active_within.
  • missed: nothing received by monitor.active_by.
  • certificate: a certificate the engine uses or connects to nears expiry, or expired.

A paused destination never alerts: it's paused on purpose. Required. Example: when: [error, stalled, stopped].

alerts.rules.severity

Type: warning, critical · Required

How bad these states are: warning (worth knowing today; held during quiet hours) or critical (act now; never held). An alert that goes from warning to critical is told again. An expired certificate is always critical. Required. Example: severity: critical.

alerts.rules.to

Type: list of text · Required

Who is told: channel names from channels, and owner (whoever owns the destination or its interface: its owner, else about.owner, mailed at their account's email, an owners: entry, or the text itself if it's an address). Lead with team channels; add owner where a person should hear directly. Required. Example: to: [oncall, chat, owner].

alerts.repeat

Type: duration, or 0

While an alert stays open and nobody has acknowledged it, remind its recipients this often; 0 never reminds. Acknowledging (on the Alerts page) stops reminders until it gets worse or recovers. Keep it long where a channel pages someone. Default: 4h. Example: repeat: 1h.

alerts.recovery

Type: true or false

Tell the people who heard about an alert when it's fine again (after settle), so they can stand down. Default: true. Example: recovery: false.

alerts.settle

Type: duration, or 0

How long a state must be fine before its alert counts as over. Bad again sooner, and the same alert goes on: a receiver flapping up and down is one alert, not dozens. 0 ends it at once. Default: 2m. Example: settle: 10m.

alerts.quiet

Type: settings, below

Hours when notifications of some severities wait until morning (warnings, overnight). A warning that comes and goes in the night is never sent, only kept in the history; one that turns critical goes at once. Default: none. Example: quiet: { between: "22:00-06:00", severities: [warning] }.

alerts.quiet.between

Type: hours (06:00-22:00) · Required

The quiet hours, in the server's local time (mind a server or container that runs on UTC): "22:00-06:00". Notifications due in them wait until they end. Required. Example: between: "21:00-07:00".

alerts.quiet.severities

Type: list of warning, critical

Which severities wait during the quiet hours. Keep critical out: a critical alert that waits until morning wasn't critical. Default: [warning]. Example: severities: [warning].

alerts.heartbeat

Type: settings, below

A URL the engine calls every every while it runs, for a service elsewhere that raises its own alarm when the calls stop (healthchecks.io, Better Stack, Uptime Kuma, a monitoring tool's push check). An engine that is down, or can't reach its database, can't send alerts itself: this is how anyone hears about it. Set it in production. Default: none. Example: heartbeat: { url: "${secret:HEARTBEAT_URL}", every: 5m }.

alerts.heartbeat.url

Type: text · Required

The URL the engine calls (an HTTP GET) while it runs: the "ping" or "push" URL the watching service gives you. It often holds a secret: ${secret:HEARTBEAT_URL}. Use a service that doesn't run on the engine's server, or it goes down with it. Required. Example: url: "${secret:HEARTBEAT_URL}".

alerts.heartbeat.every

Type: duration

How often it's called. Set the watching service's grace period to about twice this. Default: 5m. Example: every: 1m.

alerts.heartbeat.ca

Type: text

A CA to trust for the heartbeat's server besides the ones this server trusts (a certificates: entry in resources.yaml). Default: the system's trusted CAs only. Example: ca: hospital-ca.

alerts.heartbeat.certificate

Type: text

A client certificate to present to the heartbeat's server (a certificates: entry with its key). Default: none. Example: certificate: ductus-client.

alerts.keep

Type: duration (or years: 6y)

How long alerts' history (what fired, who was told, who acknowledged) is kept, in years (1y) or any duration. Default: 1y. Example: keep: 2y.

alerts.certificates

Type: settings, below

When an expiring certificate alerts (certificate in a rule): the steps before expiry. Default: 30, 14 and 7 days.

alerts.certificates.warn_days

Type: list of number

The days before a certificate expires at which its alert is told: it fires at the first (with the rule's severity) and is told again at each later one, then as critical once expired. No reminders between steps: the steps are the reminders. Counted in whole days, from the earliest expiry in the certificate's chain. Pick the first step to leave time for your slowest renewal (a vendor's CA, a change board). Default: [30, 14, 7]. Example: warn_days: [45, 14, 3].

owners

Type: names, each with settings, below

Owners that aren't accounts here, by the name interfaces give in about.owner, with the address that reaches them: a team's mailing list ("Lab Systems": { email: lab-systems@hospital.org }). Alert rules with to: owner mail it. Owners who are accounts (best written as their email) need no entry. Default: none.

owners.<name>.email

Type: text, or a list

The address, or addresses, that reach this owner: a team's mailing list. Alert rules with to: owner mail it for the interfaces and destinations this owner owns. Default: none. Example: email: lab-systems@hospital.org.

doctor

Type: settings, below

Connection checks the engine runs by itself, as ductus doctor would: by default every six hours, only as far as each server's certificate (nothing logs in), so expiring certificates alert 30, 14 and 7 days ahead. Default: schedule: 6h, mode: certificates.

doctor.schedule

Type: duration, or off

How often the engine checks its connections: a duration of at least 1m, or off. The first check is a minute or two after the engine starts (at random, so engines started together don't check together). Each check takes well under a second for a handful of servers. Turn it off only if something else watches every certificate the engine connects to. Default: 6h. Example: schedule: 1h.

doctor.mode

Type: certificates, full

What a check covers:

  • certificates: DNS, TCP and the TLS handshake of every connection with a certificate. Nothing logs in, so no service account's log fills up and none is ever locked out.
  • full: everything ductus doctor checks, logins included (databases, SFTP, the directory), so a changed password is noticed before a message needs it.

Use full where the servers' owners agree to a login every schedule. Default: certificates. Example: mode: full.

environments/*.yaml

environments/<name>.yaml: one environment's values (dev.yaml, test.yaml, prod.yaml). The engine reads the one it's started with (--env prod, or DUCTUS_ENV), and every ${NAME} in interface.yaml, resources.yaml and ductus.yaml's alerts: takes its value from here. The same project then runs anywhere, with only this file differing.

Secrets (passwords, tokens, keys) don't go here: they're in secrets/<name>.json, encrypted (ductus secret set NAME), and written ${secret:NAME}. ductus validate --env <name> checks one environment: run it for each before deploying there, and a variable missing from it is found then, not at the engine's start.

Schema for editors: docs/schema/environment.json (written into a project by ductus types). Files: environments/*.yaml.

variables

Type: names and values of your own

The environment's values by name, used as ${NAME} in the configuration. A value that stands alone keeps its type (port: ${EPIC_PORT} is a number when EPIC_PORT: 6661); inside text it's text (url: "https://${EMPI_HOST}/api"). Give every environment the same names. Default: none. Example: variables: { PATIENT_DB_HOST: db1.hospital.local, EPIC_PORT: 6661 }.

docs-schema.yaml

docs-schema.yaml: the documentation fields your company keeps about its interfaces, destinations and resources, beyond what Ductus asks for (a BAA reference, a change ticket, a runbook link, the analyst). Values go in custom: beside each one; ductus validate checks them, and the web UI shows and filters by them. The engine itself ignores them.

The file sits at the top of the project and is optional: without it there are no fields.

Schema for editors: docs/schema/docs-schema.json (written into a project by ductus types). Files: docs-schema.yaml.

fields

Type: names, each with settings, below

The fields, by the key custom: uses, in the order the web UI shows them. Keys are your choice, except names Ductus uses beside custom (name, owner, vendor, …). Default: none. Example: fields: { baa_signed: { type: enum, values: ["yes", "no"], required: true } }.

fields.<name>.type

Type: text, enum, date, url, person, list · Required

What the value is:

  • text: any text (quote numbers and yes/no, or YAML reads them as such).
  • enum: one of values.
  • date: a date, written 2026-10-30.
  • url: an http(s):// address, shown as a link.
  • person: who, as text: best an email address.
  • list: several texts (each one of values, if given).

Required. Example: type: enum.

fields.<name>.values

Type: list of text

The values an enum may take (and each item of a list, if given), matched exactly, case included. Quote ones YAML would read otherwise ("yes", "no"). Default: none. Example: values: ["yes", "no", n/a].

fields.<name>.required

Type: true or false

Every interface (destination, resource) the field applies to must have a value: ductus validate reports each one missing. The engine starts anyway. Default: false. Example: required: true.

fields.<name>.applies_to

Type: list of interface, destination, resource

Where the field may be given: interface, destination and/or resource (a resources.yaml entry). Default: [interface]. Example: applies_to: [interface, resource].

fields.<name>.label

Type: text

The name the web UI shows instead of the key. Default: the key. Example: label: BAA signed.

fields.<name>.help

Type: text

A hint shown under the field in the web UI: what it's for, or where to find the value. Default: none. Example: help: Business associate agreement, from the contracts office.

Event Log IDs

On Windows, the service writes what operations teams watch for to the Application log, source Ductus, with these IDs; Event Viewer shows the text below. Everything else goes only to the log files in C:\ProgramData\Ductus\logs. Errors not listed here are event 1100, written at most once a minute for each kind of message, so the log can't flood. On Linux, the same lines are in the journal (journalctl -u ductus).

1000 · Information

The engine started and is taking messages: its version, and the start's details (interfaces, where the API listens).

Ductus … started.

1001 · Information

The service stopped cleanly (a stop, a shutdown, or a drain that ended with a stop): every message in hand was finished first.

Ductus stopped cleanly: it finished the messages in hand.

1002 · Warning

The service stopped before finishing the messages in hand (the stop timeout passed, or a second stop came). Nothing is lost: they're pending and are processed again at the next start, so a destination may get one twice. If it recurs, look at what was slow (a receiver timing out) in the log files.

Ductus stopped before it finished the messages in hand: they stay pending and are processed again at the next start (a destination may get one again).

1003 · Error

The engine couldn't start, and the service stays stopped: the text says why (the database can't be reached, the project has an error, a certificate can't be used, another engine runs some of these interfaces). Fix the cause and start the service.

Ductus could not start. The service stays stopped until the cause is fixed and it is started again.

1004 · Warning

The previous run didn't stop cleanly (killed, crashed, or the machine lost power). The messages it had in hand are processed again; the previous run's last lines are in the log files.

The last run of Ductus on this computer did not stop cleanly (it was killed, crashed or lost power). The messages it had in hand are processed again, as the delivery guarantee says.

1005 · Warning

The service runs as an account with a password, which an install or upgrade can't keep, so it was left stopped. Run ductus service account <account> --start as an administrator and type the password; a gMSA needs none of this.

The Ductus service runs as …, an account with a password, which an install or upgrade can't keep: the service was left stopped. As an administrator, run

1006 · Error

The engine stopped on an error while running: the text says which.

Ductus stopped on an error.

1007 · Information

The service's account was changed with ductus service account: from which, to which, and by whom.

The Ductus service's account was changed.

1010 · Error

An interface's code was stopped: it ran past its timeout three times in a row, or out of memory. Its messages wait; fix the code, deploy it and restart the service.

An interface was stopped: its code ran away. Its messages wait until the code is fixed and the engine is restarted.

1020 · Error

A critical alert fired or escalated (the alert channels are told too), for tools that watch the Event Log.

A critical alert fired.

1100 · Error

Any other error the engine logged, at most once a minute for each kind of message and what it's about; the log files have every line.

Ductus logged an error.

Prometheus metrics

GET /api/v1/metrics serves these in Prometheus's text format, for Prometheus, Grafana Agent, Datadog or anything else that scrapes it. It needs a token like every other call: in Prometheus's scrape configuration, metrics_path: /api/v1/metrics and authorization: { credentials: dux_… } (a viewer token: ductus token create prometheus --role viewer).

Counters and histograms count from when the engine started (rate() and increase() handle restarts); queue sizes are read from the database at each scrape. Each destination adds a series per family, so very many destinations mean many series.

MetricTypeLabelsWhat it measures
ductus_start_time_secondsgaugeWhen the engine started (Unix time).
ductus_draininggauge1 while draining or drained (not taking new messages).
ductus_alerts_opengaugeseverityOpen alerts, by severity. Only with alerts: in ductus.yaml.
ductus_alert_send_failures_totalcounterchannelAlert notifications that couldn't be sent (each try counts), by channel (owner: owners' addresses). A failing channel can't alert about itself: watch this.
ductus_certificate_expiry_secondsgaugeside, resource, host, portSeconds until the earliest expiry in a certificate chain (negative once expired): a server's as of its last check (side remote), or one the engine presents (side ours: host is this server, port 0).
ductus_interface_stoppedgaugeinterface1 if the interface was stopped because its code ran away.
ductus_messages_received_totalcounterinterfaceMessages stored and queued.
ductus_messages_duplicate_totalcounterinterfaceMessages stored as duplicates (not queued).
ductus_messages_store_failures_totalcounterinterfaceMessages that couldn't be stored (the sender was told to retry).
ductus_deliveries_totalcounterinterface, destination, outcomeMessages a destination finished, by outcome (sent, filtered, quarantined).
ductus_delivery_retries_totalcounterinterface, destinationTemporary failures (each retry counts).
ductus_delivery_latency_secondshistograminterface, destinationFrom receipt to sent, waiting in the queue included.
ductus_send_secondshistograminterface, destinationEach successful send: the receiver's time.
ductus_transform_secondshistograminterface, destinationThe destination's function, per message.
ductus_queue_pendinggaugeinterface, destinationMessages waiting for a destination.
ductus_queue_quarantinedgaugeinterface, destinationMessages quarantined by a destination (needing a person).
ductus_queue_oldest_secondsgaugeinterface, destinationHow long the oldest message waiting for a destination has waited.
ductus_destination_stategaugeinterface, destination0 ok, 1 behind (past its behind limit), 2 error (quarantined messages), 3 stalled, 4 paused.
ductus_interface_stategaugeinterface0 ok, 1 silent (nothing within active_within), 2 missed (nothing by active_by), 3 stopped.
ductus_destination_pausedgaugeinterface, destination1 while a destination is paused on purpose (ductus pause).
ductus_held_messagesgaugeinterface, statusUnfinished messages past their retention, in the held area (as of the hourly retention run).
ductus_held_bytesgaugeDisk used by the held area (as of the hourly retention run).
ductus_http_request_secondshistograminterfaceRespond-mode HTTP requests: time in the engine.
ductus_http_request_errors_totalcounterinterfaceRespond-mode HTTP requests that failed.
ductus_resource_call_secondshistogramkind, resourceCalls from interface code to a database or API.
ductus_resource_call_errors_totalcounterkind, resourceCalls that failed (network, SQL, timeout).

Audit actions

The audit log records who did what, to what, from where, and how it ended: on the Audit page of the web UI (with the audit permission), through GET /api/v1/audit, and with ductus audit export on the server. Each row has one of these actions. Rows are kept audit.keep (six years unless ductus.yaml says otherwise).

ActionWhat happened
session.sign_inSomeone signed in to the web UI, with a password or a token.
session.failedA sign-in was refused: a wrong password, a locked or disabled account, or a name nobody has (recorded as anonymous: what was typed isn't kept, it may be a password).
session.sign_outSomeone signed out (or signed out their other sessions).
password.changeSomeone changed their own password.
password.resetAn admin set another person's password (they must change it at their next sign-in).
user.createAn account was made: by an admin, at the command line, at a directory person's first sign-in, or by the engine for a directory person who owns something ("reason": "owner").
user.updateAn admin changed an account's role, permissions, name or email.
user.disableAn admin disabled or enabled an account (a disabled account's sessions and tokens stop at once).
user.unlockAn admin unlocked an account locked by wrong passwords.
user.directory_changeThe directory changed an account: its role, permissions, name or email, a rename, a sign-in name moved aside for someone new, or let it back in.
user.directory_refusedThe directory said no to an account (removed from the groups, disabled or deleted): its sessions and tokens stopped.
token.createAn API token was made (by an admin, at the command line, or by a person for themselves).
token.revokeAn API token was revoked.
message.payload.readSomeone read a message's payload (what it contains), written before it was shown; one row per person and message per audit.payload_read_window.
message.releaseSomeone resent a message's quarantined deliveries.
message.replaySomeone sent a message again to a destination that had already had it (sent or filtered).
message.editSomeone corrected a message and sent the corrected copy in its place.
message.dismissSomeone closed a quarantined delivery without sending it, with a reason.
destination.pauseSomeone paused a destination (with a reason, or added one later).
destination.resumeSomeone resumed a paused destination.
engine.drainSomeone started a drain: the engine stopped taking messages to deliver what it has.
engine.resumeSomeone ended a drain: the engine takes messages again.
retention.changeThe engine started applying a new retention to an interface's stored messages.
retention.confirmSomeone confirmed a shorter retention, which deletes messages past the new window.
doctor.runSomeone ran the connection checks from the web UI or the API.
audit.exportSomeone exported the audit log.
alert.ackSomeone acknowledged an alert: they're on it, and reminders stop.
alert.silenceSomeone silenced an alert (or a destination before planned work), with a reason and an end.
alert.unsilenceSomeone ended an alert's silence early.
alert.testSomeone sent a test notification to a channel.
engine.startThe engine started, with its version and how the previous run on this host ended (a start without a stop before it is a crash).
engine.stopThe engine stopped cleanly (or handed over to a new engine).
engine.handoverA new engine took over from the running one (systemctl reload: an upgrade or a deploy), with both versions and how long sending paused.
engine.refusedA start was refused: another live engine runs some of its interfaces, its lanes would strand waiting messages, or a handover would lower lanes.
interface.stoppedAn interface's code was stopped (it ran away or ran out of memory): its messages wait.
interface.partitions_raisedAn interface started with more lanes while messages waited: a key's new messages may pass its older ones until those are sent.

Libraries and versions

Everything this version of Ductus is built from, for a security or legal review: every Rust crate in the Linux and Windows builds of the ductus binary and every npm package in the web UI it serves, with its version and license. The ones a review usually asks about first come first. The lists include the crates that only run while the binary is compiled (macros, build scripts), since they shape it too.

Nothing on this page is downloaded when Ductus runs, except Postgres for ductus dev (below). The binary needs only the operating system's C library on Linux (glibc 2.34 or later).

  • Rust crates: 567 (542 in the Linux build, 549 in the Windows build)
  • npm packages in the web UI: 102
  • TypeScript 6.0.3 (Microsoft, Apache-2.0), inside the binary: ductus validate and ductus types type-check interface code with it

The notable ones

LibraryVersionLicenseWhat it does in DuctusIn
v8150.4.0MITGoogle's V8 JavaScript engine (V8 15.0.245.2): runs interface codeLinux, Windows
deno_core0.412.0MITEmbeds V8: modules, async calls, the bridge between interface code and the engineLinux, Windows
rustls0.23.45Apache-2.0 OR ISC OR MITTLS for every connection the engine makes or acceptsLinux, Windows
aws-lc-rs1.18.1ISC AND (Apache-2.0 OR ISC)The cryptography under TLS (AWS-LC; post-quantum key exchange preferred)Linux, Windows
rustls-platform-verifier0.7.1MIT OR Apache-2.0Checks certificates against the operating system's trusted rootsLinux, Windows
sqlx0.9.0MIT OR Apache-2.0PostgreSQL: the engine's store and queue, and db() calls to PostgresLinux, Windows
tiberius (fork)0.13.0MIT/Apache-2.0SQL Server for db() calls (our fork: Windows logins)Linux, Windows
tokio1.53.2MITThe asynchronous runtime: sockets, timers, tasksLinux, Windows
hyper1.11.1MITHTTP/1 and HTTP/2, under the API, HTTP sources and destinationsLinux, Windows
axum0.8.9MITThe REST API and the web UI's serverLinux, Windows
reqwest0.13.5MIT OR Apache-2.0Outgoing HTTP: destinations, fetch(), webhooksLinux, Windows
russh0.64.1Apache-2.0SFTP sources and destinations (SSH)Linux, Windows
ldap30.12.1MIT/Apache-2.0Sign-in with LDAP or Active DirectoryLinux, Windows
lettre0.11.23MITAlert email (SMTP)Linux, Windows
argon20.5.3, 0.6.0MIT OR Apache-2.0Hashes local accounts' passwords (argon2id)Linux, Windows
chacha20poly13050.11.0Apache-2.0 OR MITEncrypts secrets filesLinux, Windows
postgresql_embedded0.21.0(Apache-2.0 OR MIT) AND PostgreSQLductus dev: downloads and runs Postgres for a laptopLinux, Windows
serde_yaml_ng0.10.0MITReads the project's YAML filesLinux, Windows
clap4.6.7MIT OR Apache-2.0The ductus command lineLinux, Windows
windows-service0.8.1MIT OR Apache-2.0Runs as a Windows serviceWindows
TypeScript6.0.3Apache-2.0Type-checks interface code (validate, types); kept in vendor/typescriptLinux, Windows
react (npm)19.3.0MITThe web UI's componentsthe web UI
react-dom (npm)19.3.0MITRenders them in the browserthe web UI
@tanstack/react-router (npm)1.170.41MITThe web UI's pages and linksthe web UI
@tanstack/react-query (npm)5.104.1MITReads the REST API and keeps pages currentthe web UI
radix-ui (npm)1.7.0MITAccessible menus, dialogs and controlsthe web UI
openapi-fetch (npm)0.17.0MITThe typed REST API clientthe web UI

Downloaded by ductus dev

ductus dev (the engine with its own database, for a developer's machine) downloads PostgreSQL 18.6.0 once per user: zonky's binaries from Maven Central (about 30 MB), under the PostgreSQL License. Servers never download it: production uses your own PostgreSQL.

Licenses

How many libraries use each license expression (A OR B: the library may be used under either).

LicenseRust cratesnpm packages
MIT OR Apache-2.0257
MIT10798
Apache-2.0 OR MIT61
Apache-2.0421
Unicode-3.025
MIT/Apache-2.022
BSD-3-Clause7
ISC41
Unlicense OR MIT5
Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT4
(Apache-2.0 OR MIT) AND PostgreSQL3
Apache-2.0 OR ISC OR MIT3
(MIT OR Apache-2.0) AND Unicode-3.02
0BSD11
Apache-2.0 OR BSL-1.02
MIT OR Zlib OR Apache-2.02
Unlicense/MIT2
Zlib2
Zlib OR Apache-2.0 OR MIT2
(Apache-2.0 OR MIT) AND BSD-3-Clause1
0BSD OR MIT OR Apache-2.01
Apache-2.0 / MIT1
Apache-2.0 OR BSL-1.0 OR MIT1
Apache-2.0 WITH LLVM-exception1
Apache-2.0/MIT1
BSD-2-Clause OR Apache-2.0 OR MIT1
CC0-1.0 OR MIT-0 OR Apache-2.01
CDLA-Permissive-2.01
ISC AND (Apache-2.0 OR ISC)1
ISC AND (Apache-2.0 OR ISC) AND Apache-2.0 AND MIT AND BSD-3-Clause AND (Apache-2.0 OR ISC OR MIT) AND (Apache-2.0 OR ISC OR MIT-0)1
MIT AND BSD-3-Clause1
MPL-2.01
MPL-2.0+1
Unlicense1

Every Rust crate

CrateVersionLicenseLinuxWindows
adler22.0.10BSD OR MIT OR Apache-2.0✓✓
aead0.6.1MIT OR Apache-2.0✓✓
aes0.9.3MIT OR Apache-2.0✓✓
aes-gcm0.11.1Apache-2.0 OR MIT✓✓
ahash0.8.12MIT OR Apache-2.0✓✓
aho-corasick1.1.5Unlicense OR MIT✓✓
allocator-api20.2.21MIT OR Apache-2.0✓✓
anstream1.0.0MIT OR Apache-2.0✓✓
anstyle1.0.14MIT OR Apache-2.0✓✓
anstyle-parse1.0.0MIT OR Apache-2.0✓✓
anstyle-query1.1.5MIT OR Apache-2.0✓✓
anstyle-wincon3.0.11MIT OR Apache-2.0✓
anyhow1.0.104MIT OR Apache-2.0✓✓
ar_archive_writer0.5.3Apache-2.0 WITH LLVM-exception✓✓
argon20.5.3MIT OR Apache-2.0✓✓
argon20.6.0MIT OR Apache-2.0✓✓
ascii1.1.0Apache-2.0 OR MIT✓✓
asn1-rs0.7.2MIT OR Apache-2.0✓✓
asn1-rs-derive0.6.0MIT OR Apache-2.0✓✓
asn1-rs-impl0.2.0MIT/Apache-2.0✓✓
ast_node5.0.0Apache-2.0✓✓
async-recursion1.2.0MIT OR Apache-2.0✓
async-trait0.1.92MIT OR Apache-2.0✓✓
asynchronous-codec0.7.0MIT✓✓
atoi2.0.0MIT✓✓
atomic-waker1.1.2Apache-2.0 OR MIT✓✓
autocfg1.5.1Apache-2.0 OR MIT✓✓
aws-lc-rs1.18.1ISC AND (Apache-2.0 OR ISC)✓✓
aws-lc-sys0.45.0ISC AND (Apache-2.0 OR ISC) AND Apache-2.0 AND MIT AND BSD-3-Clause AND (Apache-2.0 OR ISC OR MIT) AND (Apache-2.0 OR ISC OR MIT-0)✓✓
axum0.8.9MIT✓✓
axum-core0.5.6MIT✓✓
base16ct1.0.0Apache-2.0 OR MIT✓✓
base640.22.1MIT OR Apache-2.0✓✓
base640.23.1MIT OR Apache-2.0✓✓
base64-simd0.8.0MIT✓✓
base64ct1.8.3Apache-2.0 OR MIT✓✓
bcrypt-pbkdf0.11.0MIT OR Apache-2.0✓✓
better_scoped_tls1.0.1Apache-2.0✓✓
bindgen0.72.1BSD-3-Clause✓✓
bit-set0.8.0Apache-2.0 OR MIT✓✓
bit-vec0.8.0Apache-2.0 OR MIT✓✓
bitflags2.13.2MIT OR Apache-2.0✓✓
bitvec1.1.1MIT✓✓
blake20.10.6MIT OR Apache-2.0✓✓
blake20.11.0MIT OR Apache-2.0✓✓
block-buffer0.10.4MIT OR Apache-2.0✓✓
block-buffer0.12.1MIT OR Apache-2.0✓✓
block-padding0.4.2MIT OR Apache-2.0✓✓
blowfish0.10.0MIT OR Apache-2.0✓✓
boxed_error0.2.3MIT✓✓
bumpalo3.20.3MIT OR Apache-2.0✓✓
bytemuck1.25.2Zlib OR Apache-2.0 OR MIT✓
byteorder1.5.0Unlicense OR MIT✓✓
bytes1.12.1MIT✓✓
bytes-str0.2.9Apache-2.0✓✓
calendrical_calculations0.2.4Apache-2.0✓✓
capacity_builder0.5.0MIT✓✓
capacity_builder_macros0.3.0MIT✓✓
castaway0.2.4MIT✓✓
cbc0.2.1MIT OR Apache-2.0✓✓
cc1.6.0MIT OR Apache-2.0✓✓
cexpr0.6.0Apache-2.0/MIT✓✓
cfg_aliases0.2.2MIT✓
cfg-if1.0.5MIT OR Apache-2.0✓✓
chacha200.10.2MIT OR Apache-2.0✓✓
chacha20poly13050.11.0Apache-2.0 OR MIT✓✓
chrono0.4.45MIT OR Apache-2.0✓✓
cipher0.5.2MIT OR Apache-2.0✓✓
clang-sys1.9.1Apache-2.0✓✓
clap4.6.7MIT OR Apache-2.0✓✓
clap_builder4.6.7MIT OR Apache-2.0✓✓
clap_derive4.6.7MIT OR Apache-2.0✓✓
clap_lex1.1.1MIT OR Apache-2.0✓✓
cmake0.1.58MIT OR Apache-2.0✓✓
cmov0.5.4Apache-2.0 OR MIT✓✓
colorchoice1.0.5MIT OR Apache-2.0✓✓
compact_str0.7.1MIT✓✓
connection-string0.2.0MIT OR Apache-2.0✓✓
const-oid0.10.2Apache-2.0 OR MIT✓✓
cooked-waker5.0.0MPL-2.0✓✓
core_detect1.0.0MIT/Apache-2.0✓✓
core_maths0.1.1MIT✓✓
cpubits0.1.1MIT OR Apache-2.0✓✓
cpufeatures0.2.17MIT OR Apache-2.0✓✓
cpufeatures0.3.1MIT OR Apache-2.0✓✓
crc3.4.0MIT OR Apache-2.0✓✓
crc-catalog2.5.0MIT OR Apache-2.0✓✓
crc32fast1.5.2MIT OR Apache-2.0✓✓
croner4.0.1MIT✓✓
crossbeam-channel0.5.17MIT OR Apache-2.0✓
crossbeam-queue0.3.14MIT OR Apache-2.0✓✓
crossbeam-utils0.8.23MIT OR Apache-2.0✓✓
crypto-bigint0.7.5Apache-2.0 OR MIT✓✓
crypto-common0.1.7MIT OR Apache-2.0✓✓
crypto-common0.2.2MIT OR Apache-2.0✓✓
crypto-mac0.11.0MIT OR Apache-2.0✓
crypto-primes0.7.2Apache-2.0 OR MIT✓✓
csv1.4.0Unlicense/MIT✓✓
csv-core0.1.13Unlicense/MIT✓✓
ctr0.10.1MIT OR Apache-2.0✓✓
ctutils0.4.3Apache-2.0 OR MIT✓✓
curve25519-dalek5.0.0BSD-3-Clause✓✓
curve25519-dalek-derive0.1.1MIT/Apache-2.0✓✓
darling0.20.11MIT✓✓
darling_core0.20.11MIT✓✓
darling_macro0.20.11MIT✓✓
dashmap6.2.1MIT✓✓
data-encoding2.11.1MIT✓✓
data-url0.3.2MIT OR Apache-2.0✓✓
debugid0.8.0Apache-2.0✓✓
delegate0.13.5MIT OR Apache-2.0✓✓
deno_ast0.53.3MIT✓✓
deno_core0.412.0MIT✓✓
deno_error0.7.1MIT✓✓
deno_error_macro0.7.1MIT✓✓
deno_media_type0.4.0MIT✓✓
deno_ops0.288.0MIT✓✓
deno_path_util0.6.4MIT✓✓
deno_terminal0.2.3MIT✓✓
deno_unsync0.4.4MIT✓✓
deno_v80.4.0MIT✓✓
der0.8.2Apache-2.0 OR MIT✓✓
der-parser10.0.0MIT OR Apache-2.0✓✓
deranged0.5.8MIT OR Apache-2.0✓✓
derive_builder0.20.2MIT OR Apache-2.0✓✓
derive_builder_core0.20.2MIT OR Apache-2.0✓✓
derive_builder_macro0.20.2MIT OR Apache-2.0✓✓
des0.9.0MIT OR Apache-2.0✓
digest0.10.7MIT OR Apache-2.0✓✓
digest0.11.3MIT OR Apache-2.0✓✓
diplomat0.16.1MIT OR Apache-2.0✓✓
diplomat_core0.16.1MIT OR Apache-2.0✓✓
diplomat-runtime0.16.0MIT OR Apache-2.0✓✓
displaydoc0.2.7MIT OR Apache-2.0✓✓
dotenvy0.15.7MIT✓✓
dprint-swc-ext0.26.1MIT✓✓
dunce1.0.5CC0-1.0 OR MIT-0 OR Apache-2.0✓✓
ecdsa0.17.0Apache-2.0 OR MIT✓✓
ed255193.0.0Apache-2.0 OR MIT✓✓
ed25519-dalek3.0.0BSD-3-Clause✓✓
either1.18.0MIT OR Apache-2.0✓✓
elliptic-curve0.14.1Apache-2.0 OR MIT✓✓
email_address0.2.9MIT✓✓
email-encoding0.4.2MIT OR Apache-2.0✓✓
embed-resource3.0.12MIT✓✓
encoding_rs0.8.42(Apache-2.0 OR MIT) AND BSD-3-Clause✓✓
enum_dispatch0.3.13MIT OR Apache-2.0✓✓
enumflags20.7.12MIT OR Apache-2.0✓✓
enumflags2_derive0.7.12MIT OR Apache-2.0✓✓
equivalent1.0.2Apache-2.0 OR MIT✓✓
errno0.3.14MIT OR Apache-2.0✓✓
etcetera0.11.0MIT OR Apache-2.0✓
event-listener5.4.2Apache-2.0 OR MIT✓✓
fastrand2.5.0Apache-2.0 OR MIT✓✓
ff0.14.0MIT/Apache-2.0✓✓
filetime0.2.29MIT/Apache-2.0✓✓
find-msvc-tools0.1.14MIT OR Apache-2.0✓✓
flate21.1.10MIT OR Apache-2.0✓✓
fnv1.0.7Apache-2.0 / MIT✓✓
foldhash0.2.0Zlib✓✓
form_urlencoded1.2.2MIT OR Apache-2.0✓✓
from_variant3.0.0Apache-2.0✓✓
fs_extra1.3.0MIT✓✓
fslock0.2.1MIT✓✓
funty2.0.0MIT✓✓
futures0.3.34MIT OR Apache-2.0✓✓
futures-channel0.3.34MIT OR Apache-2.0✓✓
futures-core0.3.34MIT OR Apache-2.0✓✓
futures-executor0.3.34MIT OR Apache-2.0✓✓
futures-intrusive0.5.0MIT OR Apache-2.0✓✓
futures-io0.3.34MIT OR Apache-2.0✓✓
futures-macro0.3.34MIT OR Apache-2.0✓✓
futures-sink0.3.34MIT OR Apache-2.0✓✓
futures-task0.3.34MIT OR Apache-2.0✓✓
futures-timer3.0.4MIT/Apache-2.0✓✓
futures-util0.3.34MIT OR Apache-2.0✓✓
generic-array0.14.7MIT✓✓
generic-array1.4.5MIT✓✓
getrandom0.3.4MIT OR Apache-2.0✓
getrandom0.4.3MIT OR Apache-2.0✓✓
ghash0.6.0Apache-2.0 OR MIT✓✓
glob0.3.4MIT OR Apache-2.0✓✓
group0.14.0MIT/Apache-2.0✓✓
gzip-header1.1.0MIT/Apache-2.0✓✓
h20.4.19MIT✓✓
hashbrown0.14.5MIT OR Apache-2.0✓✓
hashbrown0.16.1MIT OR Apache-2.0✓✓
hashbrown0.17.1MIT OR Apache-2.0✓✓
hashlink0.11.1MIT OR Apache-2.0✓✓
heck0.5.0MIT OR Apache-2.0✓✓
hex0.4.3MIT OR Apache-2.0✓✓
hex-literal1.1.0MIT OR Apache-2.0✓✓
hkdf0.13.0MIT OR Apache-2.0✓✓
hmac0.13.0MIT OR Apache-2.0✓✓
home0.5.12MIT OR Apache-2.0✓✓
hostname0.4.2MIT✓✓
hstr3.0.6Apache-2.0✓✓
http1.5.0MIT OR Apache-2.0✓✓
http-body1.1.0MIT✓✓
http-body-util0.1.5MIT✓✓
httparse1.10.1MIT OR Apache-2.0✓✓
httpdate1.0.3MIT OR Apache-2.0✓✓
hybrid-array0.4.15MIT OR Apache-2.0✓✓
hyper1.11.1MIT✓✓
hyper-rustls0.27.10Apache-2.0 OR ISC OR MIT✓✓
hyper-util0.1.21MIT✓✓
iana-time-zone0.1.65MIT OR Apache-2.0✓✓
icu_calendar2.3.0Unicode-3.0✓✓
icu_calendar_data2.3.0Unicode-3.0✓✓
icu_collections2.3.0Unicode-3.0✓✓
icu_locale_core2.3.0Unicode-3.0✓✓
icu_locale_fallback2.3.0Unicode-3.0✓✓
icu_locale_fallback_data2.3.0Unicode-3.0✓✓
icu_normalizer2.3.0Unicode-3.0✓✓
icu_normalizer_data2.3.0Unicode-3.0✓✓
icu_properties2.3.0Unicode-3.0✓✓
icu_properties_data2.3.0Unicode-3.0✓✓
icu_provider2.3.1Unicode-3.0✓✓
ident_case1.0.1MIT/Apache-2.0✓✓
idna1.1.0MIT OR Apache-2.0✓✓
idna_adapter1.2.2Apache-2.0 OR MIT✓✓
if_chain1.0.3MIT/Apache-2.0✓✓
indexmap2.14.2Apache-2.0 OR MIT✓✓
inout0.2.2MIT OR Apache-2.0✓✓
inventory0.3.24MIT OR Apache-2.0✓✓
ipnet2.12.2MIT OR Apache-2.0✓✓
is_terminal_polyfill1.70.2MIT OR Apache-2.0✓✓
is-macro0.3.8Apache-2.0✓✓
itertools0.13.0MIT OR Apache-2.0✓✓
itoa1.0.18MIT OR Apache-2.0✓✓
ixdtf0.6.6Unicode-3.0✓✓
jobserver0.1.35MIT OR Apache-2.0✓✓
keccak0.2.2Apache-2.0 OR MIT✓✓
kem0.3.0Apache-2.0 OR MIT✓✓
lazy_static1.5.1MIT OR Apache-2.0✓✓
lber0.5.1MIT✓✓
ldap30.12.1MIT/Apache-2.0✓✓
lettre0.11.23MIT✓✓
libc0.2.190MIT OR Apache-2.0✓✓
libloading0.8.9ISC✓✓
liblzma0.4.8MIT OR Apache-2.0✓✓
liblzma-sys0.4.9MIT OR Apache-2.0✓✓
libm0.2.16MIT✓✓
linux-raw-sys0.4.15Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT✓✓
linux-raw-sys0.12.1Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT✓✓
litemap0.8.3Unicode-3.0✓✓
lock_api0.4.14MIT OR Apache-2.0✓✓
log0.4.34MIT OR Apache-2.0✓✓
matchers0.2.0MIT✓✓
matchit0.8.4MIT AND BSD-3-Clause✓✓
md-50.10.6MIT OR Apache-2.0✓✓
md-50.11.0MIT OR Apache-2.0✓✓
md40.11.0MIT OR Apache-2.0✓
md50.8.1Apache-2.0 OR MIT✓✓
memchr2.8.3Unlicense OR MIT✓✓
mime0.3.17MIT OR Apache-2.0✓✓
minimal-lexical0.2.1MIT/Apache-2.0✓✓
miniz_oxide0.8.9MIT OR Zlib OR Apache-2.0✓✓
miniz_oxide0.9.1MIT OR Zlib OR Apache-2.0✓✓
mio1.2.4MIT✓✓
ml-kem0.3.2Apache-2.0 OR MIT✓✓
module-lattice0.2.3Apache-2.0 OR MIT✓✓
multiversion_no_op1.0.0Apache-2.0 OR MIT✓✓
new_debug_unreachable1.0.6MIT✓✓
nix0.31.3MIT✓
nom7.1.3MIT✓✓
nom8.0.0MIT✓✓
nu-ansi-term0.50.3MIT✓✓
num_cpus1.17.0MIT OR Apache-2.0✓✓
num-bigint0.4.8MIT OR Apache-2.0✓✓
num-bigint0.5.1MIT OR Apache-2.0✓✓
num-conv0.2.2MIT OR Apache-2.0✓✓
num-derive0.5.1MIT OR Apache-2.0✓
num-integer0.1.47MIT OR Apache-2.0✓✓
num-traits0.2.19MIT OR Apache-2.0✓✓
object0.39.1Apache-2.0 OR MIT✓✓
oid0.2.1MIT OR Apache-2.0✓
oid-registry0.8.1MIT OR Apache-2.0✓✓
once_cell1.21.4MIT OR Apache-2.0✓✓
once_cell_polyfill1.70.2MIT OR Apache-2.0✓
openssl-probe0.2.1MIT OR Apache-2.0✓✓
outref0.5.2MIT✓✓
p2560.14.0Apache-2.0 OR MIT✓✓
p3840.14.0Apache-2.0 OR MIT✓✓
p5210.14.0Apache-2.0 OR MIT✓✓
pageant0.2.4Apache-2.0✓
par-core2.0.0Apache-2.0✓✓
parking2.2.1Apache-2.0 OR MIT✓✓
parking_lot0.12.5MIT OR Apache-2.0✓✓
parking_lot_core0.9.12MIT OR Apache-2.0✓✓
password-hash0.5.0MIT OR Apache-2.0✓✓
paste1.0.15MIT OR Apache-2.0✓✓
pastey0.2.3MIT OR Apache-2.0✓✓
pathdiff0.2.3MIT/Apache-2.0✓✓
pbkdf20.13.0MIT OR Apache-2.0✓✓
pem4.0.0MIT✓✓
pem-rfc74681.0.0Apache-2.0 OR MIT✓✓
percent-encoding2.3.2MIT OR Apache-2.0✓✓
phf0.11.3MIT✓✓
phf_generator0.11.3MIT✓✓
phf_macros0.11.3MIT✓✓
phf_shared0.11.3MIT✓✓
picky7.0.0-rc.26MIT OR Apache-2.0✓
picky-asn10.10.1MIT OR Apache-2.0✓
picky-asn1-der0.5.7MIT OR Apache-2.0✓
picky-asn1-x5090.15.5MIT OR Apache-2.0✓
picky-krb0.12.4MIT OR Apache-2.0✓
pin-project1.1.13Apache-2.0 OR MIT✓✓
pin-project-internal1.1.13Apache-2.0 OR MIT✓✓
pin-project-lite0.2.17Apache-2.0 OR MIT✓✓
pkcs10.8.0-rc.4Apache-2.0 OR MIT✓✓
pkcs50.8.1Apache-2.0 OR MIT✓✓
pkcs80.11.0Apache-2.0 OR MIT✓✓
pkg-config0.3.34MIT OR Apache-2.0✓✓
poly13050.9.1Apache-2.0 OR MIT✓✓
polyval0.7.3Apache-2.0 OR MIT✓✓
postgresql_archive0.21.0(Apache-2.0 OR MIT) AND PostgreSQL✓✓
postgresql_commands0.21.0(Apache-2.0 OR MIT) AND PostgreSQL✓✓
postgresql_embedded0.21.0(Apache-2.0 OR MIT) AND PostgreSQL✓✓
potential_utf0.1.6Unicode-3.0✓✓
powerfmt0.2.1MIT OR Apache-2.0✓✓
pretty-hex0.4.2MIT✓✓
prettyplease0.2.37MIT OR Apache-2.0✓✓
primefield0.14.0Apache-2.0 OR MIT✓✓
primeorder0.14.0Apache-2.0 OR MIT✓✓
proc-macro21.0.107MIT OR Apache-2.0✓✓
psm0.1.32MIT OR Apache-2.0✓✓
quick-xml0.41.0MIT✓✓
quote1.0.47MIT OR Apache-2.0✓✓
quoted_printable0.5.20BSD✓✓
radium0.7.0MIT✓✓
rand0.8.8MIT OR Apache-2.0✓✓
rand0.10.3MIT OR Apache-2.0✓✓
rand_core0.6.4MIT OR Apache-2.0✓✓
rand_core0.10.1MIT OR Apache-2.0✓✓
rcgen0.14.10MIT OR Apache-2.0✓✓
regex1.13.1MIT OR Apache-2.0✓✓
regex-automata0.4.18MIT OR Apache-2.0✓✓
regex-lite0.1.9MIT OR Apache-2.0✓✓
regex-syntax0.8.11MIT OR Apache-2.0✓✓
reqwest0.13.5MIT OR Apache-2.0✓✓
reqwest-middleware0.5.2MIT OR Apache-2.0✓✓
reqwest-retry0.9.1MIT OR Apache-2.0✓✓
reqwest-tracing0.7.1MIT OR Apache-2.0✓✓
resb0.1.2Unicode-3.0✓✓
retry-policies0.5.2MIT OR Apache-2.0✓✓
rfc69790.6.0Apache-2.0 OR MIT✓✓
rpassword7.5.4Apache-2.0✓✓
rsa0.10.0-rc.18MIT OR Apache-2.0✓✓
rtoolbox0.0.6Apache-2.0✓✓
russh0.64.1Apache-2.0✓✓
russh-cryptovec0.62.0Apache-2.0✓✓
russh-sftp3.0.1Apache-2.0✓✓
russh-util0.52.0Apache-2.0✓✓
rustc_version0.4.1MIT OR Apache-2.0✓✓
rustc-hash2.1.3Apache-2.0 OR MIT✓✓
rusticata-macros4.1.0MIT/Apache-2.0✓✓
rustix0.38.44Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT✓✓
rustix1.1.5Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT✓✓
rustls0.23.45Apache-2.0 OR ISC OR MIT✓✓
rustls-native-certs0.8.4Apache-2.0 OR ISC OR MIT✓✓
rustls-pki-types1.15.1MIT OR Apache-2.0✓✓
rustls-platform-verifier0.7.1MIT OR Apache-2.0✓✓
rustls-webpki0.103.15ISC✓✓
rustversion1.0.23MIT OR Apache-2.0✓✓
ryu1.0.23Apache-2.0 OR BSL-1.0✓✓
ryu-js1.0.3Apache-2.0 OR BSL-1.0✓✓
salsa200.11.0MIT OR Apache-2.0✓✓
schannel0.1.29MIT✓
scoped-tls1.0.1MIT/Apache-2.0✓✓
scopeguard1.2.0MIT OR Apache-2.0✓✓
scrypt0.12.0MIT OR Apache-2.0✓✓
sec10.8.1Apache-2.0 OR MIT✓✓
secrecy0.10.3Apache-2.0 OR MIT✓✓
semver1.0.28MIT OR Apache-2.0✓✓
seq-macro0.3.6MIT OR Apache-2.0✓✓
serde1.0.229MIT OR Apache-2.0✓✓
serde_bytes0.11.19MIT OR Apache-2.0✓✓
serde_core1.0.229MIT OR Apache-2.0✓✓
serde_derive1.0.229MIT OR Apache-2.0✓✓
serde_json1.0.151MIT OR Apache-2.0✓✓
serde_path_to_error0.1.20MIT OR Apache-2.0✓✓
serde_spanned1.1.2MIT OR Apache-2.0✓✓
serde_urlencoded0.7.1MIT/Apache-2.0✓✓
serde_v80.321.0MIT✓✓
serde_yaml_ng0.10.0MIT✓✓
sha10.10.7MIT OR Apache-2.0✓✓
sha10.11.0MIT OR Apache-2.0✓✓
sha20.10.9MIT OR Apache-2.0✓✓
sha20.11.0MIT OR Apache-2.0✓✓
sha30.11.0MIT OR Apache-2.0✓✓
sha30.12.0MIT OR Apache-2.0✓✓
sharded-slab0.1.7MIT✓✓
shlex1.3.0MIT OR Apache-2.0✓✓
shlex2.0.1MIT OR Apache-2.0✓✓
signal-hook-registry1.4.8MIT OR Apache-2.0✓✓
signature3.0.0Apache-2.0 OR MIT✓✓
simd-adler320.3.10MIT✓✓
simdutf80.1.5MIT OR Apache-2.0✓✓
siphasher0.3.11MIT/Apache-2.0✓✓
siphasher1.0.4MIT OR Apache-2.0✓✓
slab0.4.12MIT✓✓
smallvec1.16.2MIT OR Apache-2.0✓✓
smartstring1.0.1MPL-2.0+✓✓
socket20.6.5MIT OR Apache-2.0✓✓
sourcemap9.3.2BSD-3-Clause✓✓
spki0.8.1Apache-2.0 OR MIT✓✓
sponge-cursor0.1.0MIT OR Apache-2.0✓✓
sqlx0.9.0MIT OR Apache-2.0✓✓
sqlx-core0.9.0MIT OR Apache-2.0✓✓
sqlx-macros0.9.0MIT OR Apache-2.0✓✓
sqlx-macros-core0.9.0MIT OR Apache-2.0✓✓
sqlx-postgres0.9.0MIT OR Apache-2.0✓✓
ssh-cipher0.3.0Apache-2.0 OR MIT✓✓
ssh-encoding0.3.0Apache-2.0 OR MIT✓✓
ssh-key0.7.0-rc.11Apache-2.0 OR MIT✓✓
sspi0.23.0MIT OR Apache-2.0✓
stable_deref_trait1.2.1MIT OR Apache-2.0✓✓
stacker0.1.25MIT OR Apache-2.0✓✓
static_assertions1.1.0MIT OR Apache-2.0✓✓
strck1.0.0MIT✓✓
string_enum1.0.2Apache-2.0✓✓
stringcase0.4.0MIT✓✓
stringprep0.1.5MIT/Apache-2.0✓✓
strsim0.11.1MIT✓✓
strum0.27.2MIT✓✓
strum_macros0.27.2MIT✓✓
subtle2.6.1BSD-3-Clause✓✓
swc_allocator4.0.1Apache-2.0✓✓
swc_atoms9.0.0Apache-2.0✓✓
swc_common17.0.1Apache-2.0✓✓
swc_config3.1.2Apache-2.0✓✓
swc_config_macro1.0.1Apache-2.0✓✓
swc_ecma_ast18.0.0Apache-2.0✓✓
swc_ecma_codegen20.0.2Apache-2.0✓✓
swc_ecma_codegen_macros2.0.2Apache-2.0✓✓
swc_ecma_lexer26.0.0Apache-2.0✓✓
swc_ecma_loader17.0.0Apache-2.0✓✓
swc_ecma_parser27.0.7Apache-2.0✓✓
swc_ecma_transforms_base30.0.1Apache-2.0✓✓
swc_ecma_transforms_classes30.0.0Apache-2.0✓✓
swc_ecma_transforms_macros1.0.1Apache-2.0✓✓
swc_ecma_transforms_proposal30.0.0Apache-2.0✓✓
swc_ecma_transforms_react33.0.0Apache-2.0✓✓
swc_ecma_transforms_typescript33.0.0Apache-2.0✓✓
swc_ecma_utils24.0.0Apache-2.0✓✓
swc_ecma_visit18.0.1Apache-2.0✓✓
swc_eq_ignore_macros1.0.1Apache-2.0✓✓
swc_macros_common1.0.1Apache-2.0✓✓
swc_sourcemap9.3.4BSD-3-Clause✓✓
swc_visit2.0.1Apache-2.0✓✓
symlink0.1.0MIT/Apache-2.0✓
syn2.0.119MIT OR Apache-2.0✓✓
syn3.0.6MIT OR Apache-2.0✓✓
syn-match0.3.1MIT✓✓
sync_wrapper1.0.2Apache-2.0✓✓
synstructure0.13.2MIT✓✓
synstructure0.14.0MIT✓✓
sys_traits0.1.29MIT✓✓
sys_traits_macros0.1.0MIT✓✓
tap1.0.1MIT✓✓
tar0.4.46MIT OR Apache-2.0✓✓
target-triple1.0.1MIT OR Apache-2.0✓✓
tempfile3.27.0MIT OR Apache-2.0✓✓
temporal_capi0.2.6MIT OR Apache-2.0✓✓
temporal_rs0.2.6MIT OR Apache-2.0✓✓
termcolor1.4.1Unlicense OR MIT✓✓
text_lines0.6.0MIT✓✓
thiserror2.0.21MIT OR Apache-2.0✓✓
thiserror-impl2.0.21MIT OR Apache-2.0✓✓
thread_local1.1.10MIT OR Apache-2.0✓✓
tiberius (from https://github.com/thorst/tiberius?rev=94fea3f1491fe85862590fee8ce7cafa647a8e54)0.13.0MIT/Apache-2.0✓✓
time0.3.55MIT OR Apache-2.0✓✓
time-core0.1.9MIT OR Apache-2.0✓✓
time-macros0.2.32MIT OR Apache-2.0✓✓
timezone_provider0.2.6MIT OR Apache-2.0✓✓
tinystr0.8.4Unicode-3.0✓✓
tinyvec1.13.3Zlib OR Apache-2.0 OR MIT✓✓
tokio1.53.2MIT✓✓
tokio-macros2.7.2MIT✓✓
tokio-rustls0.26.6MIT OR Apache-2.0✓✓
tokio-stream0.1.19MIT✓✓
tokio-util0.7.19MIT✓✓
toml1.1.8+spec-1.1.0MIT OR Apache-2.0✓✓
toml_datetime1.1.2+spec-1.1.0MIT OR Apache-2.0✓✓
toml_parser1.1.5+spec-1.1.0MIT OR Apache-2.0✓✓
toml_writer1.1.3+spec-1.1.0MIT OR Apache-2.0✓✓
tower0.5.3MIT✓✓
tower-http0.6.11MIT✓✓
tower-layer0.3.3MIT✓✓
tower-service0.3.3MIT✓✓
tracing0.1.44MIT✓✓
tracing-appender0.2.5MIT✓
tracing-attributes0.1.31MIT✓✓
tracing-core0.1.36MIT✓✓
tracing-log0.2.0MIT✓✓
tracing-subscriber0.3.23MIT✓✓
triomphe0.1.16MIT OR Apache-2.0✓✓
try-lock0.2.5MIT✓✓
typed-path0.12.3MIT OR Apache-2.0✓✓
typenum1.20.1MIT OR Apache-2.0✓✓
unicode-bidi0.3.18MIT OR Apache-2.0✓✓
unicode-id-start1.5.0(MIT OR Apache-2.0) AND Unicode-3.0✓✓
unicode-ident1.0.26(MIT OR Apache-2.0) AND Unicode-3.0✓✓
unicode-normalization0.1.25MIT OR Apache-2.0✓✓
unicode-properties0.1.4MIT/Apache-2.0✓✓
unicode-width0.2.2MIT OR Apache-2.0✓✓
universal-hash0.6.1MIT OR Apache-2.0✓✓
unsafe-libyaml0.2.11MIT✓✓
untrusted0.7.1ISC✓✓
untrusted0.9.0ISC✓✓
url2.5.8MIT OR Apache-2.0✓✓
utf8_iter1.0.4Apache-2.0 OR MIT✓✓
utf8parse0.2.2Apache-2.0 OR MIT✓✓
utoipa6.0.0MIT OR Apache-2.0✓✓
utoipa-axum0.3.0MIT OR Apache-2.0✓✓
utoipa-gen6.0.1MIT OR Apache-2.0✓✓
uuid1.27.0Apache-2.0 OR MIT✓✓
v8150.4.0MIT✓✓
version_check0.9.5MIT/Apache-2.0✓✓
vsimd0.8.0MIT✓✓
want0.3.2MIT✓✓
wasm_dep_analyzer0.4.0MIT✓✓
webpki-roots1.0.9CDLA-Permissive-2.0✓✓
which6.0.3MIT✓✓
whoami2.1.3Apache-2.0 OR BSL-1.0 OR MIT✓✓
widestring1.2.1MIT OR Apache-2.0✓✓
winapi-util0.1.11Unlicense OR MIT✓
windows0.62.2MIT OR Apache-2.0✓
windows_x86_64_msvc0.52.6MIT OR Apache-2.0✓
windows-collections0.3.2MIT OR Apache-2.0✓
windows-core0.62.2MIT OR Apache-2.0✓
windows-future0.3.2MIT OR Apache-2.0✓
windows-implement0.60.2MIT OR Apache-2.0✓
windows-interface0.59.3MIT OR Apache-2.0✓
windows-link0.2.1MIT OR Apache-2.0✓
windows-numerics0.3.1MIT OR Apache-2.0✓
windows-result0.4.1MIT OR Apache-2.0✓
windows-service0.8.1MIT OR Apache-2.0✓
windows-strings0.5.1MIT OR Apache-2.0✓
windows-sys0.59.0MIT OR Apache-2.0✓
windows-sys0.61.2MIT OR Apache-2.0✓
windows-targets0.52.6MIT OR Apache-2.0✓
windows-threading0.2.1MIT OR Apache-2.0✓
winnow1.0.4MIT✓✓
wnaf0.14.1Apache-2.0 OR MIT✓✓
writeable0.6.4Unicode-3.0✓✓
wyz0.5.1MIT✓✓
x25519-dalek3.0.0BSD-3-Clause✓
x509-parser0.18.1MIT OR Apache-2.0✓✓
xattr1.6.1MIT OR Apache-2.0✓✓
yasna0.6.0MIT OR Apache-2.0✓✓
yoke0.8.3Unicode-3.0✓✓
yoke-derive0.8.4Unicode-3.0✓✓
zerocopy0.8.60BSD-2-Clause OR Apache-2.0 OR MIT✓✓
zerofrom0.1.8Unicode-3.0✓✓
zerofrom-derive0.1.8Unicode-3.0✓✓
zeroize1.9.0Apache-2.0 OR MIT✓✓
zeroize_derive1.5.0Apache-2.0 OR MIT✓
zerotrie0.2.5Unicode-3.0✓✓
zerovec0.11.8Unicode-3.0✓✓
zerovec-derive0.11.6Unicode-3.0✓✓
zip8.6.0MIT✓✓
zlib-rs0.6.8Zlib✓✓
zmij1.0.23MIT✓✓
zoneinfo640.3.0Unicode-3.0✓✓
zopfli0.8.4Apache-2.0✓✓

Every npm package in the web UI

The packages its production build bundles (the build tools and test tools aren't shipped).

PackageVersionLicense
@floating-ui/core1.8.0MIT
@floating-ui/dom1.8.0MIT
@floating-ui/react-dom2.1.9MIT
@floating-ui/utils0.2.12MIT
@radix-ui/number1.1.3MIT
@radix-ui/primitive1.1.7MIT
@radix-ui/react-accessible-icon1.1.16MIT
@radix-ui/react-accordion1.2.21MIT
@radix-ui/react-alert-dialog1.1.24MIT
@radix-ui/react-arrow1.1.16MIT
@radix-ui/react-aspect-ratio1.1.16MIT
@radix-ui/react-avatar1.2.7MIT
@radix-ui/react-checkbox1.3.12MIT
@radix-ui/react-collapsible1.1.21MIT
@radix-ui/react-collection1.1.16MIT
@radix-ui/react-compose-refs1.1.5MIT
@radix-ui/react-context1.2.2MIT
@radix-ui/react-context-menu2.3.8MIT
@radix-ui/react-dialog1.2.0MIT
@radix-ui/react-direction1.1.5MIT
@radix-ui/react-dismissable-layer1.1.20MIT
@radix-ui/react-dropdown-menu2.1.25MIT
@radix-ui/react-focus-guards1.1.6MIT
@radix-ui/react-focus-scope1.2.0MIT
@radix-ui/react-form0.2.0MIT
@radix-ui/react-hover-card1.1.24MIT
@radix-ui/react-id1.1.4MIT
@radix-ui/react-label2.1.16MIT
@radix-ui/react-menu2.1.25MIT
@radix-ui/react-menubar1.1.25MIT
@radix-ui/react-navigation-menu1.3.0MIT
@radix-ui/react-one-time-password-field0.1.17MIT
@radix-ui/react-password-toggle-field0.1.12MIT
@radix-ui/react-popover1.2.0MIT
@radix-ui/react-popper1.3.8MIT
@radix-ui/react-portal1.1.18MIT
@radix-ui/react-presence1.1.11MIT
@radix-ui/react-primitive2.1.11MIT
@radix-ui/react-progress1.1.17MIT
@radix-ui/react-radio-group1.4.8MIT
@radix-ui/react-roving-focus1.1.20MIT
@radix-ui/react-scroll-area1.3.0MIT
@radix-ui/react-select2.3.8MIT
@radix-ui/react-separator1.1.16MIT
@radix-ui/react-slider1.5.0MIT
@radix-ui/react-slot1.4.0MIT
@radix-ui/react-switch1.3.8MIT
@radix-ui/react-tabs1.1.22MIT
@radix-ui/react-toast1.2.24MIT
@radix-ui/react-toggle1.1.19MIT
@radix-ui/react-toggle-group1.1.20MIT
@radix-ui/react-toolbar1.1.20MIT
@radix-ui/react-tooltip1.3.0MIT
@radix-ui/react-use-callback-ref1.1.4MIT
@radix-ui/react-use-controllable-state1.2.6MIT
@radix-ui/react-use-effect-event0.0.5MIT
@radix-ui/react-use-escape-keydown1.1.5MIT
@radix-ui/react-use-is-hydrated0.1.3MIT
@radix-ui/react-use-layout-effect1.1.4MIT
@radix-ui/react-use-previous1.1.4MIT
@radix-ui/react-use-rect1.1.4MIT
@radix-ui/react-use-size1.1.5MIT
@radix-ui/react-visually-hidden1.2.12MIT
@radix-ui/rect1.1.3MIT
@tanstack/history1.162.4MIT
@tanstack/query-core5.104.1MIT
@tanstack/react-query5.104.1MIT
@tanstack/react-router1.170.41MIT
@tanstack/react-store0.11.2MIT
@tanstack/react-table9.2.8MIT
@tanstack/react-virtual3.14.14MIT
@tanstack/router-core1.171.34MIT
@tanstack/store0.11.2MIT
@tanstack/table-core9.2.8MIT
@tanstack/virtual-core3.18.0MIT
@types/react19.3.0MIT
@types/react-dom19.3.0MIT
aria-hidden1.2.6MIT
class-variance-authority0.7.1Apache-2.0
clsx2.1.1MIT
cookie-es3.1.1MIT
csstype3.2.3MIT
detect-node-es1.1.0MIT
get-nonce1.0.1MIT
isbot5.2.2Unlicense
lucide-react1.54.0ISC
openapi-fetch0.17.0MIT
openapi-typescript-helpers0.1.0MIT
radix-ui1.7.0MIT
react19.3.0MIT
react-dom19.3.0MIT
react-remove-scroll2.7.2MIT
react-remove-scroll-bar2.3.8MIT
react-style-singleton2.2.3MIT
scheduler0.28.0MIT
seroval1.6.9MIT
seroval-plugins1.6.9MIT
tailwind-merge3.7.0MIT
tslib2.8.10BSD
use-callback-ref1.3.3MIT
use-sidecar1.1.3MIT
use-sync-external-store1.7.0MIT

Made by scripts/dev/dependencies.sh from these files (a test fails when either changes and this page isn't made again):

  • Cargo.lock, SHA-256 600576e5612a60c1d3e9015a30cf66069288187a656860e536f4562882ba566c
  • ui/package-lock.json, SHA-256 aec4cea823c0e8b27156b1e5038d2521714f67461399e2431aebb321ea34ffa5