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 to | Read |
|---|---|
| Decide where to run it | Choosing Linux or Windows |
| Try it on your own machine in ten minutes | On your laptop: ductus dev, then Your first interface |
| Install it on a server | Install, PostgreSQL, Ports and firewalls |
| Set up a project for real | The project and the pages after it |
| Know what could surprise you | Nuances, 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) | Windows | What it means for you | |
|---|---|---|---|
| Install | .deb or .rpm (Ubuntu 22.04+, Debian 12+, RHEL/Rocky/Alma 9), or the .tar.gz | The MSI, or the .zip | Same engine and project files on both; a project moves between them, paths aside. |
| Runs as | A 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, crash | systemd 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. |
| Upgrade | The 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.0 | Linux upgrades need no quiet moment; on Windows, pick one. |
| Deploy project changes | systemctl reload ductus: the same handover (nuance) | Restart-Service Ductus: the same short gap as a restart | As above. |
| SQL Server Windows logins | NTLM 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 refused | Known at once | Takes ~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. |
| Logs | The journal: journalctl -u ductus | Files 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. |
| Certificates | The 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/ductus | C:\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. |
| Containers | The Docker image (a development build, not published before v1.0) | None | Kubernetes is planned for v1.2. |
| Tested on every push | Every test, all the end-to-end demos, the packages under real systemd (255 and 252), the web UI in a browser | Every 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
| Way | For | Page |
|---|---|---|
.deb / .rpm | A Linux server: the service, its user, its folders, upgrades in place with no connection refused | Linux packages |
.msi | A Windows server: the service, its account, the Event Log, upgrades in place | Windows installer |
.tar.gz / .zip | Checking an upgrade before installing it; servers where the packages can't be used | The portable archive |
| Docker image | Trying it with Compose; containers in development | Docker |
ductus dev | Your own laptop: the engine with a Postgres of its own, nothing else to install | On 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 ductusshows 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):
| Variable | What it says | Default |
|---|---|---|
DUCTUS_DATABASE_URL | The 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_PROJECT | The project folder (interfaces/, ductus.yaml, environments/). | The current folder |
DUCTUS_ENV | Which environment's variables and secrets to use (environments). | dev |
DUCTUS_SECRET_KEY | The key that opens secrets/<env>.json. | None (no secrets) |
DUCTUS_API_LISTEN | Where the REST API and web UI listen, in place of api.listen in ductus.yaml. | 127.0.0.1:7700 (this machine only) |
DUCTUS_THREADS | Worker threads for interface code. | One per CPU core |
DUCTUS_DB_CONNECTIONS | The most connections the engine opens to its own database (plus one it holds for its lock). | 32 |
DUCTUS_STOP_TIMEOUT | How long a stop may take to finish the messages in hand. | 30s |
RUST_LOG | How 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
- Give it a database: PostgreSQL.
- Put the project in place and set
DUCTUS_PROJECTandDUCTUS_ENV. - 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. - Start the service, and make the first admin account:
ductus user create you --role admin --env-file …(people). - Open the web UI:
http://127.0.0.1:7700on the server itself, or set upapi.tlsto 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 --checksay what each one changes.
Which file
| File | For | Page |
|---|---|---|
ductus_0.6.0_amd64.deb | Ubuntu 22.04 and later, Debian 12 and later | Linux packages |
ductus-0.6.0-1.x86_64.rpm | RHEL, Rocky, Alma 9 | Linux packages |
ductus-0.6.0-x64.msi | Windows Server, Windows 11 | Windows installer |
ductus-0.6.0-linux-x64.tar.gz, ductus-0.6.0-windows-x64.zip | The program alone: checking an upgrade before installing it, servers without the packages | The portable archive |
ductus-docs-0.6.0.zip | These docs, as they are for this version, to read offline | The 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.json | Your security team: every library in each build | The SBOMs |
SHA256SUMS | Checking any of the files above | Below |
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.
| File | Lists |
|---|---|
ductus-0.6.0-linux-x64.cdx.json | Every Rust crate compiled into the Linux binary (the .deb, .rpm and .tar.gz hold the same one) |
ductus-0.6.0-windows-x64.cdx.json | The same for ductus.exe (the .msi and .zip): a few crates differ by platform |
ductus-0.6.0-web-ui.cdx.json | The 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
| Path | What |
|---|---|
/usr/bin/ductus | The engine and its command line (the web UI is inside it). |
/usr/lib/systemd/system/ductus.service | The service: runs as ductus, sandboxed (below). |
/etc/ductus/ductus.env | Settings: 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/ductus | The 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
-
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_KEYis the key your project'ssecrets/prod.jsonwas encrypted with (secrets). -
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/projectThe
ductususer has no shell (nologin), butsudo -u ductusruns 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. -
Check before starting. As root or the
ductususer, 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/projectvalidatechecks 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.--rootis needed: unlikerun, these commands don't readDUCTUS_PROJECT.doctorconnects 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 startwaits 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, thensystemctl start ductus. Any other failure is retried every 5 s. - Logs go to the journal, plain text with fields (
interface=adt-in).RUST_LOGin ductus.env sets how much:infoby 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
| Path | What |
|---|---|
C:\Program Files\Ductus\ductus.exe | The engine and its command line (the web UI is inside it). The folder is on the system PATH. |
C:\ProgramData\Ductus\ductus.env | Settings (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 Ductus | In 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):
| Account | How | When |
|---|---|---|
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 password | Install, 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
-
Settings. Edit
C:\ProgramData\Ductus\ductus.envas 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.
-
The project. Clone it into
C:\ProgramData\Ductus\project(or pointDUCTUS_PROJECTelsewhere 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). -
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\projectThese 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\Ductusand the database stay; giveSERVICEACCOUNT=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.ps1goes 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.
| File | What |
|---|---|
ductus (ductus.exe) | The engine and its command line; the web UI is inside it. |
LICENSE | Apache-2.0. |
README.md | What 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_KEYand the database password out of the Compose file: Compose reads${...}from a.envfile 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:7700inside the container, so Docker can publish it: the image setsDUCTUS_API_LISTEN=0.0.0.0:7700, which wins overapi.listenin ductus.yaml (set the variable to move it). Publish it to127.0.0.1(as above) unless the API hastls: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:6661for an MLLP port).ductus portslists 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(ordocker 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_URLat the DBA's Postgres (PostgreSQL). - Time zone: the image runs on UTC unless you set
TZ. Cron schedules, quiet hours andmonitor: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 thedevlogin 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 = "…"), thenductus status,ductus user create,ductus token createand the rest talk to it. - Ctrl-C stops the engine cleanly, then its Postgres.
- The data stays in
.ductus/postgresbetween runs, and so does its port.ductus init's.gitignoreleaves.ductus/out of Git.
| Option | What it does |
|---|---|
--reset | Throws this project's dev database away and starts with an empty one (asks first; --yes doesn't). |
--database-only | Starts only the Postgres and prints its URL: for tests, or to run ductus run yourself against it. |
--port N | Postgres's port, instead of the one kept from the last run (or a free one the first time). |
--env NAME | The environment's variables and secrets, as with run (environments). |
--api-listen HOST:PORT | Where 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 devin the same folder says it's already running. - No handover:
systemctl reloadis the packages'. After a change to the project, stop and startductus devagain (it starts in well under a second). DUCTUS_DATABASE_URLis ignored:devalways 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
publicschema) 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 inpublic; 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, noCREATEROLE, no extensions. -
A read-only login for checks and audits:
ductus upgrade --checkandductus replayonly 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):
sslmode | TLS | Checked | Use for |
|---|---|---|---|
disable | no | A database on this machine over TCP, when nothing else is possible (prefer the Unix socket). | |
prefer (the default) | if offered | nothing | Nothing you'd choose: it's the default only so a URL that works in psql works here. |
require | yes | nothing | A network you trust, against listening only. |
verify-ca | yes | a trusted CA issued it | Rarely: the name isn't checked. |
verify-full | yes | a trusted CA issued it, for this host name | Every 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)
| Port | What | Who connects | Open it to |
|---|---|---|---|
7700 (api.listen) | The REST API and the web UI, one port for both | People's browsers, the ductus command line from other machines, monitoring (Prometheus), scripts | Developers' and operators' networks, the monitoring server. Listens on 127.0.0.1 (this machine only) until you change it. |
| Each interface's port | Its MLLP or HTTP listener, from its interface.yaml | The systems that send to it: the EHR, a lab system, another engine | The 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)
| To | Port | When |
|---|---|---|
| Its PostgreSQL | 5432 (or the URL's) | Always. |
| Receivers of MLLP destinations | as configured | For each destination: { mllp: … }. |
HTTP destinations, APIs (api(), fetch()), webhooks, the heartbeat | 443 (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 servers | 22 (or the entry's) | For each sftp: entry. |
| The directory (Active Directory, OpenLDAP) | 636 (ldaps), or 389 with StartTLS | With auth.ldap. |
| The mail server | 587 (STARTTLS), 465 (TLS) or 25 | With alerts.smtp. |
| DNS | 53 | To 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: runtransform.tson each message.destination: write each result as a file. The folder is relative to the interface's own folder, so the files land ininterfaces/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 whatductus impactand 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:
- Install the engine there, and give it a database.
- Clone the project as the service's user, and set
DUCTUS_PROJECTandDUCTUS_ENV=prod. - Certificates on the server: run
ductus cert requestthere, 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, notlab-ca. ductus doctoron 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)
| File | What it holds | Page |
|---|---|---|
interfaces/<name>/interface.yaml | One interface: its source, format, destinations, and how messages are delivered, kept and watched. | interface.yaml |
resources.yaml | Databases, 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.yaml | Settings for the whole project and the engine that runs it. Optional: every setting has a default. | ductus.yaml |
environments/<env>.yaml | The values that differ between environments (${NAME} in the other files). | Environments |
secrets/<env>.json | Passwords, tokens and keys, encrypted (${secret:NAME}). | Secrets |
docs-schema.yaml | Documentation 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, sotransform.tsknowsMessage,db(),api()and every HL7 field's name, andtsconfig.jsonpointing at them;.ductus/schema/: a JSON Schema for each configuration file, and.vscode/settings.jsonmapping 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 eachenvironments/<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 saysapi: billing, a certificate isductus-mllp. When the database moves, one line changes, andductus impactcan 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.
| Where | Written | Holds | |
|---|---|---|---|
| Variables | environments/<env>.yaml | ${NAME} | Anything that differs and isn't secret: hosts, ports, URLs, folders. |
| Secrets | secrets/<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 validatechecks the environment you name, and a variable missing from it stops the start, naming it: runductus validate --env prodbefore 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 withsecret("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 listgives names only, and the engine shows a webhook's URL by its scheme and host only. Anyone who can runductuson 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 validatestop and say which secret is missing. - Rotating a secret:
ductus secret setthe new value, commit, deploy (systemctl reload ductuson 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:
1wfor busy feeds nobody looks back on,7wwhere 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, oroffto run without the API and the web UI (then the command line'sstatus --token,drainandpausecan't reach this engine either). Listen on the server's address, or0.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, acertificates:entry in resources.yaml. Needed as soon aslistenisn'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 andductus validatewarn without it (TLS for the web UI).allow: the addresses, subnets and host names that may connect (operators', developers', the monitoring server). List127.0.0.1too, 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 thepayloadspermission 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: fullalso 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 everyschedule(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: dedicatedwith 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 withisolation: dedicatedcan have connections of its own (dedicated_pools). - Postgres TLS works as for the engine's own database:
tls: verify-fullchecks the server's certificate against this server's trusted CAs (ca:names a private one); withouttls, it'sprefer, which checks nothing (PostgreSQL's TLS). - SQL Server is always TLS, verified:
encrypt: mandatoryby 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_certificatewhen connecting by an alias or a listener's name (nuance). A named instance isinstance: 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: withoutuser, the engine logs in as the Windows service's own account (a gMSA is best: no password anywhere); withuserandpassword, 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: strictis 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_certificatechecks 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 questmakes 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. Apasswordworks too. host_keyis 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): bothcertificates: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] }
| Part | Settings | Page |
|---|---|---|
| Where messages come from, what they are, who may send | source, format, output, allow | Sources and formats |
| Where they go | destination, destinations | Destinations |
| How they're delivered | ordering, retry, dedupe, rate_limit, durability, isolation, workers, dedicated_pools | Delivery |
| How long they're kept, what's expected, who hears | retention, quarantined, monitor, alert | Keeping and watching |
| What it is and who owns it | about, custom | About 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
schedulesource, and an HTTP source inrespondmode, need one: there, the code makes the message or the answer. - What the code returns decides: a message to send,
nullto 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.
| Source | Messages come from | Use it for |
|---|---|---|
mllp | Senders connecting over MLLP | HL7 v2 feeds: ADT, orders, results from an EHR, a lab, a device gateway |
http | Requests to a path | Systems that push JSON or XML; REST APIs other systems call and wait on |
file | Files dropped in a folder on this server or a share | Batch files, exports from systems that only write files |
sftp | Files on an SFTP server | Partners who leave files on their server, or yours |
schedule | The code, on a timer | Pulling: a nightly query, an API polled every few minutes |
internal | Other interfaces' routes | The 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, or127.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, answered202 Acceptedonce 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;awaitthem 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
tlsandallow(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,
pollapart (default10s), 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.donefile beside it) can usemarker: .doneinstead. - Files keep their order, oldest first, one at a time: if one fails, later ones wait (nuance).
split: recordsmakes 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, toarchive_dir, by defaultarchiveinsidedir) ordelete, 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
diris 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 validatewarns whenabout.from.hostsnames 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;
validaterefuses 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).
| Format | The code gets | Keys are written |
|---|---|---|
hl7 | Segments and fields (msg.get("PID-3.1")), numbered from 1 as the standard does | PID-3.1, MSH-10 |
json | The document | order.id |
xml | The document | Order/@id |
csv | Each row an object by column name (with a header line) or a list (without) | A column's name, or its number from 1 |
fixed | Each record an object by field name | A field's name |
text | The 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.
| Connector | Sends each message | Delivered when |
|---|---|---|
mllp | Over MLLP to a receiver, one connection kept open | The receiver answers AA |
http | As an HTTP request to a URL, or a path under an API from resources.yaml | A 2xx answer |
file | As a file in a folder of this server or a share | The file is complete under its name |
sftp | As a file on an SFTP server | The same, on the server |
interface | Into another interface's queue | It'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:(defaulttransform), not on another destination's result:toLabandtoBillingeach 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 nameddefault. 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
partitionsbefore 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 --waitfirst (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_attemptswhere 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_msis 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_mbof 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
| Setting | Breaking it is | For |
|---|---|---|
active_within (with between) | silent | Feeds that never stop in normal operation: set it a little above the longest normal gap. |
active_by (with from) | missed | Feeds that come once a day or week: a nightly file, a Monday batch. |
behind (messages, age, or both) | behind | Queues: 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 impactanswers "what does this touch?":ductus impact --vendor "Epic Systems"for a vendor's outage or breach,ductus impact --host 10.20.30.0/24for 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.
hostsis 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, andductus validatewarns when the source'sallow: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
ownertoo: 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:
| Where | Settings | The engine |
|---|---|---|
| On what it listens on: MLLP and HTTP sources, the API and web UI | tls: on the source, api.tls | Presents its certificate; optionally asks senders for theirs |
| On what it connects to: MLLP and HTTP destinations, APIs, databases, the directory, the mail server, webhooks | tls: / ca: / certificate: on each | Checks the receiver's certificate; optionally presents its own |
| To its own database | sslmode in the URL | PostgreSQL'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_fileholds 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 byductus; on Windows, the service's account only). - A CA (
ca_file, orcaas text) is trusted for a connection that names it, or checks senders' client certificates. - From a vault instead of files:
certandkeyas 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 theductusgroup), 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 validatechecks 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 bycaand, withsubjects, naming an allowed system:CN,O,OU… parts with*and?wildcards, orDNS=for a certificate's DNS names. Keepsubjectsnarrow: 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: trueon an MLLP destination: TLS, trusting this server's CAs.ca:also trusts acertificates: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.orgis refused at10.1.2.3unless it lists that address. - The same
ca:andcertificate:go on anapis:entry (whichapi()andhttpdestinations through it share), an HTTP destination with its ownhttps://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-labmakes 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.pemis the certificate with its intermediates, ascert_filewants. 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 showsays 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.keyonce, thenstep ca renew --daemon mllp.pem mllp.keykeeps 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) says | It means | Do |
|---|---|---|
| "the sender isn't speaking TLS" | A plain MLLP or HTTP sender on a TLS port | Turn TLS on at the sender, or point it at a plain port |
| "the receiver wants a client certificate" | The receiver asks for mutual TLS | Name a pair in certificate: |
"chain incomplete" (cert show, doctor) | cert_file lacks the intermediates | Append them after yours |
| "no TLS version or cipher in common" | A sender on TLS 1.0/1.1 | Upgrade 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 -x509 | Name that exact certificate in ca: (nuance) |
"timed out waiting for ACK" right after turning tls: on | The receiver's port speaks plain MLLP | Ask 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
| Role | Can |
|---|---|
viewer | See interfaces, health, queues, messages' status and errors, alerts. Change nothing. |
operator | What viewers can, and act: resend, replay, edit and dismiss messages, pause and resume, drain, run connection checks. |
admin | What 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 benor 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), orldap://withstarttls: true. Plainldap://is refused unlessinsecure: 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 byuser_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;permissionsgroups 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,
objectGUIDorentryUUID), and a sign-in name given to someone new gets a new account (nuance). - Nested groups:
nested: truecounts 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
refintoverlay (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: starttlson 587 by default,tlson 465,noneonly for a relay on the same network segment). Ask the mail team to letfromthrough their filters. - Webhooks:
format: teams(a Microsoft Teams Workflows webhook),slack(also Mattermost, Rocket.Chat), orjson(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 oncallsends 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.
| State | Means | Usually |
|---|---|---|
error | A destination has quarantined messages someone must look at | critical |
stalled | A destination has had messages waiting over a minute and sent none: its receiver is down, or a message keeps failing | critical |
stopped | An interface's code was stopped (it ran away, or ran out of memory) | critical |
behind | A queue is past its monitor.behind | warning |
silent | Nothing received within monitor.active_within | warning, or critical for a core feed |
missed | Nothing received by monitor.active_by | warning |
certificate | A 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(default4h): while an alert is open and unacknowledged, its recipients are reminded this often;0never. 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(default2m): 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. Keepcriticalout ofseverities(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]
type | A value is | Example |
|---|---|---|
text | Any text (quote numbers and yes/no, or YAML reads them otherwise) | "CHG-1234" |
enum | One of values, exactly | "yes" |
date | A date | 2026-10-30 |
url | An http(s):// address, shown as a link | https://wiki.hospital.org/adt |
person | Who, best as an email address | ana@hospital.org |
list | Several 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 validatereports each missing one. The engine starts anyway.applies_to:interface(the default),destination,resource(resources.yaml entries), or several.labelandhelp: 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 to | Without code | Page |
|---|---|---|
| Send each message to several places, each with its own queue and retries | destinations | Destinations |
| Pass a feed into another team's interface | destination.interface and source: internal | Routes and filters |
| Keep one patient's messages in order | ordering.key: PID-3.1 | Delivery |
| Ignore a sender's repeat after a lost ACK | dedupe.keys: [MSH-10] | Delivery |
| Read CSV, fixed-width, JSON or XML; write another of them | format and output | Formats |
| Split a batch file into one message per record | split: records on a file source | Sources |
| Relay messages as they came | no script at all | below |
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
- 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
respondmode, below). - 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. - What the function returns decides what happens, for that destination only:
| The function | The delivery is | Shown as |
|---|---|---|
| returns a message, an object or a string | sent: written in the destination's output format and delivered | sent |
returns null (or nothing) | filtered: never sent, kept with the message for audit | filtered |
returns kill("reason") | filtered, with the reason shown on the message | filtered: reason |
throws quarantine("reason") | quarantined: held for a person to look at, resend, correct or dismiss | quarantined: reason |
| throws any other error | quarantined, with the error's text | quarantined: 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 lane | pending, with the error |
- 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 withtransform:. 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: aMessagefor HL7, an object or array for JSON, anXmlElementfor XML, a record (or a list of them) for CSV and fixed-width, a string fortext(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) andorigin(for a message routed from another interface, its id there). - A function may be
asyncandawaitlookups (lookups). While one waits, the thread runs other messages. - What it returns is written in the destination's output format: a
Messageor anXmlElementas 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 inductus 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 APIPOST) must be safe to repeat: usemeta.idas 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). -
timeoutcounts time the code runs, not time it waits on anawait: a function waiting on a slow lookup isn't stopped for it (nuance). Code that runs away after anawaitinterrupts 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-levelletis one copy shared by every interface on that thread, andductus validatewarns about it (DX3). - A heavy loop belongs in the interface's own file: a runaway in a shared file after an
awaitcan'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 nullfilters: 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 ownsadt-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 (
nullor the message). That's what letsductus validaterefuse a loop and check that the receiving interface reads the format this one writes, andductus impactfollow 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 ownabout.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:
dedupeonMSH-10recognizes 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
eventthat 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 (
mapFieldsskips""), so a receiver sees a missing discharge time as missing, not as an empty string. If your receiver wantsnullor""instead, change the helper once. - HL7 to HL7 works the same way:
copyFields(from, to, [["PID-3.1", "PID-2.1"]]), also inlib/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\\), thenaddsegments andsetfields; a field set past the end of a segment pads it with empty fields. - Times and ids from
meta, not the clock:meta.receivedAtandmeta.idare the same every time this message is processed, so a retry, a resend or a replay produces the same message (and MSH-10BB501the 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, nonew 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, neverreturn nullfor 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 transforms500msis 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.
| Format | Your code gets | Return | Written as |
|---|---|---|---|
hl7 | a Message | the Message, or a string | HL7, segments ending in \r |
json | the parsed value (object, array…) | an object, array or string | JSON |
xml | an XmlElement, the document's root | an XmlElement, or a string | XML |
csv | records, objects by column name (or one record with split: records) | a record or a list of them | CSV with a header row |
fixed | records by the layout's field names (or one) | a record or a list | fixed-width lines |
text | the payload as a string | a string | as 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.
| To | Write | Gives |
|---|---|---|
| Read a field | msg.PV1[2], msg.get("PV1-2") | "I" (text) |
| Read a component | msg.PID[5][1], msg.get("PID-5.1") | "SMITH" |
| A composite field's whole text | String(msg.PID[5]), msg.PID[5].value | "SMITH^ANNA^M" |
| Every occurrence of a segment | for (const obx of msg.OBX) | each OBX, in order |
| The n-th occurrence | msg.OBX.rep(2), msg.OBX.count | the second OBX; how many |
| Repetitions of a field | for (const id of msg.PID[3]), msg.PID[3].rep(2), .count | each PID-3 identifier |
| Whether a segment is there | msg.PV1.exists | true / false |
| Segment names in order | msg.segmentNames | ["MSH", "EVN", "PID", "PV1"] |
| Set a field or component | msg.PV1[2] = "IN", msg.set("PID-5.1", "SMITH") | |
| Add a segment | msg.add("ZDU", ["value"]), msg.insertAfter("PID", "PD1") | the new segment |
| Remove segments | msg.remove("ZPV"), msg.NTE.remove() | every segment with that name |
| The whole message | msg.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
OperationOutcomesaying 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 onapplication/fhir+jsongets it from the destination'sheaders, 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 isformat: { 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 andtransformgets every record (an array); withsplit: records, each row is a message of its own (the header kept with each), with its own status, retries and quarantine, andtransformgets 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.toCsvandparseCsvfrom 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 unlesstrim: 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@namefor an attribute as the last step.findgives 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>ishl7:patientin 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: recordsdoesn'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:
| Function | For | Defined in |
|---|---|---|
db("name") | A Postgres or SQL Server database | databases: in resources.yaml |
api("name") | An HTTP API with its URL, token and TLS set once | apis: in resources.yaml |
fetch(url) | Any other URL | the 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 itself | ductus 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,onethe first row ornull,executethe 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 indexedvarcharcolumn, 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
jsonbin Postgres) (nuance). - Writes must be safe to repeat. A message's code can run again (a retry, a resend), so an
executethat inserts uses the message'smeta.idas 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 tofetch()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.statusorr.ok. retry: falsegives 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()andr.text()need noawait: 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:
| Where | Best when | How |
|---|---|---|
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 maintains | Another team changes it often (locations, chargemaster codes), or it's large | db("ref_db") with a cache, as above |
| Behind an API (a terminology server) | The organization has one | api("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<…>andconst, 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.tsbesideroute.ts), imports the functions it tests, and registers tests withtest(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
asynctest 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 with | Gives |
|---|---|
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")insidetest(...), or in a function the test calls (nuance). - With
split: records,input()gives the fixture's first record, as the engine givestransformone 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
| Assertion | Passes 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(ortoRejectfor 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.
| Mock | Answers | Returns |
|---|---|---|
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 request | api("name") calls; the request's url is the path | the requests made |
mock.fetch({ status, body }) or a function | fetch() calls | the 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].paramsis the MRN your code sent;calls.lengthshows 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 validatetype-checks every interface and checks its configuration and resources for one environment: run it for each environment you deploy to.ductus replayruns 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 genandductus sinksend synthetic ADT and receive it, to try a whole project on your laptop withductus 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
| For | Page | |
|---|---|---|
| The web UI (the console) | Everything a person does: health at a glance, messages, quarantine, resend, pause, alerts | The console |
The ductus command | The 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 API | Your own tools and runbooks; everything the console does | REST API reference |
| Prometheus | Graphs and your monitoring system's own alerts | Monitoring and metrics |
| Logs | What the engine did and why, line by line | Logs |
| Alerts | Being told, so nobody has to watch | Alerts 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 see | Look at |
|---|---|
| A destination stalled | Its receiver is down, or one message keeps failing: hotspots says which |
| A destination behind | It's sending, but slower than messages arrive: the receiver is slow, or a rate limit |
| A destination in error | Quarantined messages: the quarantine |
| An interface silent or missed | Nothing received when it should have been: the sender, or the network between |
| An interface stopped | Its code ran away or ran out of memory three times: the logs and the quarantined messages say which message (nuance) |
| A sender says connection refused | Is the engine running, draining, or refusing it by allow:? Logs, ductus doctor |
| A TLS handshake fails | The 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 --checkwith the new version first, thenductus replayfor the evidence a change board asks for (resend and replay). - A project change (new code, a new destination):
ductus testandductus validate --env prodin CI, then deploy. On Linux,systemctl reload ductusdeploys 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:7700withapi.tls(the browser's sign-in cookie is only sent over https, or to localhost) and anallow: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 thepayloadspermission, 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 doctorfrom 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
auditpermission): 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:
viewerfor status, hotspots and metrics;operatorto pause, resend, drain. A token reads payloads only with--permission payloads. ductus token createandductus userwork on the database directly (that's how the first admin is made), so they needDUCTUS_DATABASE_URL, not a token.
| Command | Does | Needs |
|---|---|---|
ductus status | Counts by interface and status; with a token, health | database; token for health |
ductus pause / resume | Pause or resume one destination | operator token |
ductus hotspots | Where a backlog sits, and why | viewer token |
ductus drain / resume | Stop taking messages, deliver what's stored | operator token |
ductus alerts | What's open; alerts test sends a test notification | viewer token; the project for test |
ductus retention | Retention per interface, changes, confirmations | viewer token; operator to confirm |
ductus doctor | Every connection the project needs, checked from here | the project |
ductus audit export | The audit log, as JSON lines | database |
ductus user, token | Accounts and tokens | database |
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
statusand 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
--stopexits 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 ductushands 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
commitsline 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 (
doctorin 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
| Linux | Windows | |
|---|---|---|
| Start, stop | systemctl start|stop ductus | Start-Service Ductus, Stop-Service Ductus |
| Deploy a project change | systemctl reload ductus (no gap) | Restart-Service Ductus |
| Is it running | systemctl status ductus | Get-Service Ductus |
| Its logs | journalctl -u ductus | C:\ProgramData\Ductus\logs, the Event Log |
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
| Kept | For | |
|---|---|---|
| The message | as received, byte for byte, with when it arrived, its ordering key (the MRN) and MSH-10 | the retention window (retention) |
| Each destination's delivery | its status, attempts, last error, when it changed | the same window |
| What was sent | only for destinations with save_output: true; otherwise it's made again by running the code when you look | the same window |
| What people did | every resend, replay, edit, dismissal and payload read, with who and why | audit.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
| Status | A delivery is | A message is |
|---|---|---|
| pending | waiting to be sent, or being retried | waiting for at least one destination |
| sent | delivered: AA, a 2xx, a complete file, stored in the next interface | finished; at least one destination sent it, none quarantined it |
| filtered | not sent on purpose: null, kill(), dismissed, replaced by a corrected copy, or a destination removed | finished; every destination filtered it |
| quarantined | held for a person: refused (AR, a 4xx), the code threw, or max_attempts ran out | finished, 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 forordering.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
payloadspermission, 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.
| Action | On | Does | Use it when |
|---|---|---|---|
| Resend | a quarantined delivery | sends the same message to that destination again | you fixed the cause: the receiver, the code, a code table |
| Replay | a sent or filtered delivery | sends the same message to that destination again | a receiver lost what it got, or a destination was filtering wrongly |
| Edit and resend | a quarantined message | sends a corrected copy; the original's quarantined deliveries are marked replaced | the message itself is wrong and the sender can't send it again |
| Dismiss | a quarantined delivery | takes it out of the quarantine without sending, with a reason | nobody 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
destinationsit 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/resendwith 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_attemptson 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 24hcompares against the installed version and exits 1 on any difference. With--report replay.htmlit writes the evidence a change board asks for (--hide-valuesto 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 APIPOST): those calls are refused in a replay, and the message is marked not comparable (nuance). - The clock is each message's arrival and
Math.randomrepeats, 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
| State | Of | Means | What to do |
|---|---|---|---|
| ok | both | nothing to report | |
| behind | destination | past its monitor.behind (a count of waiting messages, the oldest one's age, or both), but still sending | the receiver is slower than the feed, or a rate limit: usually wait; call the vendor if it doesn't catch up |
| error | destination | it has quarantined messages | look at them: resend, correct or dismiss |
| stalled | destination | messages waiting over a minute and nothing finished for a minute | the urgent one: the receiver is down, or one message keeps failing. Hotspots says which |
| paused | destination | paused on purpose, with who and why | resume it when the receiver is back |
| silent | interface | nothing received within monitor.active_within | the sender, or the network between |
| missed | interface | nothing received by monitor.active_by | the nightly file or batch didn't come |
| stopped | interface | its code ran away or ran out of memory three times in a row: it receives, but sends nothing until the engine restarts | the 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_attemptson 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 pausestops 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.partitionswas 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
| Question | Query |
|---|---|
| Messages in, per second | rate(ductus_messages_received_total[5m]) |
| Sent, filtered, quarantined, per second | rate(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 send | histogram_quantile(0.95, rate(ductus_send_seconds_bucket[5m])) |
| Your code's time per message | histogram_quantile(0.95, rate(ductus_transform_seconds_bucket[5m])) |
| What's waiting, and for how long | ductus_queue_pending, ductus_queue_oldest_seconds |
| Retries (a receiver flapping) | rate(ductus_delivery_retries_total[5m]) |
| Database and API calls from code | ductus_resource_call_seconds, ductus_resource_call_errors_total |
- Counters start at zero when the engine starts;
rate()andincrease()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).
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
| Linux | Windows | |
|---|---|---|
| The log | the journal: journalctl -u ductus -f | C:\ProgramData\Ductus\logs\ductus.<date>.log, one file a day (named by the UTC date), the last 14 kept |
| What operations watch | the 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 much | RUST_LOG in /etc/ductus/ductus.env | RUST_LOG in C:\ProgramData\Ductus\ductus.env |
| Kept | as 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.)
| Line | Means | Act when |
|---|---|---|
started interfaces=N … took= | every interface is up and taking messages; how long the start took | never: 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_ms | the 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= / resumed | someone paused or resumed, with their name | |
alert … kind="fired" / "recovered" | an alert opened or ended, and how many were told | notifications=0 when you expected some: the rules or the channel |
draining / drained | a drain started and finished | |
stopping … why= / stopped took= | a clean stop, and why it stopped | a 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
- 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").
- 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).
- 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). - 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).
- It recovers by itself when the state is fine, and the channel is told. Nothing to close.
Silence, pause, mute: which one
| Does | For | |
|---|---|---|
| Acknowledge | stops 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 shows | a known problem someone else owns, while they fix it |
| Pause the destination | nothing is sent or retried; never alerts | planned downtime of the receiver |
alert: { mute: true } on the interface | its alerts open and show, but nobody is told | a 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 needmonitor:. - Tune
monitor:from what happened. Asilentthat fires every Sunday morning meansactive_withindoesn't fit the feed's quiet hours: addbetween:. Abehindthat 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
| Event | What happens | Senders see |
|---|---|---|
| A restart (Linux) | systemd keeps the listening sockets across it | nothing refused; ~0.2–0.3 s wait for an ACK (measured) |
| A crash (Linux) | systemd restarts it after 5 s, with the same sockets | nothing refused; ~5 s wait |
| A project deploy or an upgrade (Linux) | systemctl reload: the new engine starts beside the old one and takes over | nothing refused; sending paused ~35 ms (1 interface) to ~0.6 s (1,001) (measured) |
| A restart (Windows) | the service stops and starts | a short gap: measured 0 refused, a few resets, ~1.1 s longest wait |
| An upgrade (Windows) | the MSI stops, replaces and starts the service | down 3.1–3.5 s: a few refused, nothing lost (nuance) |
| The database restarts or fails over | sending, file pickup and schedules pause; receiving goes on; the engine reconnects and catches up | nothing, as long as the database takes writes (nuance) |
| A receiver is down | its destination retries, in order, until it's back; other destinations carry on | nothing |
| The engine's server dies | nothing runs until an engine is started on another server | refused 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:
- 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.
- 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). - 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. - 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 ductusfor 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 --checkwith the new version, andductus replayfor 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 hotspotsshows which message. For receivers that refuse a message for good with errors that look temporary,retry.max_attemptsquarantines 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
partitionsonly 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,3wor7w(the default). Each is a set of partitions of the message tables, a day each for1wand a week each for3wand7w(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: a7wmessage 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 retentionshows 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 FULLthe 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:
- Unpack the new version's portable archive anywhere on the server.
./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.- Install the new package. It hands the running engine over to the new version: receiving never stops.
- 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 options | May change in any minor version; the release notes say what | A 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 database | Every version only adds (new tables and columns); older versions run on what newer ones added | The same in every minor. A major may also remove what an earlier minor stopped using |
| Upgrading across versions | From any version to any later one | Within 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 back | To the version you came from | To 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 ✓:
| Section | It says | When it isn't ✓ |
|---|---|---|
| The database | The 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 database | Every engine running on it: host, process, version, environment, how many interfaces | Engines 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 project | ductus validate and ductus test, run with the new version's SDK and type checker | Fix the project (or the new version broke it: then don't upgrade yet). The full output is under the line |
| Between versions | Versions you must install on the way (none before 1.0), and settings the new version deprecates | Replace each deprecated setting with the one named; it still works until the next major |
| The last … replayed | With --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:
| Count | Means |
|---|---|
| identical | Compared 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 |
| different | The 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 ductussays why, and senders notice nothing. Fix what it names andsudo systemctl reload ductus(nuance). - A start longer than 5 minutes (a large database change) is given up by the handover:
sudo ductus handover --timeout 30minstead 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 with | Going back |
|---|---|
| Linux package | sudo 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 installer | The 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 archive | Put the older program back and sudo systemctl reload ductus |
| Docker | Run 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 ductusshows "running N interfaces" (Windows:Get-Service Ductusis 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.handoverentry (audit actions). ductus status --token …shows every interface ok within a minute;ductus doctorchecks 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:
| Command | Answers | Needs |
|---|---|---|
ductus status | What's waiting, sent, quarantined, per interface and destination; with a token, each one's health in words | the database; a token for health |
ductus hotspots | Where a backlog sits and why: the message holding it up, the receiver's answer | a viewer token |
ductus doctor | Every connection the project needs, checked from this server, step by step, with the fix for the step that fails | the project |
| The log | The 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 says | Meaning | Do |
|---|---|---|
Connection refused, timed out, no answer on the first message, every lane waiting | The receiver is down or unreachable | Ask 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 later | See AE or AR |
| A TLS reason | The handshake fails | See TLS |
| "blocked by message M" while other lanes were sent | One message keeps failing; the others of its worker wait behind it, to keep order | Look 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 why | Someone paused it | ductus resume (or Resume on the console) once whatever they paused it for is done |
| Nothing waiting, but nothing arrives | The sender isn't sending | See 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 answers | It means | The engine | Default |
|---|---|---|---|
| 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 meanwhile | forever (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 on | on_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:
- Read the receiver's reason, and the message (the console's message page shows it field by field).
- If the data is wrong at the source, the sender fixes it and sends again; dismiss the rejected one with a reason (Dismiss).
- 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.
- 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 says | Do |
|---|---|
| the certificate isn't valid for the name | Connect by a name the certificate lists (doctor shows them), or ask for one with the name you use |
| expired, or not valid yet | The receiver renews it; "not valid yet" can also be this server's clock |
| the receiver wants a client certificate | Name a pair in tls: { certificate: … } (TLS to receivers) |
| presents a CA's certificate as its own | A lab receiver made with openssl req -x509: name that exact certificate in ca: |
"timed out waiting for ACK" right after turning tls: on | That 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 says | Do |
|---|---|
| the password was refused | Correct it in the URL (special characters percent-encoded); a secrets manager's value may have changed |
no database x | Create 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 answer | Postgres 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:
| Exit | Means | Linux (systemd) | Windows |
|---|---|---|---|
| 3 | Refused on purpose: another engine owns an interface, or a change to an interface's lanes would strand waiting messages | Not retried (RestartPreventExitStatus=3): fix it, then systemctl start ductus | Event 1003; the service stays stopped: fix it, then Start-Service Ductus |
| 1 | Anything 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 journal | Event 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
| Symptom | Where 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 anyone | ductus 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 expire | Renewing: replace both files, no restart |
| Someone can't sign in | People |
| Upgrade or deploy problems | Upgrade |
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)
- Persist before acknowledge. A source only ACKs (MLLP
AA, HTTP202) 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. - At-least-once delivery. A delivery stays
pendinguntil its destination confirms. A crash after sending but before recordingsentre-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. - 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). - 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, ornone), whichductus upgrade --checkshows with the table's size; 0001–0024 are described inductus_store::migrations(an applied file can't change: sqlx checksums its whole text). - No credentials in interface code. Secrets come from
secrets/<env>.json(encrypted) via${secret:NAME}in YAML orsecret()in code; connections are named resources (resources.yaml). - Interface code is sandboxed from the network and filesystem except through engine services (
db(),fetch(),env(),secret()). Remote module imports are refused.
Crates
| crate | role |
|---|---|
ductus-core | interface.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-hl7 | HL7v2 essentials in Rust: field lookup by path, ACK generation (ingest only; interface code uses the SDK) |
ductus-mllp | MLLP framing, listener (in order per connection), reconnecting client (with or without waiting for ACK) |
ductus-store | Postgres: 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-script | Embedded 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-runtime | The 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:, nameddefault) or several (destinations:by name). Each destination has its own function (transform:, defaulttransform), output format,retry,rate_limit(its own allowance) andsave_output. deliveries(day-partitioned likemessages): one row per queued message and destination, inserted in the same statement as the message (so still one commit per ACK). Its partial indexdeliveries_queue (destination_id, message_id, partition_key) WHERE status = 0is 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 carrystatus,attempts,last_errorand, withsave_output,output.- One worker per (slot, destination). Each runs the destination's function on the original message (with
meta:id,receivedAt,destination, andoriginfor 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.statusis the overall status: pending until no delivery is pending; then quarantined if any is, else sent if any is, else filtered.Store::settlesets 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_pendingfails 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 onlymessages.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, nevermessages.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 hassource: 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), andIntake::accept_routedon the target's intake stores the batch:Store::finish_routedupdates this destination's deliveries (pending ones only,RETURNINGwhich) 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
Intakebefore any worker, since a route's worker holds its target's.
Worker per delivery: function → outcome:
- deliver → rate limit → send →
sent(batched) Filter(reason)→filteredQuarantine(reason)(transform threw,quarantine(), AR, 4xx) →quarantinedRetry(error text contains[ductus:transient], e.g. DB unreachable) → back off 1s→60s, retry the same message (order kept)Runaway(the code ran past itstimeoutwithout pausing, or filled the isolate's heap) →quarantined; three timeouts in a row or one heap overflow stop the interface (health.rs): its messages staypendinguntil the code is fixed and the engine restarted; respond-mode requests get 503 Delivery failures: transient → back off and retry (forever unlessretry.max_attempts); permanent → quarantine.
Files (file and SFTP sources)
- One
FileWatcherper interface over aBackend(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 itsmarkerexists). 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: recordsstreams the file throughsplit::Records(256 KB buffer, never the whole file): HL7 per MSH, CSV per record with the header prepended, others per line. Records go toIntake::accept_batch500 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: trueis 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::runsleeps until the next due time (re-checking the clock every minute), thenIntake::accept_scheduledstores 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 runstransformon it like any message; the transform's input format is JSON andformat/outputdescribe what it produces.ductus_core::schedule::Timing:everyruns 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 idengine.startlogs) holds a session advisory lock with a random key on a connection of its own for its whole life (Store::lock_engine,ownership::Owner;enginestable). 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, andidle_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 writesengine.refused, deletes itsenginesrow, closes the lock connection and exits 3 (ownership::Refused; systemdRestartPreventExitStatus=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 setsstopped_atafter 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 isType=notifywith 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), akill -9none (5.4 s:RestartSec). A drain, a stop of the service and a failed start close the sockets for real (FDSTOREREMOVE).READY=1follows thestartedline;STOPPING=1comes before the gates close. Nothing runs in the background: one notify datagram per listener at start. - The handover (0.6.6.3, Linux):
systemctl reloadrunsductus 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/exewould be the deleted old file) with its arguments, a copy of every listening socket (systemd::handover_sockets, named inDUCTUS_LISTEN_FDS) and a socket pair (DUCTUS_TAKEOVER; JSON lines:From,Ready,Released,Running,Failed). The new engine runs the start withOwner::prepareinstead of the claim (its own lock andenginesrow; 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 withOwnershipnot held, loads the code, listens on the inherited sockets (both engines accept on them now; any number may store), starts the API, saysReady. 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: noSTOPPING=1, the sockets stay), releases, writesengine.stop, sendsMAINPID=<new>(systemd follows the new process from here;NotifyAccess=mainignored the new one until then:systemd::promotethen stores its new sockets and removes the dropped ones), saysReleased. 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 beforeReleasedends the new process (Failedwith the reason; its sockets are the old one's, soabandondoes 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 missingTCP_NODELAY). Background: one idle Unix socket listener. - The replay (0.6.6.4):
ductus replayreads 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'sreceived_atandMath.randomseeded per message and destination (Isolate::pin_clock/set_clock, installed before any module loads),metaand the decision exactly as the worker makes them (worker::meta,Outcome::of), services that refuse writes (db().execute, non-GETfetch) and mark the call not comparable, Postgres pools withdefault_transaction_read_only. Nothing is connected or stored. The other version runs asductus 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):Ownershipturns false, so workers (inhold_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_appliedkeeps each interface's lanes (partition_count():partitionswith an ordering key, else 1). Lowered or never recorded →Store::strandedcounts 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-destinationLATERALcount 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 validateruns 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 /enginehasholds_interfacesandothers(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 onapiin ductus.yaml (ductus_core::tls: the shape, subject patterns, andcheckfor names and kinds). HTTP interfaces on one port share itstls(check_endpoints). - Key exchange (0.6.4.1): rustls's
prefer-post-quantumfeature (its own default, whichdefault-features = falsehad 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 validateruns the same per entry. - One rustls
ServerConfigper listener (TLS 1.2 and 1.3, aws-lc, the same stack as the outbound connections), whose certificate comes from a resolver (Pair, aResolvesServerCertbehind 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,DCby OID) and DNS names againstsubjects. - Handshakes never hold up the accept loop: MLLP runs it on each connection's own task (
ductus_mllp::serve_with: anopenstep before the first frame; the listener still closes at once on a drain). HTTP sources and the API usetls::Listener(axum'sListener): TLS connections are accepted and handshaken on their own tasks, finished ones handed to the server through a channel; the API wraps it intap_ioso axum'sConnectInfo<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.listenoff loopback withouttlswarns at start and in validate (the session cookie isSecure). - 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 CAca:names, and the paircertificate:names as the client certificate through aResolvesClientCert(the samePair, so a renewal applies to client certificates too). Names areConnectTls { ca, certificate }(ductus_core::tls:tls: true | { ca, certificate }on MLLP destinations, flatca/certificateonapis:, HTTP destinations with aurl,alerts.smtp, webhooks and the heartbeat; LDAP'scais a name);tls::outbound+check_outboundrefuse wrong names and kinds in validate and at start.Certificates::clientbuilds oneClientConfigper distinctConnectTlsand shares it (MLLP destinations naming the same CA share settings and session cache). Users:ductus_mllp::Client::with_connect(the client's connect step: atokio_rustlshandshake within the send's timeout, its refusals turned into a temporary error's words bytls::outbound_failure); reqwest viatls_backend_preconfigured(ALPN h2 + http/1.1 set by us) for HTTPS destinations,api()(a client per API that names TLS) and webhooks; ldap3'sset_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 sameTrustwith 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 withcreate_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_chainfor order and completeness,tls::check_pairfor the key, the same 30-day warning),ductus cert self-signed.gen --tlsandsink --tlsbuildcertificates:entries in memory from their files and go throughCertificateslike 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 andapi(ductus_core::allow: addresses, subnets, host names; parsed withabout::Network). Each listener with a list has anallow::Admission, checked right afteraccept(): inserve_with'sopenfor MLLP (on the connection's own task) and intls::Listener's accept loop for HTTP and the API, before the handshake task. A refused connection is dropped unread, counted, and logged throughtls::Refusals(once a minute per listener and address). HTTP interfaces on one port share one admission (check_endpointsrefuses different lists).- Host names: one
allow::Namesfor 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_portfinds 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 (tiercolumn, 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 tier0(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 inductus-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 ofmessages/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 (retryingin 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):heldinGET /retention,ductus retention,ductus_held_messages/ductus_held_bytesin/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 (
keptinGET /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.deliveriesfirst. An interrupted run is finished by the next (pending detachesFINALIZEd, detached leftovers dropped). - Changing retention applies to old messages too (
ductus_store::retention,ductus-runtime/src/retention.rs). Theretentiontable 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 forductus 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
settleretries (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'sreceived_atrange (span): without it, Postgres priced looking up each message in all ~100 partitions above scanning them, and settling 50 messages read everymessagespartition (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.listenin ductus.yaml, default127.0.0.1:7700,offto disable;--api-listenoverrides). Routes are declared withutoipa_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, aPrincipal): 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) andaudit(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 callcaller.require(Role)/require_permission(..);api/mod.rshas 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 withductus 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 inauth.lockout); a password an admin chose setsmust_change_password, and then onlyGET/DELETE /sessionandPOST /me/passwordwork (theMustChangeextractor; everything else answers 403password_change_required). The first admin is made on the server (ductus user create;ductus devmakes adevadmin). - Directory sign-in (
auth.ldap;ductus-runtime/src/ldap.rs,ductus_core::ldap,ductus_storedirectory.rs, migration 0022):POST /sessionchecks a name a local account holds locally and only locally; any other name goes to the directory named byauth.ldap.resource(anldap: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'sca): 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 inuserAccountControl, 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; withnested, 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 everyrefresh, 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 setsusers.directory_refused_at(the identity join treats it likedisabled_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 byattributes.email: one entry, in a role group, gets its account (save_directory_user, audituser.createby 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 hitsusers_username(orusers_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. NoAuthProvidertrait: 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 /sessionwith{username, password}or{token}(the latter unlessauth.token_sign_inis off) sets the cookieductus_session=dus_<64 hex>(HttpOnly,Secure,SameSite=Strict,Path=/api), stored hashed inapi_sessionswith the user's or the token's id.Callertakes 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 carryX-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=Strictalone is the first line).GET /sessionsays 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 adetaildocument. Rows are written with the change they describe: a resend, edit or dismissal inserts its audit row first in its own transaction (itsmessage_actionspoint at it withaudit_id, and a request that finds nothing to do rolls the row back); account, token and session changes take an&Auditand 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 peraudit.payload_read_window; refused with 503 if the row can't be written). The engine writesengine.start(with how the previous run on the host ended: anengine.startwithout anengine.stopof its instance is a crash),engine.stop,interface.stopped(the runaway guard tellsHealth, which tells a channel, which a task drains), andretention.change. An hourly job (60 s after start, then every hour) setsmessage_actions.previous_outputNULL once an action is older than its interface's retention (a copy of a payload must not outlive the message), and deletesmessage_actionsandaudit_logrows older thanaudit.keep(default 6 years, at least 1) in batches of 5,000.GET /auditneeds theauditpermission (format=ndjsonis an oldest-first export, itself logged);GET /me/activityis 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 becomesfiltered("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.rsembedsui/dist(each file with a gzip copy when that's a tenth smaller) as a sorted static table; every path outside/apiis served from it, unknown ones asindex.html(the app's router shows the page) except under/assets/, whose content-hashed names are cached for a year (immutable); everything else isno-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: withoutui/dista "build the UI" page is embedded, andDUCTUS_REQUIRE_UI=1(CI, check.sh) makes that an error. The UI's API types are generated fromductus api-spec(the OpenAPI document, without an engine) and committed asui/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 whatindex.htmlreferences, 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 byui/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 calleval/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,ownersanddoctoras 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::reassignon 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 forgit applyand 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);/metricsfor 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 inmessage_actionswith what it was before, marks the messages pending; the lane's workers are woken through the interface'sIntake). 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/resendtakes 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'sorigin_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 /quarantinereads quarantined deliveries across interfaces fromdeliveries_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/eventsor a drain is running, and shares the result with every subscriber. - Drain: each interface has a
Gate(gate.rs). Draining closes them all:Intakerefuses 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 countedPass; 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. Withstop, the engine then exits (Engine::shutdown). Resume opens the gates; MLLP listeners bind again. - Stop (
ductus_runtime::stop; on Ctrl-C, SIGTERM, or drained withstop): 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'stoppingtoken. 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, askill -9does.
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_atto 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_startinGET /interfaces(counts, and mean/p50/p95 in ms),resourcesinGET /engine, andGET /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 optionalbetweenhours),active_by(a daily, weekly or cron deadline, its window from midnight orfrom),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 marklast_received,last_sentandlast_errorin memory (stats::Moment: an atomicfetch_max, no lock), loaded frominterface_activity/destination_activityat 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_sinceis 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 todestination_activityfirst, then awatchswitch 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::diagnoseandinterface_state, pure;Engine::diagnosegathers 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 overSTALL_AFTER= 1 min and nothing finished for as long; the last error if it's newer than the stall) > error (quarantined) > behind (pastbehind) > 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 ashealthinGET /interfaces,ductus_destination_state/ductus_interface_state/ductus_queue_oldest_secondsin/metrics, and inductus statuswith 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::lanescounts 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.partitionslowered 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 injson,slackorteamsformat) and rules (the first naming a state gives its severity and recipients;ownerresolves to the email of the one account it names (owners::find; not a disabled one), else theowners: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_stateanddiagnosefor everything (the same as the API), andalerts::stepon each stored alert (alert_state, one row per interface and destination, made at start): fire, escalate (higher severity), change quietly, reopen (bad again withinsettle), recovering, recover, remind (everyrepeatuntil acknowledged). Paused destinations hold; states that need watching wait until the engine has run a minute (errorandstoppeddon't). A change is written with its event and its notifications in one transaction, compare-and-set onrev, so two engines on one database record it once. Sending claims due rows ofalert_deliveries(the outbox) withFOR UPDATE SKIP LOCKEDand 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 everyevery. History is keptalerts.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_storecertificates.rs, migration 0021;api/certificates.rs): everydoctor.schedule(6 h; the first 60–120 s after start) the engine runs doctor's checks incertificatesmode: 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/requireones are described by doctor, not watched), and SQL Servers the engine verifies (notrust_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 ALPNtds/8.0forencrypt: 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 tocertificate_status(a check that couldn't read one keeps the earlier dates,read_at), each run todoctor_runs(the last 30;POST /doctorstores its run too). No code analysis incertificatesmode (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, againstalerts.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 aftersettle(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 0024side): eachcertificates:pair the engine presents (a TLS listener's, the API's, a client certificate;ductus_core::tls::presented) is one targetcert:<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 whentls::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 for0.0.0.0/::), records the chain served without verifying it, and judges the path (judge_chain: in order, ends at a root, acertificates: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). Seedocs/shared-pool.mdfor 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::beginreturnsCall::Done(sync transform) orCall::Pending(a future not borrowing the isolate). A per-threaddrive_event_looptask polls the JS event loop when kicked, so many interfaces can await DB calls concurrently on one thread.settleconverts the result. - Wake-ups: ingest calls
notify_oneon the owning slot'sNotify(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 itstransform, the module's for loading it. A singlescript-watchdogthread checks all isolates every 10 ms and, past a deadline, callsIsolateHandle::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 callsterminate_execution.disarmthen 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_loopsignals them all (watch): the culprit gets theRunaway, the others a transient error and are retried. - Limits: code in a shared module resumed after an
awaithas 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 totimeout.
SDK ↔ engine protocol
- Each interface is loaded through a generated glue module:
export const run = (raw) => __invoke(entry.transform, raw, formats)whereformats=InterfaceConfig::format_spec()({"input", "output", "http"}). __invokedecodes the payload by format, callstransform, serializes the result. Special results:null→ filtered;kill(reason)→ string prefixedductus_script::KILL; errors containingQUARANTINE/TRANSIENTmarkers 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 asglobalThis.__ductus_ops. The runtime implements theServicestrait (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→@P1outside literals/quoted names/comments; rows are converted in Rust to match Postgres's JSON (numbers, ISO 8601 times,\xhex binary, upper-case GUIDs; unnamed columnscolumn<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 (tiberiusRequired, orStrictfor TDS 8), verified against the system's certificates plusca:(trust_cert_ca_bundle) forhostorname_in_certificate, unlesstrust_server_certificate; refusals worded bymssql::refusal. fetch()(services.rshttp_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 unlessretry: false. Responses capped at 16 MB; messages never show the query string or URL password.api(name)uses the same op withapiset and a path asurl; 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 inresources.yamland never enter V8. HTTP destinations withapi:are resolved the same way when their sender is built.- Under
ductus testthe 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_testsdrives 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; itsdb()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.
- Postgres: queries are wrapped as
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).deliverieshas the same tiers and a partition for everymessagespartition, with the same bounds (copied from the catalog). Partitions created 3 days ahead, hourly, one table and tier per transaction (messages, thendeliveries): one transaction holding both tables deadlocked with status settling, which readsdeliveriesthen updatesmessages(migration 0006; reproduced 8/8 bypartition_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): seedecisions.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: relaxedsetssynchronous_commit = offfor that insert only.source_state(interface, key, value jsonb): what sources remember (file progressfile:<name>,schedule). Always written in the same transaction as the messages it accounts for.- Batched inserts (
Store::insert_batch) use oneINSERT ... 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 intotarget/book. The references underdocs/reference/(CLI, REST API, configuration, Event Log IDs, metrics, audit actions) are written byductus reference docsfrom 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.mdcomes fromscripts/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, soductus typesgives 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.jsputs 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 fromfile://(measurements below). - Checks:
scripts/dev/docs-check.shbuilds 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.ymlon every docs change; a weekly run also checks other sites' links and reports without failing. docs.yml then builds the site and runsscripts/dev/docs-search-check.cjs: both searches in a real browser (the runner's Chrome through playwright-core). Publishing (pagesjob,target/site) waits for the repository variableDOCS_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 thereferencecommand, 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~/targetwas 125,800 bytes larger (143,087,184): its test stage builds the binary again, likely with dev-dependency features unified in. Compare sizes fromcargo build --releasealone; 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 docs0.32–0.49 s (5 runs);docs-check.sh5.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
buildjob 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.sh8.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.cjsin 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 onreference/libraries.md749,205 gzipped (−3.5%); no search on it andreference/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 = 2706,796 (−9%, but a setting's result would land on its page's top: the configuration reference has a###per setting);heading-split-level = 1561,707 (−28%, every result a page's top); no search onreference/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 fromfile://(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.shtakes 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:local 10 Mbit/s, 40 ms 4 Mbit/s, 100 ms 3G (1.6 Mbit/s, 150 ms) mdBook (the zip; the site before) 0.20–0.30 s 0.83–0.90 s 1.80–1.91 s 4.18–4.29 s Pagefind's modal (the site now) 0.45–0.53 s 0.65–0.72 s 1.09–1.21 s 1.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.shweighs 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.mdfirst for "alerts").docs-search-check.cjschecks "retention" opens with the guide. Pagefind's 300 ms wait after the last key stays (Todd, 2026-10-31). -
The offline zip (
target/bookzipped: 3,212,622 bytes), unpacked into a folder whose name has a space and opened fromfile://: 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/bookunder$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 withTCP_NODELAY) the start is 0.35 s: sqlx 0.9 doesn't setTCP_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=disableagainstverify-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, 64prefer, 79verify-full. Nearly all of it is sqlx 0.9.0 not settingTCP_NODELAY(the handshake then the startup message wait on delayed ACKs): with anLD_PRELOADshim 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 (soprefer= TLS): 0.91–1.06 s vs 0.52–0.66 s withsslmode=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 asdb()'s pool does (PRELOGIN, TLS, login,SELECT 1): 123–130 ms p50trust_server_certificate, 120–141 verified, 116–141 strict verified: within the noise between rounds, so verification costs nothing measurable (tiberius setsTCP_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 setTCP_NODELAYand 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: dedicatedwithworkers: Nfor 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).
| Level | Who it's for |
|---|---|
| 1 Foundations | Writing a first interface |
| 2 Practitioner | Building and testing interfaces day to day |
| 3 Professional | Databases, APIs, files, schedules, ordering, retries |
| 4 Advanced | Isolation, performance, operations |
| 5 Expert | Delivery guarantees and failure semantics under crashes and runaways |
By subject
- Messages and delivery (5)
- The store (Postgres) (2)
- Several engines on one database (4)
- Retention (7)
- Several destinations (7)
- Order, retries and durability (7)
- Resending and correcting messages (3)
- Routing between interfaces (6)
- Writing transforms (10)
- Shared pool (3)
- Runaway scripts (6)
- Databases and APIs (5)
- Impact and
about:(8) - Checking connections (
ductus doctor) (4) - Files, SFTP and schedules (4)
- Operations: drain mode and the REST API (6)
- The web UI (3)
- Monitoring (10)
- Alerts (10)
- TLS on what the engine listens on (13)
- Who may connect (
allow:) (7) - TLS on what the engine connects to (5)
- TLS to Postgres (6)
- TLS to SQL Server (6)
- SQL Server Windows logins (6)
- Certificates (10)
- People and access (19)
- Local development (2)
- Upgrades (15)
- Windows (9)
Level 1: Foundations (24)
- Retention is 1, 3 or 7 weeks, not any number of days (Retention)
- Missing fields read as "" and never throw (Writing transforms)
return nullfilters; throwing quarantines (Writing transforms)- Fixtures can be read only inside a test (Writing transforms)
- A quoted number in an environment file is text (Writing transforms)
- A destination's owner is its interface's unless it says otherwise (Impact and
about:) - Moving ownership in the web UI is a preview, not a change (Impact and
about:) - An owner is a person when the text names exactly one account (Impact and
about:) - Operators start on their own interfaces (Impact and
about:) - Your own colors can be unreadable on purpose (The web UI)
- There's no search by message content yet (Monitoring)
- Silencing is not pausing, and acknowledging is not fixing (Alerts)
- The API on the network needs
tls(TLS on what the engine listens on) - Testing TLS without the real systems (TLS on what the engine listens on)
- Cloud Postgres requires TLS (TLS to Postgres)
auth: windows: the engine's own account, or a named one (SQL Server Windows logins)- Theme and colors follow the account (People and access)
ductus devis a development tool, not a way to run in production (Local development)- Your editor's help is the installed version's: run
ductus typesafter an upgrade (Local development) - Run
ductus upgrade --checkwith the new version before every upgrade (Upgrades) - Deploying project changes:
systemctl reload(Upgrades) ductus replayshows what a change would do to real traffic, before you make it (Upgrades)- The installer isn't signed yet: SmartScreen says "unknown publisher" (Windows)
- Certificate files: write the drive (Windows)
Level 2: Practitioner (84)
- Stopping the engine: what finishes and what waits (Messages and delivery)
- A start waits 30 s for a database that refuses, then says why (The store (Postgres))
- An interface runs on one engine at a time; a second one is refused (Several engines on one database)
- Messages are kept at least their window, and gone within a period after (Retention)
- Pending messages are never purged; past the window they're held (Retention)
- Quarantined messages are kept until resolved, unless you say
purge(Retention) - Shortening retention deletes messages, so it needs a confirmation (Retention)
- Each destination runs its own function on the original message (Several destinations)
- One destination down holds up only itself (Several destinations)
- Pausing a destination: at once, no questions, and it holds (Several destinations)
- Filtering and quarantine are per destination (Several destinations)
- Resend sends a message again in place: first in its lane, not last (Resending and correcting messages)
- The receiving interface's keys, dedupe and destinations apply (Routing between interfaces)
- Routes are fixed in interface.yaml, and loops are refused (Routing between interfaces)
- Imports used only for their side effects are removed (Writing transforms)
- Copy what you change, not the whole thing through JSON (Writing transforms)
- With
split: records,input()gives a fixture's first record (Writing transforms) - Interfaces share a global scope (Shared pool)
- Module-level caches live until the engine restarts (Shared pool)
timeoutcounts time without pausing, not total time (Runaway scripts)- A runaway message is quarantined; three in a row stop the interface (Runaway scripts)
api()headers: the API's, then yours (Databases and APIs)- Write resource names out:
db("patient_db"), notdb(name)(Databases and APIs) ductus impactonly knows what's named (Impact andabout:)- Moving an owner moves their resources.yaml entries too (Impact and
about:) - Your own documentation fields are checked by
ductus validate, not by the engine (Impact andabout:) - Doctor checks from where it runs (Checking connections (
ductus doctor)) - A pass means "can connect and log in", not "will accept your messages" (Checking connections (
ductus doctor)) - On Linux the service can write only
/var/lib/ductusuntil you add a folder (Files, SFTP and schedules) - A start logs one line, not one per listener (Operations: drain mode and the REST API)
- A console session acts as whoever signed in (The web UI)
- In respond mode, calls run one after another unless you await them together (Monitoring)
- Nothing is expected until you say so (Monitoring)
- What each state means, and when it shows (Monitoring)
- A receiver that's back can stay stalled for up to a minute (Monitoring)
- Alerts tell you once, then remind; they don't repeat every check (Alerts)
- A pause never alerts, and holds an alert open (Alerts)
- An engine that is down sends nothing: use the heartbeat (Alerts)
- Quiet hours hold warnings until morning; critical ones never wait (Alerts)
- Who "owner" is (Alerts)
- Webhook URLs are secrets (Alerts)
- A certificate the engine can't use stops the start, with the reason (TLS on what the engine listens on)
- Renewing: replace both files, no restart (TLS on what the engine listens on)
- A certificate from the hospital's CA: request, then show (TLS on what the engine listens on)
- HTTP interfaces on one port share its
tls(TLS on what the engine listens on) - An allow list says who, which "one connection only" never did (Who may connect (
allow:)) - It's defence in depth, next to the firewall (Who may connect (
allow:)) - List 127.0.0.1 on the API if you use it from its own server (Who may connect (
allow:)) - HTTP interfaces on one port share its
allow(Who may connect (allow:)) - A private CA is trusted only where you name it (TLS on what the engine connects to)
- A refused handshake is a temporary error: the message waits (TLS on what the engine connects to)
- Our client certificate: tell the receiver what to expect (TLS on what the engine connects to)
- An HTTP destination through an API uses the API's TLS (TLS on what the engine connects to)
- A CA installed on this server counts (TLS to Postgres)
- A refused Windows login: 18452 is Windows saying no, 18456 is SQL Server (SQL Server Windows logins)
- The engine checks certificates by itself, and logs in to nothing (Certificates)
- The earliest expiry in the chain counts, in whole days (Certificates)
- A certificate alert is told at 30, 14 and 7 days, and when it expires (Certificates)
- A check that can't connect keeps the last dates (Certificates)
- Our certificates count down like the rest, with no check (Certificates)
- A renewal that can't be used shows on its row, not as an alert (Certificates)
- Passwords, lockout and what a disabled account loses (People and access)
- The audit log records who did what, and what it leaves out (People and access)
- Dismissing a quarantined message is not deleting it (People and access)
- Signing in with the directory: local names first, groups decide the role (People and access)
- Write several group DNs one per line, not
[a, b](People and access) - A person removed from the groups loses access within
refresh(People and access) - Directory owners get their account when they're in a Ductus group (People and access)
- A new directory account for the same person can't be linked to the old one (People and access)
- A renamed directory person keeps one account; a reused name gets a new one (People and access)
- When the directory is down (People and access)
- A migration marked heavy holds up a table while it runs (Upgrades)
- On Linux a restart refuses no connection: senders wait for it (Upgrades)
systemctl reloadhands over: receiving never stops, sending pauses briefly (Upgrades)- A new version or project that can't start leaves the running one serving (Upgrades)
- A replay compares outputs with the installed version from 0.6.6.4 on (Upgrades)
- A replay's report holds patient data unless you hide it (Upgrades)
- A stopped service, a failed start and a drain refuse connections, on purpose (Upgrades)
- Windows and containers: a restart is a short gap (Upgrades)
- The service's account: the default, a gMSA, or a named account (Windows)
- An upgrade can't keep an account's password: the service waits for it (Windows)
- An upgrade starts the service again, unless it was stopped on purpose (Windows)
- What goes to the Event Log, and what only to the files (Windows)
- The Windows service can only touch what you give it (Windows)
Level 3: Professional (67)
- A message can be delivered twice (Messages and delivery)
- Your transform can run more than once for the same message (Messages and delivery)
- Only the original is stored; outputs are recomputed (Messages and delivery)
- A dead engine's interfaces are free at once; a vanished host's within ~25 s (Several engines on one database)
- Losing the database connection pauses sending, not receiving (Several engines on one database)
- PgBouncer in transaction mode can't hold the engine's lock (Several engines on one database)
- One retention change at a time per interface (Retention)
- Adding, removing or renaming a destination (Several destinations)
- Many destinations on one interface: when to split (Several destinations)
- Order is per key, and one stuck message holds up its key (Order, retries and durability)
- Without an ordering key, more workers don't help (Order, retries and durability)
- Temporary failures retry forever by default (Order, retries and durability)
- Database login failures make messages wait, not fail (Order, retries and durability)
- Respond-mode HTTP requests aren't stored (Order, retries and durability)
- A replay is the same message; an edited copy is a new one (Resending and correcting messages)
- A message still pending can't be replaced yet (Resending and correcting messages)
- A route is exactly once inside the engine, not beyond it (Routing between interfaces)
- One query for many values, not one query per value (Writing transforms)
- SQL Server: one result set, one batch per call (Databases and APIs)
fetch()andapi()retry 5xx, 408 and 429 by default (Databases and APIs)- The documentation snapshot is as old as the last engine start (Impact and
about:) - One working address is enough to pass, with a warning (Checking connections (
ductus doctor)) - A file is picked up one poll after it stops changing (Files, SFTP and schedules)
- One file at a time, oldest first; a failure stops the rest (Files, SFTP and schedules)
- Cron uses the server's local time; missed runs collapse to one (Files, SFTP and schedules)
- Counts and timings start at zero when the engine starts (Monitoring)
- Burst and slow receiver need a few weeks of history (Monitoring)
- Finding the message or patient that holds a queue up (Monitoring)
- Notifications are sent at least once, and a failing channel only shows in the UI (Alerts)
- A restart doesn't alert on what it hasn't watched yet (Alerts)
- Give the whole chain in
cert_file(TLS on what the engine listens on) - Senders on TLS 1.0 or 1.1 can't connect (TLS on what the engine listens on)
- A client certificate says which system sent, not who (TLS on what the engine listens on)
- A refused handshake is logged once a minute per sender (TLS on what the engine listens on)
- A CA's certificate as a server's (TLS on what the engine listens on)
- Post-quantum key exchange: on by default, never in the way (TLS on what the engine listens on)
- Host names are looked up at start and every 5 minutes (Who may connect (
allow:)) - Refusals are logged once a minute per listener and address (Who may connect (
allow:)) - Dead connections are noticed in about 30 seconds (Who may connect (
allow:)) - Doctor makes the engine's handshake (TLS on what the engine connects to)
- Without
sslmode, the database connection is TLS when offered, and nobody is checked (TLS to Postgres) - The database's certificate is watched when the engine checks it (TLS to Postgres)
- A client certificate renews at the next connection (TLS to Postgres)
- SQL Server connections are verified, and
trust_server_certificateis for labs (TLS to SQL Server) - Connecting by a name the certificate doesn't list:
name_in_certificate(TLS to SQL Server) encrypt: strictis TDS 8: TLS before anything (TLS to SQL Server)- SQL Server 2022 on Linux doesn't accept
encrypt: strict(TLS to SQL Server) - SQL Server's certificate is read through PRELOGIN, and watched when verified (TLS to SQL Server)
- The service's own account is the computer on the network (SQL Server Windows logins)
- Kerberos or NTLM: doctor and the start log say which (SQL Server Windows logins)
- From Linux, a Windows login is always NTLM (SQL Server Windows logins)
- Extended Protection set to Required refuses NTLM logins (SQL Server Windows logins)
- What has a certificate the engine watches (Certificates)
- Behind a load balancer, you see the balancer's certificate (Certificates)
- Doctor reads the chain our listeners serve, but not the name senders use (Certificates)
- Payload reads are logged before they are shown (People and access)
- Copies of payloads and the audit log are kept for different times (People and access)
- A crash shows in the audit log (People and access)
- OpenLDAP: group membership follows a rename only with
refint(People and access) - Wrong passwords count twice: here and in the directory (People and access)
- The directory connection is TLS, verified like the rest (People and access)
- A replay doesn't write: those messages can't be compared (Upgrades)
- A difference from the record can be the data, not the code: drift (Upgrades)
- In a replay the clock is the message's arrival, and
Math.randomrepeats (Upgrades) - What a replay costs, and where (Upgrades)
- "Log on as a service": the installer grants it, Group Policy can take it away (Windows)
- A refused connection takes about a second on Windows, and
localhostmay be::1first (Windows)
Level 4: Advanced (29)
- A query that holds the message tables for days can pause intake (The store (Postgres))
- Shortening frees disk when partitions go, not at once (Retention)
- Change
ordering.partitionsonly with an empty queue (Order, retries and durability) durability: relaxedcan lose the last fraction of a second (Order, retries and durability)- Order holds across a route when the sender keeps it (Routing between interfaces)
- A routed message waits up to 20 ms before the next interface sees it (Routing between interfaces)
- Routes keep moving while draining (Routing between interfaces)
- Short-lived objects are cheap; what you keep is what costs (Writing transforms)
- Loops over thousands of items: give objects one shape (Writing transforms)
- A shared library file is one copy per thread, not one per project (Shared pool)
- Neighbours wait while runaway code runs (Runaway scripts)
- The heap limit is shared by everything in an isolate (Runaway scripts)
- Dedicated connection pools only for dedicated interfaces (Databases and APIs)
- The commit time is the store's disk, and the ceiling per strict sender (Checking connections (
ductus doctor)) - Draining closes MLLP listeners; it doesn't answer AE (Operations: drain mode and the REST API)
- Respond-mode HTTP keeps answering while draining (Operations: drain mode and the REST API)
- A drain finishes only when every destination has caught up (Operations: drain mode and the REST API)
- Drain mode isn't remembered across a restart (Operations: drain mode and the REST API)
- The API listens on this machine only, until you open it (Operations: drain mode and the REST API)
- The UI works over https, or at localhost on the engine's own server (The web UI)
- Timings are estimates to a bucket, averages are exact (Monitoring)
- Prometheus series grow with destinations (Monitoring)
- Judging alerts costs little, and runs all the time (Alerts)
- What TLS costs (TLS on what the engine listens on)
- Opening a database connection over TLS costs ~60 ms, for now (TLS to Postgres)
- Verification costs nothing measurable (TLS to SQL Server)
- A refused certificate is said once, not logged as an error each time (Certificates)
- Reading payloads is a permission, not part of a role (People and access)
- Nested groups: Active Directory answers, other directories are walked (People and access)
Level 5: Expert (4)
- A "done" marker can't prevent duplicates (Messages and delivery)
- A runaway is blamed on the interface, not the destination (Several destinations)
- A runaway after
awaitinterrupts every async transform on its thread (Runaway scripts) - A runaway in a shared library after
awaitcan't be blamed on anyone (Runaway scripts)
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
dedupeon 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. Anydb().execute()orfetch()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: trueon that destination: what it was sent is kept with its delivery. Keep transforms that don't need lookups free of them.ductus replayrecomputes 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) andductus drain --stopall 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 -9at 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 --stopbefore planned work (it also lets the queues empty first); a plain service stop is just as safe for messages. Keep a transform'stimeoutwell 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 usedocker stop --time 45orstop_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 oftransform: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 doctoranswers 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 /engineothers). 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 afterkill -9indemo-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 locksession 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 is1w,3wor7w(or the same in days:7d,21d,49d; default:retentionin ductus.yaml, else 7w). Anything else is refused byductus 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 retentionshows the held area by month and interface, and/metricshasductus_held_messagesandductus_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 inabout:.
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, andductus retentionshows 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 FULLthe 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 (transformunless it names another withtransform: toBilling) on the message as received, not on another destination's result. Three destinations usingtransformrun it three times, and each run makes its owndb()andfetch()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
pendinguntil every destination is done with it. Then it'squarantinedif any destination quarantined it,sentif any destination got it, andfilteredif all of them filtered it. The status per destination says which (ductus statuslists 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 resumeends 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()andquarantine()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 ("destinationxwas 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 benchwill 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: 4holds up about a quarter of the patients. With noordering.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_attemptson 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
partitionswith anordering.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 validatewithDUCTUS_DATABASE_URLset says the same before you deploy. - Raised: allowed, with a warning in the log and an
interface.partitions_raisedrow 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. workersalone: safe (every lane still has one owner).
- 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)".
- 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. Runductus validateagainst the database before deploying. To give each of N workers 64 lanes, setpartitions: 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.keyis one lane, and a lane has exactly one owner.isolation: dedicatedwithworkers: 8still 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) raiseworkersup to about the number of cores, (4)dedicated_poolsif 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_attemptslimits 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.idand 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 onmeta.idor 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 newmeta.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 itsorigin. - 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: xdestination) queues the message inxagain, wherex'sdedupeapplies.
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_attemptsso 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 inlab(which hassource: 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.originin code,originin the API,origin_idin the table. Whatlabthen 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(ormeta.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,dedupeand destinations are applied as it arrives, read from what the sending function produced. The sending destination's output format must be the receiving interface'sformat(ductus validateand startup check). - What to do: if the receiving interface orders by MRN, the routed message must still carry the MRN where its
ordering.keylooks.
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.keyas 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 validateand stops startup. An interface withsource: internalthat nothing routes to only gets a warning. - What to do: to route by content, give the sender a destination per target and
return null(orkill()) 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 orquarantine("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"ifxis never used, so code that runs whenm.tsloads 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,undefinedand functions disappear, aMapbecomes{}. - 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()orfetch(), 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 aMap. 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
Mapthat 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 towardisolates.heap_mbuntil 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""orundefinedrather thandelete.
Fixtures can be read only inside a test (level 1)
- What:
fixture(),hl7()andinput()work insidetest(...)(or a function a test calls), not at the top of a test file:ductus testhands 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 givestransformone record per message, andinput("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 indexesinput(...)[1]getsundefined. - 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](parseFixedfor 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}withLAB_PORT: "6670"inenvironments/dev.yamlis the text "6670", and the interface is refused: "invalid type: string "6670", expected u16".LAB_PORT: 6670is 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/nomeant as text; a time like07: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
globalThisor changing built-in prototypes affects them all.ductus validatewarns (DX1, DX2).
A shared library file is one copy per thread, not one per project (level 4)
- What:
lib/x.tsimported 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 withworkers: 4has 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
Mapkeeps 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 ondb()orfetch()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
timeoutis 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
timeoutclose to what the code really needs (timeout: 500msis plenty for most HL7 transforms). Put interfaces with heavy or untrusted code inisolation: dedicated.
The heap limit is shared by everything in an isolate (level 4)
- What:
isolates.heap_mb(inductus.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
awaitis 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/executeis its own batch, so a transaction must begin and end within one call. Text parameters are sent asnvarchar: comparing them with avarcharcolumn 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: falseto 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.yamlbrings 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/1underhttps://h/api/v2ishttps://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 withmock.api("name", ...);mock.fetchdoesn't answerapi()calls.
Write resource names out: db("patient_db"), not db(name) (level 2)
- What:
ductus validatechecks that every database and API named in code exists inresources.yaml, andductus impactuses 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 validatebefore deploying (in CI).
Dedicated connection pools only for dedicated interfaces (level 4)
- What:
dedicated_poolsneedsisolation: dedicated; shared-pool interfaces use the shared pools fromresources.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--vendorand--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 newwrites it);validatewarns about gaps, andrequire_about: trueinductus.yamlmakes them errors. Name the vendor on shared resources once inresources.yaml. List senders' subnets inabout.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.ownerowns the interface and every destination;owner:on a destination overrides it for that destination only (e.g. its code is another team's).ductus ownerslists destinations separately only when their owner differs.ductus owners reassign --from A --to Bchanges onlyowner: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, runreassignonce 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 theductus owners reassigncommand; 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 whatgit applyneeds: from an engine on Windows (CRLF files, Git for Windows'score.autocrlf) it applies to a Windows checkout; a patch made on Linux (LF) applies to a Linux checkout or one withautocrlfoff.
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 getsto: owneralerts. Otherwise it is a team inowners:(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 ownerswithDUCTUS_DATABASE_URLmarks each owner,ductus validatedoes 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 --checkprintsductus 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 inowners:. Runductus owners --checkin 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 withductus owners reassignbefore 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) changesowner: Ain every interface.yaml and in resources.yaml (databases, APIs, SFTP servers, directories), so certificate alerts follow the person. Naming--interface xor--resource ymoves 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.yamldeclares 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 flatcustom:block beside the other keys (not underabout:). A missing required value, a value outside an enum, a date that isn'tYYYY-MM-DD, an undeclared name or a field used where it doesn't apply is an error inductus validate(andductus docs --missingexits 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 brokendocs-schema.yamlis logged at startup and the fields are ignored until it is fixed. Enums match exactly, case included (yesis notYes): that is the point, since it stops "Yes/yes/Y" from drifting into your reports. - What to do: put
ductus validatein 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 asrequired: falsefirst, 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_snapshottable, so reports and BI tools can askdocs->'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: editingcustom: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_snapshotby 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
GETto 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: relaxedwhere 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
markerfile 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 underductus runin 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]andReadWritePaths=/data/outbound /data/inbound, thensudo systemctl restart ductus(a unit change needs a restart, not a reload). Give theductususer the folders' permissions too (chownor 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 portslists 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(orPOST /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 withRetry-After: 30. File pickup pauses (a file being read stops between batches and resumes at its saved offset afterductus resumeor 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/healthreportsdrain: 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 --waitandGET /api/v1/drainshow 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:7700by default (api.listenin ductus.yaml,--api-listen, oroff). Every call except/healthand/openapi.jsonneeds a token. Tokens are made on the server withductus token createand 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 needstls), or put a TLS reverse proxy in front and keep it on127.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 markedSecure, which browsers keep only over https, or over plain http tolocalhost/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 openhttps://; or put a TLS reverse proxy in front (keepapi.listenon127.0.0.1behind it), or use an SSH tunnel (ssh -L 7700:127.0.0.1:7700 server, thenhttp://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_inallows 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 theX-Ductus: 1header, 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.idlewhere 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) andsince_startinGET /api/v1/interfacescount 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_msis 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 keepductus_delivery_latency_secondsand dropductus_transform_seconds).
In respond mode, calls run one after another unless you await them together (level 2)
- What: each
awaitwaits 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 optionalbetween: "06:00-22:00"),active_by: "Mon-Fri 09:00"(with optionalfrom:),behind: 500,behind: 10morbehind: { messages: 500, age: 10m }(a destination can set its ownbehind). 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
behindwith 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). Setactive_withinon steady feeds andactive_byon scheduled ones. The engine alerts on these states itself oncealerts: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
behindlimit), 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 hotspotsshows which message) or paused, from best to worst (it shows the worst that applies;summarylists them all); says it in words ("behind 1,240 (oldest 12 min), catching up ~6 min"). An interface is ok, silent, missed (itsactive_bydeadline) 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.partitionswas lowered while messages waited (see Changeordering.partitionsonly 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 setretry.max_attemptsso 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 pausestops 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_mson 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 withWHERE interface = … AND received_atin a rangeAND 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;certificatehas 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 everyrepeat(4 h by default;0= never). Once it has been fine forsettle(2 min) it recovers, and the people who were told hear that too. Going bad again withinsettlereopens 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. Keeprepeatunless 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
heartbeatin production, at a service that doesn't run on the engine's server, and set that service's grace period to about twiceevery.
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_totalfrom Prometheus if you have it. Try a channel before going live with Send test orductus 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:inalerts:), and an interface can add its own team withalert: { to: [lab-team] }. People change; a team's list doesn't, and someone on it is always awake.ownerinto:is optional: it also mails whoever owns the destination (its ownowner:, else its interface'sabout.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 inowners: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 databaseductus validatecan't see accounts, so it only warns about owners that are neither inowners:nor addresses; withDUCTUS_DATABASE_URLit 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 ownersand the documentation say whom to ask about what. - What to do: send alerts to team channels in your rules; add
owneronly where a person should hear directly. Put teams inowners:with their list address, write people as their email, and give accounts an email. A test interface can sayalert: { 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.errorandstoppedare 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 }withouttls: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 isSecure), so the UI can't sign in. The engine andductus validatewarn about it. - What to do: name a certificate (
api: { listen: 0.0.0.0:7700, tls: { certificate: ductus-api } }, withductus-apiundercertificates: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 validatechecks 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 validatein 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_fileandkey_fileis 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,
stepand 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 renewedwithin 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(orcert) 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 doctorwhile 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
subjectsnarrow: 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 doctorrun 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 intls/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 requeston the engine's server, send only the.csr, and runcert showon what comes back before naming it in resources.yaml. To renew: a new request under another name, then copy its.pemand.keyover the two in use (see Renewing).
A CA's certificate as a server's (level 3)
- What:
openssl req -x509makes 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 inca:, 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 showwarns 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 requestfor the hospital's CA, orductus cert self-signedfor 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()andfetch(), 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 --tlssends over TLS, trusting this server's roots plus--ca FILEand 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 withtls:does, and logs refusals the same way. Both read PEM files, the same ascertificates:entries. - What to do: try a feed's TLS settings in a lab with
ductus cert self-signedpairs before the receiver's team is involved (scripts/demo-tls.shdoes 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 validateand 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 onapiin ductus.yaml) closes a connection from any other address before the TLS handshake and before a byte is read; withoutallow, anyone who can reach the port may connect, as before. Listeners take any number of connections; there is noconnections: 1setting. - 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 validatewarns whenabout.from.hostsnames a sender the list would refuse, and refuses a malformed entry (a bad subnet, a wildcard*.hospital.org, a range10.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
allowso 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 trustX-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-bridge01compare 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 listwith 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 andGET /portscount 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 doctorrun 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.allowapplies to everyone, includingductus status,drainand a browser on the engine's own server.ductus validatewarns 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 validateand 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 andapis:, the directory, Postgresdatabases:entries, the alerts' mail server, webhooks and heartbeat) trusts what this server's system trusts, plus the CA itsca:names (acertificates:entry in resources.yaml), for that connection only.tls: trueon an MLLP destination is the system's alone. The receiver's certificate must also cover thehost(or URL host) connected to: a certificate forhie.example.orgis refused at10.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:orca: ${secret:...}), and name it inca:. Connect by the name on their certificate.ductus validateand the engine's start refuse aca:that names a pair, acertificate: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 acertificates: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:andcertificate:go on theapis:entry, whichapi()calls andhttp: { api: ... }destinations share. On an HTTP destination they are only for one with its ownurl:(https://):validaterefuses them next toapi: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 inca:orcertificate: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_URLmeans what it means in psql: withoutsslmodeit is libpq'sprefer, 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.requireinsists on TLS and checks nothing either. Onlyverify-fullchecks 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.yamlpostgresentries work the same withtls:(defaultprefer;verify-fullwhenca: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-fullby 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.pemwhen this server doesn't already trust its CA (sslrootcert=system: the system's alone); in resources.yamltls: verify-fullandca:(acertificates:entry).ductus validatesays "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 addsslrootcert=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) plussslrootcert(orca:), as MLLP and HTTPS connections are. sqlx reads the system's certificates at every new connection (about 10 ms withverify-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-fullorverify-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. Withpreferorrequire, doctor shows the certificate (and whetherverify-fullwould 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 acertificates:pair (pg_hba'scertmethod: no password; or with a password andclientcert=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 readssslcert=andsslkey=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% withTCP_NODELAYset. Fixed in sqlx (transact-rs/sqlx#4336, after 0.9.0): with it, opening takes 8–11 ms (22 msverify-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:(acertificates:entry), for the name inhost.trust_server_certificate: trueaccepts 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 andductus validatewarn 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 toca:orname_in_certificate:(they'd never be checked). There's noencrypt: 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.tlscertandnetwork.tlskeyon Linux), name the CA inca:unless this server already trusts it, and droptrust_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 tohost. - 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: strictmakes the handshake first, asking fortds/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 ignoresTrustServerCertificatewith strict and always verifies; heretrust_server_certificatestill turns verification off (for labs), warned about as without strict. - What to do: leave
encryptout unless the DBA forces strict; then setencrypt: strict. A server that doesn't accept strict closes the connection during the handshake, and the error says to leaveencrypt: strictout.
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
encryptout: the connection is still TLS throughout and verified. Useencrypt: strictwith 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 withtrust_server_certificateis described, not watched (an expired certificate doesn't stop a connection that checks nothing). A named instance withoutport: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: windowswith nouserlogs in as the account the engine runs as (integrated, likeIntegrated Security=true); withuser: 'HOSPITAL\svc-lab'and itspasswordit 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: windowswithout auseris 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\Ductusby default. On the network that account is the computer's:HOSPITAL\SERVER01$. An integrated login from the default service is therefore a login forHOSPITAL\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 validateandductus doctoron 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 WINDOWSand a user in the database. A login given to a Windows group (SQL Server Express gives one toBUILTIN\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: windowsneeds 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 incertificatesmode: 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: fullruns everythingductus doctordoes, 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
certificatesunless 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 withverify-fullorverify-ca(the engine's own database and resources.yaml entries: see "The database's certificate is watched when the engine checks it"), SQL Server withouttrust_server_certificate(see SQL Server's certificate is read through PRELOGIN), and what alerts connect to: the mail server (tls: starttlsortls), HTTPS webhooks and the heartbeat. Not watched: databases the engine doesn't verify, SFTP (host keys, not certificates), MLLP destinations withouttls:(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 usualrepeatafter. Renewed past 30 days, it recovers (aftersettle); renewed with a certificate that is itself short, it moves to that step quietly.alerts.certificates.warn_dayschanges the steps. Without a rule namingcertificate, nothing is sent (ductus validatesays so) and the Connections page still shows them. - What to do: send
certificateto 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'sowner:(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 inGET /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 itsowner: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 namingcertificateto 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 /doctorwithmode: full, a scheduledmode: 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 acertificates: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 doctordoes 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 thepayloadspermission. 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
payloadsto 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_inoff once everyone has an account, so a leaked token can't also open the UI. If you lock yourself out,ductus useron 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,systemfor the engine or someone at the server's command line,anonymousfor 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 theauditpermission (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
auditto the compliance or security person, not to operators. Ship the log to your SIEM withductus 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 withpayloads) 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 withinaudit.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.0slogs every read.audit.payload_reads: falseturns 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
0sand 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}/dismissand/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 withsave_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 afteraudit.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.keepinductus.yamlto 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 writesengine.stopwith the same instance. A start whose predecessor has no stop was killed, crashed or lost power; the new start's row saysprevious: uncleanand the engine logs a warning. A stop that had to give up waiting (30 s) is writtenforced. 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.ldapin 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;permissionsgroups 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 inmemberand 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 becomesCN=PHI,OU=Groups,DC=h, ...: pieces that each look like a DN and match no group. The engine refuses to start (andductus validatesays) 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 auser.directory_refusedrow 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 torefresh. - 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
refreshcosts 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 inowners:) 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 (audituser.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 withinrefresh; someone deleted or removed from the groups there is refused, so the owner shows as disabled,ductus owners --checkfails 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 nextrefresh. - 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, andductus 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'sobjectGUID, elseentryUUID, 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→jjonesat 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 (audituser.directory_changewith"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 validatewarns, and the engine logs it once. - The sign-in name is read from the attribute
user_filtermatches{username}against (sAMAccountNameby default); a filter that matches it another way needsattributes.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
memberlinks follow a renamed or moved person by themselves. OpenLDAP's are plain DNs: without therefintoverlay, 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
refintformemberanduniqueMember(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, andductus 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
urlat 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: truecounts 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, searchinggroup_base(default: the directory's naming context) for groups that contain each one. Withoutnested, only direct members count (member, oruniqueMemberfor 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_baseto 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) orldap://...withstarttls: 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 unlessca:names that CA (acertificates:entry:hospital-ca: { ca: ${secret:HOSPITAL_CA} }orca_file:), which is then trusted for this directory only. Plainldap://is refused unlessinsecure: true, whichductus validateand 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 devstarts 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 ignoresDUCTUS_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 devon your own machine; everywhere else setDUCTUS_DATABASE_URLand useductus run. Without a download possible, unpack the Postgres binaries yourself and setDUCTUS_POSTGRES_DIR.ductus dev --resetthrows 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 typeswrites into.ductus/schema/(with the SDK's typings in.ductus/types/), and those come from theductusthat 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 olderductusthan the servers, the editor describes the older one.ductus testandductus validatewrite 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(ortest) after installing a new version; keep developers'ductusat 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.gzor-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.--jsongives 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 ductusand 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; akill -90 refused, 5.4 s (systemd waitsRestartSec, 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 ductusshows "running N interfaces" once it's up (systemctl startandrestartwait 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 runby 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 anengine.handoverentry; 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 ductusfor upgrades (the package does) and deploys (below). Runductus upgrade --checkfirst 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 reloadfails,journalctl -u ductussays 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 ductusagain (orsystemctl restart ductusto 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 pullin/var/lib/ductus/project), thensystemctl 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 validateandductus testfirst. Changes to the systemd unit itself (systemctl edit ductus) still needsystemctl daemon-reloadand a restart. Rolling back is the same: put the previous project (or install the previous package) and reload. Outside the package's service (ductus runby hand) setDUCTUS_CONTROL=/some/dir/controland runductus handover --control /some/dir/control;ductus devhas no handover.
ductus replay shows what a change would do to real traffic, before you make it (level 1)
- What:
ductus replay --since 24hruns 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.htmlwrites a page with the versions, the period, the counts and a checksum on top (for a change board to sign),--jsonthe 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 1dadds 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--baselinebefore deploying a code change. A read-only login or a replica is enough.--limit 1000samples the first thousand per interface;--interfacepicks 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 --emititself, 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 (alsooutputs_not_comparedin--json, and on the HTML page), andupgrade --check --replayshows it as a warning.--against PATHnames 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: trueon 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().queryruns (Postgres sessions are read-only, so a query that writes is refused by the server),db().executeandfetch()other than GET and HEAD aren't made: the call fails, and the message is reported "not comparable: writes to databasebilling: 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: adb().querythat 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().queryonly 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()andDate.now()give the time the message was received (and the period's start in a module's top-level code), andMath.random()gives the same sequence for the same message and destination, in every version.new Date(x),Date.UTCandinstanceof Datework 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.receivedAtwhen 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-valuesleaves 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, likeductus 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 onlypg_read_all_data.
A stopped service, a failed start and a drain refuse connections, on purpose (level 2)
- What:
systemctl stop ductuscloses 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 ductussays 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 Ductusmeasured 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).msiexecitself 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.execarry 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", orUnblock-Fileit 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.\nameon this computer) works too, but the installer can't take its password (installer properties end up in the install log): give it withductus service account 'HOSPITAL\svc-ductus'(it asks;--password-stdinfor scripts).ductus service accountwith 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 /qnwithductus 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 accountgrant it on this computer;ductus service accountalso 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 accountshows 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.envexists). A service someone stopped (services.msc,Stop-Service) stays stopped: a stop asked of the service leaves astoppedfile inC:\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
stoppedfile). 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 runin 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'sGet-WinEvent, a monitoring agent that renders messages) keepsductus.exeopen, 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, thenStart-Serviceafter: 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 runningductus.exe(aductus sinkorgenleft 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 readC:\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 onD:\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 doctorin 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, andlocalhostis::1before127.0.0.1: a receiver listening on127.0.0.1only 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, notlocalhost, for an IPv4-only receiver on the same server). Keep an MLLP destination'stimeout_msabove ~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_fileandca_filewith 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 validatewarns 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
timeoutwithout pausing (default5s; set it ininterface.yaml, e.g.timeout: 500ms). Waiting ondb()orfetch()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_mbinductus.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 stoppedwith 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.
| column | type | |
|---|---|---|
id | bigint | From a sequence: arrival order within an interface. With received_at, the primary key (a partitioned table's key must include the partition column). |
received_at | timestamptz | When it was stored. |
interface | text | The interface's name. |
status | smallint | Overall status: pending until every destination is done; then quarantined if any destination quarantined it, else sent if any sent it, else filtered. |
partition_key | integer | The lane, from the ordering key (ductus_core::partition_of). |
ordering_key | text | The ordering key's value (e.g. the MRN), if the interface has one. |
control_id | text | MSH-10 for HL7, when present. |
payload | bytea | The message as received. |
attempts | integer | Used 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_error | text | Why it was quarantined or filtered (destination: reason; ... with several destinations). |
updated_at | timestamptz | Last status change. |
origin_id | bigint | Routed from another interface (source: internal): the id of the message there. NULL for messages received from outside. Not indexed. |
tier | smallint | The 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).
| column | type | |
|---|---|---|
message_id | bigint | messages.id. |
received_at | timestamptz | messages.received_at (finds the partition). |
destination_id | integer | destinations.id. |
partition_key | integer | The message's lane (copied, so the queue index needs no join). |
status | smallint | This destination's status. |
attempts | integer | Failed attempts so far. |
last_error | text | Last failure or the reason it was filtered/quarantined. |
output | bytea | Exactly what was sent, only for destinations with save_output: true. |
updated_at | timestamptz | Last change. |
tier | smallint | Always 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).
| column | type | |
|---|---|---|
id | integer | Identity. |
interface | text | The interface's name. |
name | text | The 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.
| column | type | |
|---|---|---|
interface | text | The interface's name. |
key | text | The message's key values, joined. |
seen_at | timestamptz | Last 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).
| column | type | |
|---|---|---|
interface | text | The interface's name. |
key | text | What it is (file:lab.csv, schedule). |
value | jsonb | The state. |
updated_at | timestamptz | Last 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.
| column | type | |
|---|---|---|
id | integer | Identity. |
name | text | Who or what uses it (ci, monitoring). One live token per name. |
role | smallint | 0 viewer (reads), 1 operator (also drain and connection checks), 2 admin (also tokens). |
hash | bytea | SHA-256 of the token (tokens are 32 random bytes, so a slow password hash adds nothing). |
created_at | timestamptz | When it was made. |
last_used_at | timestamptz | Last use, to the minute (written at most once a minute per token). |
revoked_at | timestamptz | When it was revoked; it stops working at once. |
user_id | integer | The 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. |
permissions | text[] | 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.
| column | type | |
|---|---|---|
id | integer | Identity. |
username | text | Sign-in name; not case sensitive (unique on lower(username)). |
display_name | text | For people reading the UI. |
email | text | Where alerts for this person go. |
role | smallint | 0 viewer, 1 operator, 2 admin (as tokens). |
permissions | text[] | payloads (may read message payloads), audit (may read the audit log). Admins have both. |
source | text | Where the password is checked: local, or ldap (a directory; no hash kept). |
password_hash | text | argon2id, a self-describing string; null when the password lives elsewhere. |
must_change_password | boolean | Set when an admin made or reset the password: the next sign-in can do nothing but change it. |
disabled_at | timestamptz | When it was disabled: sessions and tokens stop at once. |
failed_logins | integer | Wrong passwords counted within the lockout window. |
last_failed_at | timestamptz | The latest wrong password (starts a new window when the last one is over). |
locked_until | timestamptz | Past the limit, the account is refused until then. |
created_at | timestamptz | |
last_sign_in_at | timestamptz | |
directory_checked_at | timestamptz | ldap 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_at | timestamptz | ldap 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.
| column | type | |
|---|---|---|
id | bigint | Identity. |
user_id | integer | users.id (deleted with it). |
kind | text | username, email or display_name. |
value | text | The value it had. |
until | timestamptz | When 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.
| column | type | |
|---|---|---|
user_id | integer | users.id. Primary key. |
theme | text | system, light, dark, contrast or custom. |
colors | jsonb | The five custom colors and the values worked out from them, as the UI saves them. |
updated_at | timestamptz |
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.
| column | type | |
|---|---|---|
id | integer | Identity. |
hash | bytea | SHA-256 of the cookie's value (32 random bytes). |
token_id | integer | The token it signed in with (api_tokens.id); none for a user's session. |
user_id | integer | The user it signed in as (users.id); none for a token's session. Exactly one of the two is set. |
created_at | timestamptz | Sign-in. |
last_seen_at | timestamptz | Last use, to the minute (written at most once a minute). |
expires_at | timestamptz | When 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).
| column | type | |
|---|---|---|
id | bigint | Identity. |
at | timestamptz | When. |
action | text | resend, edit or dismiss (a quarantined delivery closed without sending: it became filtered, "dismissed by NAME: reason"). |
token_id | integer | The API token used (api_tokens.id). |
user_id | integer | The person who acted (users.id), when it was one (their token's owner counts). |
actor | text | The user's or token's name at the time (names can be reused). |
reason | text | Why, if given. |
message_id | bigint | The message acted on (for an edit: the original). |
received_at | timestamptz | Its received_at (with message_id, finds it in its partition while it's kept). |
interface | text | Its interface. |
destination_id | integer | The destination (destinations.id). |
previous_status | smallint | The delivery's status before (as deliveries.status); none if the message had no delivery for that destination. |
previous_attempts | integer | Its attempts before (a resend starts them over). |
previous_error | text | Its last_error before. |
previous_output | bytea | What 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_id | bigint | edit: the copy's message id. |
audit_id | bigint | The 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.
| column | type | |
|---|---|---|
id | bigint | Identity. |
at | timestamptz | When. |
actor_kind | text | user, 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_id | integer | users.id or api_tokens.id, by actor_kind. |
user_id | integer | The person behind it: the user, or the owner of a user's token. What GET /me/activity reads. |
actor_name | text | The user's or token's name at the time (a snapshot). |
via | text | session (the web UI), token, cli, engine; none for rows made from message_actions when this table was added. |
ip | inet | The address the connection came from (a proxy's address behind one). |
action | text | session.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). |
subject | text | What it was done to: a username, a token name, interface/destination. |
interface | text | The interface it concerns. |
message_id | bigint | The message, when the request was about exactly one. |
outcome | text | ok, denied or failed. |
detail | jsonb | More, 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).
| column | type | |
|---|---|---|
interface | text | The interface's name. |
days | smallint | The tier its new messages go to (the target of a running change). |
changing_from | smallint | Set 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_left | bigint | Rows still to move or delete (counted when the change started, then counted down). |
waiting | smallint | A configured value not applied yet. |
confirmed | smallint | A shorter waiting value someone confirmed. |
changed_at | timestamptz | When the current value, or the running change, started. |
updated_at | timestamptz | Last change. |
purge_quarantined | boolean | quarantined: 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.
| column | type | |
|---|---|---|
instance | text | The run's name, as audit_log engine.start/engine.stop carry it in detail.instance. |
lock_key | bigint | Its advisory lock (positive, below 2^62: pg_locks shows it as classid × 2^32 + objid). |
host | text | The server it runs on. |
pid | integer | Its process id there. |
version | text | The engine's version. |
environment | text | --env. |
started_at | timestamptz | When it started. |
stopped_at | timestamptz | Set 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.
| column | type | |
|---|---|---|
interface | text | The interface's name. |
instance | text | engines.instance (deleted with it). |
since | timestamptz | When 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).
| column | type | |
|---|---|---|
interface | text | The interface's name. |
lanes | integer | Its lanes. |
updated_at | timestamptz | When 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).
| column | type | |
|---|---|---|
interface | text | The interface's name. |
watched_since | timestamptz | When an engine first ran the interface: active_by deadlines before it don't count. |
last_received | timestamptz | When a message (or a duplicate) last arrived. |
updated_at | timestamptz | Last change. |
Index: primary key interface.
destination_activity
Each destination's last send and failure (written like interface_activity), and its pause (written at once).
| column | type | |
|---|---|---|
destination_id | integer | destinations.id. |
last_sent | timestamptz | When a message was last delivered (or routed). |
last_error | text | The last failure: a retry's error or a quarantine's reason. |
last_error_at | timestamptz | When. |
paused_at | timestamptz | Set while the destination is paused on purpose (ductus pause): nothing is sent or retried. |
paused_by | text | The API token that paused it. |
pause_reason | text | Why, once someone says (often added after the pause). |
reason_by | text | The token that gave the reason. |
reason_at | timestamptz | When. |
baseline_inflow | real[] | 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_ms | real[] | The same for a successful send's mean time, in ms. |
updated_at | timestamptz | Last 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).
| column | type | |
|---|---|---|
kind | text | interface or resource. |
name | text | The interface's name, or the resource as api empi, database patient_db, sftp server lab. |
docs | jsonb | An 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_at | timestamptz | When 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.
| column | type | |
|---|---|---|
id | integer | Identity; the API's /alerts/{id}. |
key | text | iface:<interface>, dest:<interface>/<destination> or cert:<resource>:<host>:<port> (unique). |
interface | text | The interface; for a certificate, its resource (as in certificate_status). |
destination | text | The destination; none for the interface's own alert; host:port for a certificate. |
state | text | ok, or what is wrong: error, stalled, behind (a destination), stopped, silent, missed (an interface), certificate (expires within alerts.certificates.warn_days, or has expired). |
severity | text | warning or critical (from the first rule naming the state), while open. |
since | timestamptz | Bad since (when it fired). |
ok_since | timestamptz | Fine again since then, not yet for alerts.settle: bad again before that reopens the same alert. |
last_notified_at | timestamptz | When people were last told, or will be once quiet hours end; reminders count from it. |
notify_count | integer | Notifications sent since it fired. |
summary | text | The health view's words, as of the last check. |
ack_by | text | Who acknowledged it (no reminders until it escalates or recovers). |
ack_at | timestamptz | When. |
silenced_until | timestamptz | Nothing is sent about it until then. |
silenced_by | text | Who silenced it. |
silence_reason | text | Why. |
rev | bigint | Each change adds one (compare-and-set). |
updated_at | timestamptz | Last change. |
stage | integer | A 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.
| column | type | |
|---|---|---|
id | bigint | Identity. |
at | timestamptz | When. |
alert_id | integer | alert_state.id (no foreign key). |
key | text | The alert's key. |
kind | text | See above. |
state | text | The state then; for recovered, what it recovered from. |
severity | text | The severity then. |
since | timestamptz | When the alert turned bad, for the words of a notification sent later. |
summary | text | The health view's words then. |
by_name | text | Who acknowledged or silenced it. |
detail | jsonb | For 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).
| column | type | |
|---|---|---|
id | bigint | Identity. |
event_id | bigint | alert_events.id (deleted with it). |
channel | text | A channel's name; <name>/email and <name>/webhook for a channel with both; email:<address> for an owner. |
status | text | pending, sent, failed (given up after 10 tries, about six hours), cancelled (it recovered before quiet hours ended). |
attempts | integer | Tries so far. |
last_error | text | Why the last try failed. |
next_try_at | timestamptz | Due then (after quiet hours, or a back-off of 30 s doubling to an hour); while claimed, the end of the lease. |
sent_at | timestamptz | When 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.
| column | type | |
|---|---|---|
side | text | remote (a server the engine connects to) or ours (a pair it presents; migration 0024). Older engines' rows are remote. |
resource | text | empi (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. |
host | text | The server's name as configured; ours: the engine's server (its host name). |
port | integer | Its port; ours: 0 (one pair may be served on several ports). |
subject | text | The server certificate's common name (or organisation). |
issuer | text | Who issued it. |
fingerprint | text | SHA-256 of the server certificate, hex: changes when it's renewed or replaced. |
not_before | timestamptz | Valid from. |
not_after | timestamptz | The server certificate's expiry. |
chain_min_not_after | timestamptz | The earliest expiry in the chain the server presents: what alerts count (an intermediate that ends breaks clients as surely). |
chain_min_subject | text | Whose that is, when it isn't the server certificate's. |
trusted | boolean | Whether this server trusts it (the system's trusted certificates); none for ours (whether senders do isn't known here). |
error | text | Why 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_at | timestamptz | The last check; ours: when the engine last read the pair (its start, or a renewal). |
read_at | timestamptz | When 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.
| column | type | |
|---|---|---|
id | bigint | Identity. |
at | timestamptz | When it ran. |
mode | text | certificates (DNS, TCP, TLS handshake: nothing logs in) or full (ductus doctor's checks, logins included). |
started_by | text | schedule, or who asked (a user or token name). |
seconds | double precision | How long it took. |
checks | integer | Connections checked. |
failed | integer | Of those, failed. |
report | jsonb | The 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 (dayfor 7, elseweek).ductus_create_tier_partition(table, tier, day): the partition ofmessagesordeliveriesholding that tier's rows of that day, created if missing.ductus_create_held_partition(table, day): the held area's partition ofmessagesordeliveriesfor 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: every
ductuscommand and option. - REST API: every operation under
/api/v1, who may call it, and what it takes and returns. - Configuration files: every setting of
interface.yaml,resources.yaml,ductus.yaml, the environment files anddocs-schema.yaml. - Event Log IDs: what the Windows service writes to the Event Log.
- Prometheus metrics: what
GET /api/v1/metricsserves. - Audit actions: what the audit log records.
- Libraries and versions: what each build contains.
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).
| Command | What it does |
|---|---|
ductus init | Start a new project: settings, environments, resources and a first interface, ready for ductus test. |
ductus run | Run every interface in a project. |
ductus dev | Run the project with a Postgres of its own, for local development: nothing to install. |
ductus test | Run each interface's *.test.ts files. |
ductus validate | Check configs, code loading and TypeScript types for an environment (exits 1 on problems). |
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. |
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. |
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. |
ductus new | Create something new in a project. |
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 impact | Which interfaces a vendor, application or host touches, how, and who owns them: for a vendor breach or a VPN/IP change. |
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. |
ductus docs | The company-defined documentation fields (docs-schema.yaml) and their values (custom:) for every interface, destination and resource. |
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 user | Manage the people who sign in (in the engine's database; that is how the first admin is made). |
ductus token | Manage REST API tokens (in the engine's database). |
ductus audit | The audit log (who did what), straight from the engine's database. |
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. |
ductus resume | Take new messages again after ductus drain; or, with an interface, resume its paused destination (it sends its backlog in order). |
ductus pause | Pause a destination at once: nothing is sent or retried until ductus resume <interface> [destination]; messages keep queueing. |
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. |
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. |
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 secret | Manage encrypted secrets (secrets/ |
ductus cert | Certificates for tls: on listeners (certificates: in resources.yaml). |
ductus service | The Windows service (as an administrator). |
ductus api-spec | Print the REST API's OpenAPI document (the same as GET /api/v1/openapi.json, without a running engine). |
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 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 gen | Send synthetic ADT messages over MLLP. |
ductus sink | Receive messages over MLLP, ACK them, and verify per-patient ordering. |
Options for every command
| What it does | Default | Environment | |
|---|---|---|---|
--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 does | Default | Environment | |
|---|---|---|---|
<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 does | Default | Environment | |
|---|---|---|---|
--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. | 32 | DUCTUS_DB_CONNECTIONS |
--env <ENV> | Environment: variables from environments/ | dev | DUCTUS_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. | 30s | DUCTUS_STOP_TIMEOUT |
--service | Run 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 does | Default | Environment | |
|---|---|---|---|
--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/ | dev | DUCTUS_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. | 30s | DUCTUS_STOP_TIMEOUT |
--port <PORT> | Postgres's port (default: the one kept from the last run, else a free one). | ||
--reset | Delete this project's dev database and start again. | ||
--yes | Don't ask before --reset deletes it. | ||
--database-only | Start 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 does | Default | Environment | |
|---|---|---|---|
--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/${secret:NAME} from secrets/ | dev | DUCTUS_ENV |
ductus validate
Check configs, code loading and TypeScript types for an environment (exits 1 on problems).
ductus validate [OPTIONS]
Options
| What it does | Default | Environment | |
|---|---|---|---|
--root <ROOT> | The project folder: the one with interfaces/ and ductus.yaml. | . | |
--env <ENV> | The environment: ${NAME} values come from environments/${secret:NAME} from secrets/ | dev | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
--check | Check, change nothing (the only mode for now). | ||
--json | One 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/${secret:NAME} from secrets/ | dev | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
--root <ROOT> | The project folder: the one with interfaces/ and ductus.yaml. | . | DUCTUS_PROJECT |
--env <ENV> | The environment: ${NAME} values come from environments/${secret:NAME} from secrets/ | dev | DUCTUS_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). | ||
--json | The report as JSON on stdout instead of text. | ||
--hide-values | Leave 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 does | Default | Environment | |
|---|---|---|---|
--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-environment | Start 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 does | Default | Environment | |
|---|---|---|---|
<NAME> | Folder name and interface name, e.g. adt-to-lab. |
Options
| What it does | Default | Environment | |
|---|---|---|---|
--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 does | Default | Environment | |
|---|---|---|---|
--root <ROOT> | The project folder: the one with interfaces/ and ductus.yaml. | . | |
--env <ENV> | The environment: ${NAME} values come from environments/${secret:NAME} from secrets/ | dev | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
--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. | ||
--resolve | Look 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/${secret:NAME} from secrets/ | dev | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
--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/${secret:NAME} from secrets/ | dev | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
--missing | Only what's missing. | ||
--json | As 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/${secret:NAME} from secrets/ | dev | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
--check | Fail (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/${secret:NAME} from secrets/ | dev | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
--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-run | Show 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/${secret:NAME} from secrets/ | dev | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
<USERNAME> | The name they sign in with. |
Options
| What it does | Default | Environment | |
|---|---|---|---|
--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-stdin | Read the password from stdin (one line) instead of asking. | ||
--must-change | The 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 does | Default | Environment | |
|---|---|---|---|
<USERNAME> | The account's sign-in name. |
Options
| What it does | Default | Environment | |
|---|---|---|---|
--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 does | Default | Environment | |
|---|---|---|---|
<USERNAME> | The account's sign-in name. |
Options
| What it does | Default | Environment | |
|---|---|---|---|
--password-stdin | Read 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 does | Default | Environment | |
|---|---|---|---|
<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 does | Default | Environment | |
|---|---|---|---|
<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 does | Default | Environment | |
|---|---|---|---|
<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 does | Default | Environment | |
|---|---|---|---|
<NAME> | Who or what uses it, e.g. ci or monitoring. |
Options
| What it does | Default | Environment | |
|---|---|---|---|
--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 does | Default | Environment | |
|---|---|---|---|
--revoked | Include revoked tokens. |
ductus token revoke
Revoke a token: it stops working at once.
ductus token revoke [OPTIONS] <NAME>
Arguments
| What it does | Default | Environment | |
|---|---|---|---|
<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 does | Default | Environment | |
|---|---|---|---|
--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 does | Default | Environment | |
|---|---|---|---|
--stop | Exit the engine once nothing is pending. | ||
--wait | Wait until drained, showing what's left. | ||
--api <API> | The engine's REST API. | http://127.0.0.1:7700 | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
<INTERFACE> | Resume this interface's paused destination (instead of ending a drain). | ||
<DESTINATION> | Which destination (needed when the interface has several). |
Options
| What it does | Default | Environment | |
|---|---|---|---|
--api <API> | The engine's REST API. | http://127.0.0.1:7700 | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
<INTERFACE> | The interface whose destination to pause. | ||
<DESTINATION> | Which destination (needed when the interface has several). |
Options
| What it does | Default | Environment | |
|---|---|---|---|
--reason <REASON> | Why (can be added later). | ||
--api <API> | The engine's REST API. | http://127.0.0.1:7700 | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
--api <API> | The engine's REST API. | http://127.0.0.1:7700 | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
<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 does | Default | Environment | |
|---|---|---|---|
<INTERFACE> | Only this interface. |
Options
| What it does | Default | Environment | |
|---|---|---|---|
--lanes <LANES> | Lanes to list per destination, most waiting first (default 10). | ||
--api <API> | The engine's REST API. | http://127.0.0.1:7700 | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
--api <API> | The engine's REST API. | http://127.0.0.1:7700 | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
<CHANNEL> | A channel's name under alerts.channels in ductus.yaml. |
Options
| What it does | Default | Environment | |
|---|---|---|---|
--root <ROOT> | The project folder: the one with interfaces/ and ductus.yaml. | . | |
--env <ENV> | The environment: ${NAME} values come from environments/${secret:NAME} from secrets/ | dev | DUCTUS_ENV |
ductus secret
Manage encrypted secrets (secrets/
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 does | Default | Environment | |
|---|---|---|---|
<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 does | Default | Environment | |
|---|---|---|---|
--root <ROOT> | The project folder: the one with interfaces/ and ductus.yaml. | . | |
--env <ENV> | The environment: ${NAME} values come from environments/${secret:NAME} from secrets/ | dev | DUCTUS_ENV |
ductus secret list
List secret names (never values).
ductus secret list [OPTIONS]
Options
| What it does | Default | Environment | |
|---|---|---|---|
--root <ROOT> | The project folder: the one with interfaces/ and ductus.yaml. | . | |
--env <ENV> | The environment: ${NAME} values come from environments/${secret:NAME} from secrets/ | dev | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
<OUT> | Where to write, without the extension: tls/mllp writes tls/mllp.key and tls/mllp.csr. |
Options
| What it does | Default | Environment | |
|---|---|---|---|
--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 does | Default | Environment | |
|---|---|---|---|
<TARGET> | A PEM file, or the name of a certificates: entry in resources.yaml. |
Options
| What it does | Default | Environment | |
|---|---|---|---|
--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/${secret:NAME} from secrets/ | dev | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
<OUT> | Where to write, without the extension: tls/engine writes tls/engine.pem and tls/engine.key. |
Options
| What it does | Default | Environment | |
|---|---|---|---|
--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 does | Default | Environment | |
|---|---|---|---|
<NAME> | The account: DOMAIN\name, a gMSA (DOMAIN\name$), or none to show the current one. |
Options
| What it does | Default | Environment | |
|---|---|---|---|
--password-stdin | Read the password from stdin (one line) instead of asking. | ||
--start | Start 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 does | Default | Environment | |
|---|---|---|---|
--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 does | Default | Environment | |
|---|---|---|---|
--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:7700 | DUCTUS_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 does | Default | Environment | |
|---|---|---|---|
--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 | |
--tls | Send 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 does | Default | Environment | |
|---|---|---|---|
--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. | ||
--tls | Take 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:
| public | anyone, without signing in (/health, for load balancers; signing in) |
| signed in | any person or token |
| a person | a person signed in with a password (their own account's settings) |
| operator | the operator or admin role: changes such as resend, pause, drain |
| admin | the admin role: accounts and tokens |
| audit | the 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).
| Part | What it covers |
|---|---|
| Engine | The engine, its interfaces and live updates. |
| Session | Signing a browser in and out. |
| Your account | The signed-in user's own account: password, sessions, tokens, appearance. |
| Users | People who sign in (admin). |
| Messages | The message browser, the quarantine, and resending or correcting messages. |
| Drain | Stop taking messages and deliver what's stored. |
| Reports | Impact, owners and connection checks. |
| Alerts | Alerts: what is wrong, acknowledging and silencing, and where notifications go. |
| Audit | Who did what: the audit log (the audit permission) and your own activity. |
| Retention | How long messages are kept, and changes to it. |
| Tokens | API 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
200(application/json):EngineInfo
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
200text/event-streamofupdateevents (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
| In | Type | ||
|---|---|---|---|
interface | query | text | Only this interface's destinations. |
lanes | query | number | Lanes 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
200(application/json): list ofDestinationHotspots404(application/json):ErrorBody
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
200(application/json): list ofInterfaceStatus
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
| In | Type | ||
|---|---|---|---|
name (required) | path | text | Interface name. |
Answers
200(application/json):InterfaceDetail404(application/json):ErrorBody
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
| In | Type | ||
|---|---|---|---|
name (required) | path | text | Interface name. |
destination (required) | path | text | Destination name (default for one given with destination:). |
Body (application/json): PauseRequest
Answers
200(application/json):PauseStatus403(application/json):ErrorBody404(application/json):ErrorBody
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
| In | Type | ||
|---|---|---|---|
name (required) | path | text | Interface name. |
destination (required) | path | text | Destination name. |
Answers
200(application/json):PauseStatus403(application/json):ErrorBody404(application/json):ErrorBody
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
200Prometheus text format (0.0.4) (text/plain): text
Shapes
The JSON bodies the operations above take and return.
AllowedName
| Field | Type | |
|---|---|---|
addresses | list of text | The addresses let in for it: the last lookup that found some (kept when a later one fails). |
checked_at (optional) | time (RFC 3339) or null | When it was last looked up. |
error (optional) | text or null | Why the last lookup failed, if it did. |
name | text |
DestinationCounts
| Field | Type | |
|---|---|---|
filtered | number | |
latency | Timing | From receipt to sent, waiting in the queue included. |
quarantined | number | |
retries | number | Temporary failures (each retry counts). |
send | Timing | Each successful send (the receiver's time). |
sent | number | |
transform | Timing | The destination's function. |
DestinationHealth
| Field | Type | |
|---|---|---|
behind (optional) | text or null | Which behind limit is past: messages or age. |
catch_up_s (optional) | number or null | Seconds to catch up at the last few minutes' rates (only while catching up). |
causes | list of text | Why 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 null | When it last delivered (kept across restarts). |
normal | Rates | Normal for this hour of the week, learned from the hours watched (none until then). |
oldest_waiting_s (optional) | number or null | Seconds the oldest waiting message has waited. |
recent (optional) | Rates or null | |
state | text | ok, behind (past its behind limit), error (has quarantined messages), stalled (messages wait, nothing finished for a minute) or paused (best to worst). |
summary | text | In operations' words: "behind 1,240 (oldest 12 min), catching up ~6 min; burst: 400/min received (normal 80)". |
DestinationHotspots
| Field | Type | |
|---|---|---|
destination | text | |
findings | list of FindingInfo | |
interface | text | |
lanes | list of LaneInfo | The lanes with the most waiting, plus any blocked or unread one. |
lanes_total | number | Lanes the interface has (ordering.partitions; 1 without an ordering key). |
lanes_waiting | number | |
paused | true or false | |
summary | text | In operations' words: "1,240 waiting in 3 of 64 lanes; worker 2 blocked by message ...". |
waiting | number | |
workers | list of WorkerInfo | Workers with something waiting. |
workers_total | number | Workers each destination has (1 in the shared pool). |
DestinationStatus
| Field | Type | |
|---|---|---|
connector | text | Where it sends: mllp lab.local:5000, http api billing /charges, file out. |
health | DestinationHealth | Its state and why: behind, stalled, errors, and how it compares with normal. |
name | text | |
owner (optional) | text or null | Set when it isn't the interface's owner. |
paused (optional) | PauseInfo or null | |
pending | number | Waiting to be delivered. |
quarantined | number | Needing a person. |
since_start | DestinationCounts | Counts and timings since the engine started. |
DrainStatus
| Field | Type | |
|---|---|---|
paused | list of text | Paused destinations (interface/destination): they keep their messages and don't hold the drain up. |
pending | number | Deliveries still to go, over all interfaces. |
since (optional) | time (RFC 3339) or null | When draining started, or when it finished. |
state | text | accepting, draining or drained. |
stop_when_drained | true or false | The engine exits once drained. |
waiting_for | list of text | Interfaces still holding pending deliveries (a destination that's down, or a stopped interface, keeps a drain from finishing). |
EngineInfo
| Field | Type | |
|---|---|---|
drain | DrainStatus | |
environment | text | |
handover (optional) | Handover or null | |
holds_interfaces | true or false | False 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). |
host | text | The server's name. |
interfaces | number | |
others | list of OtherEngine | Other engines running on this database, oldest first, and the interfaces each runs. An interface runs on one engine at a time. |
resources | list of ResourceCalls | Calls from interface code to each database and API in resources.yaml (fetch: URLs called directly), since the engine started. |
started_at | time (RFC 3339) | |
stopped | number | Interfaces stopped because their code ran away (their messages wait). |
uptime_seconds | number | |
version | text |
ErrorBody
| Field | Type | |
|---|---|---|
error | text | What went wrong, in plain words. |
FindingInfo
| Field | Type | |
|---|---|---|
kind | text | blocked (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 null | Its share of the backlog (0–1), for uneven and hot_lane. |
text | text | |
worker (optional) | number or null |
Handover
| Field | Type | |
|---|---|---|
at | time (RFC 3339) | When it held the interfaces. |
from_pid | number | |
from_version | text | The version it took over from. |
sending_paused_ms | number | From 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.
| Field | Type | |
|---|---|---|
attempts | number | Failed attempts: above zero, its worker is retrying it and its lanes wait. |
id | number | The 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_at | time (RFC 3339) | |
waiting_s | number |
Health
| Field | Type | |
|---|---|---|
drain | text | accepting, draining or drained: a load balancer can stop sending when it isn't accepting. |
status | text | Always ok when the engine answers. |
InterfaceCounts
| Field | Type | |
|---|---|---|
duplicates | number | Stored as duplicates, not queued. |
received | number | Stored and queued. |
request_errors (optional) | number or null | |
requests (optional) | Timing or null | |
store_failures | number | Couldn't be stored (the sender was told to retry). |
InterfaceDetail
InterfaceStatus or object
InterfaceHealth
| Field | Type | |
|---|---|---|
last_received (optional) | time (RFC 3339) or null | When something last arrived (kept across restarts). |
state | text | ok, silent (nothing within active_within), missed (nothing by active_by) or stopped (best to worst). |
summary | text | In operations' words: "silent: nothing received for 47 min". |
InterfaceStatus
| Field | Type | |
|---|---|---|
accepting | true or false | False while draining. |
description (optional) | text or null | |
destinations | list of DestinationStatus | |
health | InterfaceHealth | What it should have received (monitor: in interface.yaml), checked now. |
isolation | text | shared or dedicated. |
name | text | |
owner (optional) | text or null | |
retention_days (optional) | number or null | Days its messages are kept, as configured (none in respond mode: it stores nothing). GET /retention has changes in progress. |
since_start | InterfaceCounts | Counts since the engine started (they start at zero with it). |
source | text | Where messages come from: mllp 0.0.0.0:6661, http POST /orders on 0.0.0.0:8080 (queue). |
state | text | running, or stopped (its code ran away: its messages wait until it's fixed and the engine restarted). |
stopped_reason (optional) | text or null | |
workers | number | Parallel workers (dedicated) or 1 (shared). |
LaneInfo
| Field | Type | |
|---|---|---|
head | HeadInfo | |
lane | number | |
waiting | number | |
worker (optional) | number or null | None: no worker reads it (ordering.partitions was lowered while it had messages). |
LastError
| Field | Type | |
|---|---|---|
at | time (RFC 3339) | |
error | text |
LiveDestination
| Field | Type | |
|---|---|---|
health | LiveHealth | As in GET /interfaces (an update is sent when a state changes, not as a summary's minutes tick on). |
name | text | |
paused | true or false | |
pending | number | |
quarantined | number |
LiveHealth
| Field | Type | |
|---|---|---|
state | text | |
summary | text |
LiveInterface
| Field | Type | |
|---|---|---|
accepting | true or false | |
destinations | list of LiveDestination | |
health | LiveHealth | What it receives, as in GET /interfaces. |
name | text | |
state | text |
LiveUpdate
| Field | Type | |
|---|---|---|
at | time (RFC 3339) | |
drain | DrainStatus | |
interfaces | list of LiveInterface |
OtherEngine
| Field | Type | |
|---|---|---|
environment | text | |
host | text | |
interfaces | list of text | The interfaces it runs (respond-mode HTTP interfaces aren't claimed, so not listed). |
pid | number | |
started_at | time (RFC 3339) | |
version | text |
PauseInfo
| Field | Type | |
|---|---|---|
at | time (RFC 3339) | |
by | text | The token that paused it. |
reason (optional) | text or null | Why, 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
| Field | Type | |
|---|---|---|
reason (optional) | text or null | Why (optional: pause first, explain after; pausing again with a reason adds it). |
PauseStatus
| Field | Type | |
|---|---|---|
destination | text | |
interface | text | |
paused (optional) | PauseInfo or null | |
pending | number | Messages waiting for it. |
Rates
| Field | Type | |
|---|---|---|
done_per_min (optional) | number or null | Messages finished per minute (sent, filtered or quarantined). |
received_per_min (optional) | number or null | Messages arriving per minute. |
send_ms (optional) | number or null | A successful send's mean time. |
ResourceCalls
| Field | Type | |
|---|---|---|
calls | Timing | |
errors | number | Calls that failed (network, SQL, timeout; an HTTP error status is an answer, not a failure). |
kind | text | db, api or fetch. |
name | text |
SourceAllow
A listener's allow: list and how it's doing.
| Field | Type | |
|---|---|---|
entries | list of text | The list as written: addresses, subnets and host names. |
last_refused (optional) | text or null | The last refused connection's address and time. |
last_refused_at (optional) | time (RFC 3339) or null | |
names | list of AllowedName | Each host name in it with what it resolves to now. |
refused | number | Connections refused since the engine started (0 when this engine doesn't hold the listener). |
SourceTls
TLS on an interface's listener.
| Field | Type | |
|---|---|---|
address | text | Where: 0.0.0.0:6661, POST /orders on 0.0.0.0:8443 (queue). |
certificate | text | The certificates: pair it presents. |
clients_ca (optional) | text or null | Senders need a certificate this CA (a certificates: entry) issued; none: any sender. |
protocol | text | MLLP over TLS or HTTPS. |
subjects | list of text | And one of these subjects (none: any the CA issued). |
SystemInfo
| Field | Type | |
|---|---|---|
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).
| Field | Type | |
|---|---|---|
count | number | |
mean_ms (optional) | number or null | |
p50_ms (optional) | number or null | |
p95_ms (optional) | number or null |
WorkerInfo
| Field | Type | |
|---|---|---|
blocked_by (optional) | number or null | The lane whose first message it keeps retrying. |
lanes_waiting | number | |
waiting | number | |
worker | number |
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
200(application/json):Session401(application/json):ErrorBody403(application/json):ErrorBody429(application/json):ErrorBody
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
| Field | Type | |
|---|---|---|
error | text | What went wrong, in plain words. |
Session
| Field | Type | |
|---|---|---|
ends_at (optional) | time (RFC 3339) or null | When 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. |
kind | text | user (signed in with a password) or token. |
must_change_password | true or false | An admin chose this password: nothing else works until it is changed (POST /me/password). |
name | text | The username, or the token's name. |
permissions | list of text | What the role doesn't say: payloads, audit. Admins have both. |
role | text | viewer, operator or admin. |
SignIn
| Field | Type | |
|---|---|---|
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 null | A 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
| In | Type | ||
|---|---|---|---|
before | query | number | Entries older than this id. |
limit | query | number | At 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
200(application/json): list ofSessionInfo403(application/json):ErrorBody
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
204403(application/json):ErrorBody
DELETE /api/v1/me/sessions/{id}
End one of your sessions.
Who may call it: a person signed in with a password.
Parameters
| In | Type | ||
|---|---|---|---|
id (required) | path | number | Session 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
200(application/json):Appearance403(application/json):ErrorBody
PUT /api/v1/me/settings
Save your appearance.
Who may call it: a person signed in with a password.
Body (application/json): Appearance
Answers
200(application/json):Appearance400(application/json):ErrorBody403(application/json):ErrorBody
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
200(application/json):CreatedToken400(application/json):ErrorBody403(application/json):ErrorBody
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
| In | Type | ||
|---|---|---|---|
name (required) | path | text | Token name. |
Answers
Shapes
The JSON bodies the operations above take and return.
Appearance
| Field | Type | |
|---|---|---|
colors (optional) | object or null | The custom colors, as the UI saves them (a JSON object). |
saved (optional) | true or false | Whether this was saved before: false means the UI may offer the browser's own choice. |
theme | text | system, light, dark, contrast or custom. |
AuditEntry
| Field | Type | |
|---|---|---|
action | text | message.payload.read, message.dismiss, user.create, ... |
actor | text | The user's or token's name when it happened. |
actor_kind | text | user, token, system (the engine, or someone at the server's command line) or anonymous (a sign-in as a name nobody has). |
at | time (RFC 3339) | |
detail (optional) | object or null | More about it, by action (never a password, token or payload). |
id | number | |
interface (optional) | text or null | |
ip (optional) | text or null | The address the request came from (a proxy's, if there is one). |
message (optional) | number or null | |
outcome | text | ok, denied or failed. |
subject (optional) | text or null | What it was done to: a username, a token, interface/destination. |
via (optional) | text or null | session (the web UI), token, cli or engine; none for entries from before the log. |
AuditPage
| Field | Type | |
|---|---|---|
entries | list of AuditEntry | Newest first. |
next_before (optional) | number or null | Pass as before for the next (older) page; absent on the last page. |
CreatedToken
| Field | Type | |
|---|---|---|
secret | text | The token itself: shown this once, never stored (only its hash is). |
token | TokenInfo |
ErrorBody
| Field | Type | |
|---|---|---|
error | text | What went wrong, in plain words. |
NewMyToken
| Field | Type | |
|---|---|---|
name | text | What it is for, e.g. deploy-script; it is listed as <you>/<name>. |
permissions (optional) | list of text or null | Defaults to none; never more than you have. |
role (optional) | text or null | Defaults to your own role; never above it. |
PasswordChange
| Field | Type | |
|---|---|---|
current | text | The password you have now. |
new | text |
ProfileUpdate
| Field | Type | |
|---|---|---|
display_name (optional) | text or null | |
email (optional) | text or null |
SessionInfo
| Field | Type | |
|---|---|---|
created_at | time (RFC 3339) | |
current | true or false | The one making this request. |
expires_at | time (RFC 3339) | |
id | number | |
last_seen_at | time (RFC 3339) |
TokenInfo
| Field | Type | |
|---|---|---|
created_at | time (RFC 3339) | |
last_used_at (optional) | time (RFC 3339) or null | To the minute. |
name | text | |
owner (optional) | text or null | The user it belongs to; none for a service token made by an admin. |
permissions | list of text | payloads, audit (admins' tokens have all). |
role | text | viewer, operator or admin. |
UserInfo
| Field | Type | |
|---|---|---|
created_at | time (RFC 3339) | |
directory_checked_at (optional) | time (RFC 3339) or null | A directory account: when the directory was last asked about them (sign-in or refresh). |
directory_refused | true or false | A 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. |
disabled | true or false | |
display_name | text | |
email | text | |
id | number | |
last_sign_in_at (optional) | time (RFC 3339) or null | |
locked_until (optional) | time (RFC 3339) or null | Refused after too many wrong passwords, until then. |
made_for_owner | true or false | A 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_password | true or false | An admin chose the password; the next sign-in can only change it. |
permissions | list of text | payloads, audit (admins have both whatever is listed). |
role | text | viewer, operator or admin. |
source | text | Where the password is checked: local, or a directory. |
username | text |
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
200(application/json):UserInfo400(application/json):ErrorBody403(application/json):ErrorBody
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
| In | Type | ||
|---|---|---|---|
id (required) | path | number | User id. |
Body (application/json): UserUpdate
Answers
200(application/json):UserInfo400(application/json):ErrorBody403(application/json):ErrorBody404(application/json):ErrorBody
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
| In | Type | ||
|---|---|---|---|
id (required) | path | number | User id. |
Body (application/json): PasswordReset
Answers
204400(application/json):ErrorBody403(application/json):ErrorBody404(application/json):ErrorBody
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
| In | Type | ||
|---|---|---|---|
id (required) | path | number | User id. |
Answers
Shapes
The JSON bodies the operations above take and return.
ErrorBody
| Field | Type | |
|---|---|---|
error | text | What went wrong, in plain words. |
NewUserRequest
| Field | Type | |
|---|---|---|
display_name (optional) | text or null | |
email (optional) | text or null | |
password | text | A temporary password: the person must change it at their first sign-in. |
permissions (optional) | list of text or null | payloads and/or audit. |
role (optional) | text or null | viewer (default), operator or admin. |
username | text |
PasswordReset
| Field | Type | |
|---|---|---|
password | text | The temporary password; the person must change it at their next sign-in. |
UserInfo
| Field | Type | |
|---|---|---|
created_at | time (RFC 3339) | |
directory_checked_at (optional) | time (RFC 3339) or null | A directory account: when the directory was last asked about them (sign-in or refresh). |
directory_refused | true or false | A 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. |
disabled | true or false | |
display_name | text | |
email | text | |
id | number | |
last_sign_in_at (optional) | time (RFC 3339) or null | |
locked_until (optional) | time (RFC 3339) or null | Refused after too many wrong passwords, until then. |
made_for_owner | true or false | A 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_password | true or false | An admin chose the password; the next sign-in can only change it. |
permissions | list of text | payloads, audit (admins have both whatever is listed). |
role | text | viewer, operator or admin. |
source | text | Where the password is checked: local, or a directory. |
username | text |
UserUpdate
| Field | Type | |
|---|---|---|
disabled (optional) | true or false or null | Disabled 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
| In | Type | ||
|---|---|---|---|
name (required) | path | text | Interface name. |
status | query | text | Only messages with this overall status. |
before | query | number | Only messages older than this id (the previous page's next_before). |
limit | query | number | Page size, at most 500 (default 50). |
Answers
200(application/json):MessagePage400(application/json):ErrorBody404(application/json):ErrorBody
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
200(application/json):DismissResult400(application/json):ErrorBody403(application/json):ErrorBody
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
200(application/json):BulkResendResult400(application/json):ErrorBody403(application/json):ErrorBody
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
| In | Type | ||
|---|---|---|---|
id (required) | path | number | Message id. |
Answers
200(application/json):MessageDetail404(application/json):ErrorBody
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
| In | Type | ||
|---|---|---|---|
id (required) | path | number | Message id. |
Body (application/json): DismissRequest
Answers
200(application/json):DismissResult400(application/json):ErrorBody403(application/json):ErrorBody404(application/json):ErrorBody409Nothing quarantined to dismiss (application/json):ErrorBody
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
| In | Type | ||
|---|---|---|---|
id (required) | path | number | The message to correct. |
Body (application/json): EditRequest
Answers
201(application/json):EditResult400(application/json):ErrorBody403(application/json):ErrorBody404(application/json):ErrorBody409Still pending for a destination (application/json):ErrorBody
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
| In | Type | ||
|---|---|---|---|
id (required) | path | number | Message id. |
Body (application/json): ResendRequest
Answers
200(application/json):ResentMessage400(application/json):ErrorBody403(application/json):ErrorBody404(application/json):ErrorBody409Nothing to resend (application/json):ErrorBody
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
| In | Type | ||
|---|---|---|---|
interface | query | text | Only this interface's. |
destination | query | text | Only this destination's (with interface, that interface's; without, any interface's destination of that name). |
before | query | text | The previous page's next_before. |
limit | query | number | Page size, at most 500 (default 100). |
Answers
200(application/json):QuarantinePage400(application/json):ErrorBody404(application/json):ErrorBody
Shapes
The JSON bodies the operations above take and return.
ActionInfo
| Field | Type | |
|---|---|---|
action | text | resend (the delivery went back to pending), edit (a corrected copy was queued) or dismiss (a quarantined delivery was closed without sending). |
at | time (RFC 3339) | |
by | text | The user or token that did it. |
copy (optional) | number or null | edit: the copy's id. |
destination | text | |
previous_attempts (optional) | number or null | |
previous_error (optional) | text or null | |
previous_status (optional) | text or null | The delivery before: sent, filtered or quarantined (none: there was none). |
reason (optional) | text or null |
BulkDismissRequest
| Field | Type | |
|---|---|---|
destination (optional) | text or null | Only the destination with this name in each message's interface. Without it: all quarantined deliveries of each message. |
ids | list of number | Message ids (at most 500), from any interfaces. |
reason | text | Why. Required; kept with every dismissal and in the audit log. |
BulkResendRequest
| Field | Type | |
|---|---|---|
destination (optional) | text or null | Resend 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. |
ids | list of number | Message ids (at most 500), from any interfaces. |
quarantined_only (optional) | true or false or null | Only 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 null | Why (kept with each action). |
BulkResendResult
| Field | Type | |
|---|---|---|
resent | list of ResentMessage | The messages sent again, by id. |
skipped | list of number | Ids with nothing to resend: not found, not run by this engine, a duplicate, or no delivery that matched (pending already, or not quarantined). |
DeliveryInfo
| Field | Type | |
|---|---|---|
attempts | number | |
destination | text | |
last_error (optional) | text or null | |
output (optional) | text or null | What was sent, for destinations with save_output: true. |
status | text | pending, sent, filtered or quarantined. |
DismissRequest
| Field | Type | |
|---|---|---|
destinations (optional) | list of text or null | Dismiss only these destinations' quarantined deliveries. Without it: all of the message's. |
reason | text | Why nobody will fix or resend these. Required: it is kept in the audit log. |
DismissResult
| Field | Type | |
|---|---|---|
dismissed | list of ResentMessage | The messages with a delivery dismissed (previous_status is quarantined; previous_error is why it was). |
skipped | list of number | Ids with nothing to dismiss: not found, not run by this engine, or no delivery still quarantined (someone resolved it meanwhile). |
EditRequest
| Field | Type | |
|---|---|---|
destinations (optional) | list of text or null | Where the copy goes. Without it: the destinations that quarantined the original. |
payload | text | The corrected message, in full (HL7 segments separated by \r). |
reason (optional) | text or null | Why (kept with the action). |
EditResult
| Field | Type | |
|---|---|---|
destinations | list of text | Where the copy goes. |
id | number | The copy's id (a new message, queued at the back of its lane). |
interface | text | |
original | number | The message it corrects. |
replaced | list of text | Destinations whose quarantined delivery of the original is now filtered ("replaced by edited copy N"). |
ErrorBody
| Field | Type | |
|---|---|---|
error | text | What went wrong, in plain words. |
MessageDetail
MessageSummary or object
MessagePage
| Field | Type | |
|---|---|---|
messages | list of MessageSummary | Newest first. |
next_before (optional) | number or null | Pass as before for the next (older) page; absent on the last page. |
MessageSummary
| Field | Type | |
|---|---|---|
control_id (optional) | text or null | MSH-10 for HL7. |
id | number | |
interface | text | |
last_error (optional) | text or null | Why it was quarantined or filtered (per destination when there are several). |
ordering_key (optional) | text or null | |
origin (optional) | number or null | Routed from another interface: the id of the message there (GET /messages/{origin}). |
received_at | time (RFC 3339) | |
size | number | Payload size in bytes. |
status | text | pending, sent, filtered, quarantined or duplicate: the overall status (pending until every destination is done). |
updated_at | time (RFC 3339) |
QuarantinePage
| Field | Type | |
|---|---|---|
deliveries | list of QuarantinedDelivery | Newest message first; a message quarantined by two destinations is listed twice. |
next_before (optional) | text or null | Pass as before for the next page; absent on the last page. |
QuarantinedDelivery
| Field | Type | |
|---|---|---|
attempts | number | |
control_id (optional) | text or null | MSH-10 for HL7. |
destination | text | |
id | number | The message's id. |
interface | text | |
last_error (optional) | text or null | Why. |
ordering_key (optional) | text or null | |
quarantined_at | time (RFC 3339) | |
received_at | time (RFC 3339) | |
size | number | Payload size in bytes. |
ResendRequest
| Field | Type | |
|---|---|---|
destinations (optional) | list of text or null | Resend 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 null | Why (kept with the action). |
ResentDelivery
| Field | Type | |
|---|---|---|
destination | text | |
previous_attempts | number | |
previous_error (optional) | text or null | |
previous_status | text | What the delivery was before: sent, filtered or quarantined. |
ResentMessage
| Field | Type | |
|---|---|---|
destinations | list of ResentDelivery | The deliveries now pending again. |
id | number | |
interface | text |
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
200(application/json):DrainStatus
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
200(application/json):DrainStatus403(application/json):ErrorBody
DELETE /api/v1/drain
Stop draining: take messages again. Operator.
Who may call it: an operator or admin.
Answers
200(application/json):DrainStatus403(application/json):ErrorBody
Shapes
The JSON bodies the operations above take and return.
DrainRequest
| Field | Type | |
|---|---|---|
stop (optional) | true or false | Exit the engine once drained (for an upgrade or a server move). |
DrainStatus
| Field | Type | |
|---|---|---|
paused | list of text | Paused destinations (interface/destination): they keep their messages and don't hold the drain up. |
pending | number | Deliveries still to go, over all interfaces. |
since (optional) | time (RFC 3339) or null | When draining started, or when it finished. |
state | text | accepting, draining or drained. |
stop_when_drained | true or false | The engine exits once drained. |
waiting_for | list of text | Interfaces still holding pending deliveries (a destination that's down, or a stopped interface, keeps a drain from finishing). |
ErrorBody
| Field | Type | |
|---|---|---|
error | text | What 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
200(application/json):CertificatesOverview
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
200(application/json):DocsReport
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
| In | Type | ||
|---|---|---|---|
id (required) | path | number | The 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
| In | Type | ||
|---|---|---|---|
vendor | query | text | e.g. Epic Systems (ignores case). |
application | query | text | e.g. Sunquest Lab (ignores case). |
host | query | text | An address, subnet (10.20.30.0/24) or host name. |
resolve | query | true or false | Look host names up in DNS (from the engine's server), so names and addresses match. |
Answers
200(application/json):ImpactReport400(application/json):ErrorBody
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
200(application/json): list ofOwnerGroup
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
200(application/json): list ofOwnerPerson
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
200(application/json):ReassignPreview400(application/json):ErrorBody
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
200(application/json):PortsReport
Shapes
The JSON bodies the operations above take and return.
AllowedName
| Field | Type | |
|---|---|---|
addresses | list of text | The addresses let in for it: the last lookup that found some (kept when a later one fails). |
checked_at (optional) | time (RFC 3339) or null | When it was last looked up. |
error (optional) | text or null | Why the last lookup failed, if it did. |
name | text |
CertificateInfo
| Field | Type | |
|---|---|---|
alert (optional) | number or null | Its alert (/alerts/{id}), when alerts are set up. |
chain_earliest (optional) | text or null | Whose that is, when it isn't the server certificate's (an intermediate). |
chain_not_after (optional) | time (RFC 3339) or null | The earliest expiry in its chain (what counts). |
checked_at (optional) | time (RFC 3339) or null | The last check; ours: now (read from memory). |
days_left (optional) | number or null | Whole days left (UTC dates), counted to the earliest expiry in the chain; negative once expired. |
error (optional) | text or null | Why 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 null | SHA-256 of the server certificate, hex. |
host | text | Ours: this server's name. |
issuer (optional) | text or null | |
not_after (optional) | time (RFC 3339) or null | The server certificate's own expiry. |
not_before (optional) | time (RFC 3339) or null | |
port | number | Ours: 0 (see presented). |
presented | list of text | Ours: 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 null | When the certificate shown was read (older than checked_at when the last check couldn't). |
resource | text | empi (resources.yaml), alerts.smtp, alerts.<channel>, alerts.heartbeat, or <interface>/<destination>; ours: the certificates: entry. |
side | text | remote (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 null | The step of the countdown it is within (a warn day; 0 once expired). |
state | text | ok, 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 null | This server trusts it (none for ours: whether senders do isn't known here). |
CertificatesOverview
| Field | Type | |
|---|---|---|
alerting | true or false | A rule names certificate (alerts are sent about them). |
certificates | list of CertificateInfo | Earliest expiry first; never read ones last. |
last_run (optional) | DoctorRun or null | |
last_scheduled (optional) | DoctorRun or null | |
mode | text | What those checks cover: certificates or full. |
schedule_seconds (optional) | number or null | How often the engine checks by itself, in seconds; none when doctor.schedule: off. |
warn_days | list of number | The steps of the countdown (alerts.certificates.warn_days). |
Check
The result for one thing the project connects to.
| Field | Type | |
|---|---|---|
advice | list of text | What a failure or warning most likely means, and what to do about it. |
certificate (optional) | SeenCertificate or null | |
endpoint | text | epic.local:5000 MLLP, a folder, 0.0.0.0:6661 MLLP. |
outcome | Outcome | The worst step. |
steps | list of Step | |
used_by | list of text | |
what | text | database patient_db, api empi, destination adt → lab, listener. |
DocsEntry
One documented thing and the values it has.
| Field | Type | |
|---|---|---|
custom | map of name to any JSON | Values by field key, as written in custom:. |
interface (optional) | text or null | The interface it belongs to; none for a resource. |
level | text | interface, destination or resource. |
missing | list of text | Required fields that have no value here. |
name | text | adt-lab, adt-lab.default, api empi. |
DocsField
A field the company declared in docs-schema.yaml.
| Field | Type | |
|---|---|---|
applies_to | list of text | interface, destination, resource. |
help (optional) | text or null | |
key | text | |
required | true or false | |
title | text | The label, else the key. |
type | text | text, enum, date, url, person or list. |
values | list of text | What an enum (or each item of a list) may be. |
DocsReport
| Field | Type | |
|---|---|---|
entries | list of DocsEntry | |
fields | list of DocsField | In the order the file lists them. |
problems | list of text | What ductus validate would reject (wrong or undeclared values, missing required ones). |
DoctorRequest
| Field | Type | |
|---|---|---|
interfaces (optional) | list of text | Only what these interfaces use (the engine's database is always checked). |
mode (optional) | DoctorRunMode | full (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 null | Longest wait per step, in milliseconds (default 5000, at most 60000). |
DoctorRun
| Field | Type | |
|---|---|---|
at | time (RFC 3339) | |
checks | number | |
failed | number | |
id | number | |
mode | text | certificates or full. |
seconds | number | |
started_by | text | schedule, or who asked. |
DoctorRunMode
full, certificates
ErrorBody
| Field | Type | |
|---|---|---|
error | text | What went wrong, in plain words. |
ImpactHit
| Field | Type | |
|---|---|---|
endpoints | list of text | Hosts and ports, listening ports, folders to look at. |
interface | text | |
owner (optional) | text or null | |
reason | text | Why it matched. |
role | text | source, destination lab, database patient_db, api empi. |
ImpactReport
| Field | Type | |
|---|---|---|
affected | number | Interfaces affected, of interfaces. |
hits | list of ImpactHit | |
incomplete | list of text | Interfaces that don't name every system they connect (they may be missing here). |
interfaces | number | |
known | list of text | With no hits for a vendor or application: the names that are known. |
query | text | What was looked for, e.g. vendor "Epic Systems". |
ListeningPort
| Field | Type | |
|---|---|---|
allow | text | Who may connect: the allow: list (10.1.20.0/24, epic-bridge01), or anyone. |
allow_detail (optional) | SourceAllow or null | |
host | text | |
interface | text | The interface, or (engine REST API). |
port | number | |
protocol | text | MLLP, MLLP over TLS, HTTP or HTTPS. |
route (optional) | text or null | HTTP only: GET /patients/{mrn} (respond). |
tls | text | none, or what it presents and asks for (presents ductus-mllp, clients need a certificate from hospital-ca). |
OutboundPort
| Field | Type | |
|---|---|---|
host | text | |
port | number | |
protocol | text | MLLP, MLLP over TLS, HTTP, HTTPS, SFTP, LDAP, LDAPS, Postgres, SQL Server, SQL Browser (UDP). |
tls | text | For 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_by | text | The interface (adt → lab), or resources.yaml: <name>. |
Outcome
ok, warn, fail
Owned
| Field | Type | |
|---|---|---|
destination (optional) | text or null | Set for a destination owned by someone other than its interface's owner. |
interface | text |
OwnedResource
| Field | Type | |
|---|---|---|
kind | text | database, api, sftp server or directory. |
name | text |
OwnerGroup
| Field | Type | |
|---|---|---|
advice (optional) | text or null | How 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 null | Absent for what nobody owns (listed last). |
owns | list of Owned | Interfaces, and destinations owned by someone other than their interface's owner. |
person (optional) | OwnerPerson or null | |
reach (optional) | text or null | Who 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. |
resources | list of OwnedResource | resources.yaml entries with this owner. |
suggest (optional) | text or null | What to write instead (the account's email), for the move's to. |
was (optional) | OwnerWas or null | |
you | true or false | The owner is the caller (or the person whose token calls). |
OwnerPerson
An account, as an owner.
| Field | Type | |
|---|---|---|
disabled | true or false | Disabled, or refused by the directory. |
display_name (optional) | text or null | |
email (optional) | text or null | |
username | text |
OwnerWas
A name an account had before.
| Field | Type | |
|---|---|---|
kind | text | username, email or display_name. |
until | time (RFC 3339) | When it stopped being theirs. |
value | text | |
whose | text | The account that had it (its username now). |
PortsReport
| Field | Type | |
|---|---|---|
connects_to | list of OutboundPort | What it connects to (destinations and resources.yaml), by host: what a firewall request lists. |
listening | list of ListeningPort | What the engine listens on (its interfaces and the API), by port. |
ReassignFile
| Field | Type | |
|---|---|---|
interface (optional) | text or null | The interface whose interface.yaml this is; absent for resources.yaml. |
lines | list of ReassignLine | |
path | text | Relative to the project, /-separated. |
resources | list of text | resources.yaml entries whose owner changes. |
ReassignLine
| Field | Type | |
|---|---|---|
after | text | |
before | text | |
line | number | 1-based. |
ReassignPreview
| Field | Type | |
|---|---|---|
changed | number | Owner values that would change, over all files. |
command | text | The same change made with the CLI in the project's folder. |
files | list of ReassignFile | Files that would change, each with its changed lines. |
known | list of text | The owners there are, when nothing matched from. |
patch | text | The whole change as a unified diff, for git apply in the project's repository. |
refused | list of ReassignRefused | Files 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_reach | text | The new owner, as reach in /owners says it. |
warning (optional) | text or null | Said when the new owner isn't an account (or names a disabled one, or several). |
ReassignRefused
| Field | Type | |
|---|---|---|
error | text | |
interface (optional) | text or null | Absent for resources.yaml. |
path | text |
ReassignRequest
| Field | Type | |
|---|---|---|
from | text | The current owner (ignores case). |
interfaces (optional) | list of text | Only these interfaces. With neither interfaces nor resources, everything moves. |
resources (optional) | list of text | Only these resources.yaml entries (by name). |
to | text | The new owner. |
Report
The whole report: what ductus doctor prints and --report saves.
| Field | Type | |
|---|---|---|
checks | list of Check | |
environment | text | |
generated_at | text | |
host | text | |
notes (optional) | list of text | Lines about this machine said before the checks (on Windows: the service's account). |
seconds | number | |
version | text |
SeenCertificate
A server's certificate as a check saw it.
| Field | Type | |
|---|---|---|
chain_earliest (optional) | text or null | Whose that is, when it isn't the server certificate's (an intermediate). |
chain_not_after (optional) | time (RFC 3339) or null | The earliest expiry in the chain the server presented. |
error (optional) | text or null | Why the check failed: couldn't connect, not trusted, expired. |
fingerprint (optional) | text or null | SHA-256 of the server certificate (DER), hex. |
host | text | |
issuer (optional) | text or null | |
not_after (optional) | time (RFC 3339) or null | |
not_before (optional) | time (RFC 3339) or null | |
port | number | |
resource | text | What it belongs to: empi (resources.yaml), alerts.smtp, alerts.<channel>, alerts.heartbeat, <interface>/<destination>. |
subject (optional) | text or null | The server certificate's common name (or organisation); none if none was read. |
trusted | true or false | This server trusts it (the system's trusted certificates). |
SourceAllow
A listener's allow: list and how it's doing.
| Field | Type | |
|---|---|---|
entries | list of text | The list as written: addresses, subnets and host names. |
last_refused (optional) | text or null | The last refused connection's address and time. |
last_refused_at (optional) | time (RFC 3339) or null | |
names | list of AllowedName | Each host name in it with what it resolves to now. |
refused | number | Connections 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.
| Field | Type | |
|---|---|---|
detail | text | |
name | text | |
outcome | Outcome |
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
200(application/json):AlertsOverview
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
200(application/json):ChannelsOverview403(application/json):ErrorBody
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
| In | Type | ||
|---|---|---|---|
name (required) | path | text | Channel name. |
Answers
200(application/json):TestResult403(application/json):ErrorBody404(application/json):ErrorBody502(application/json):ErrorBody
GET /api/v1/alerts/{id}
One alert and its history.
Who may call it: anyone signed in (a person or a token).
Parameters
| In | Type | ||
|---|---|---|---|
id (required) | path | number | Alert id. |
Answers
200(application/json):AlertDetail404(application/json):ErrorBody
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
| In | Type | ||
|---|---|---|---|
id (required) | path | number | Alert id. |
Answers
200(application/json):AlertInfo403(application/json):ErrorBody404(application/json):ErrorBody409(application/json):ErrorBody
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
| In | Type | ||
|---|---|---|---|
id (required) | path | number | Alert id. |
Body (application/json): SilenceRequest
Answers
200(application/json):AlertInfo400(application/json):ErrorBody403(application/json):ErrorBody404(application/json):ErrorBody
DELETE /api/v1/alerts/{id}/silence
End a silence early. Operator.
Who may call it: an operator or admin.
Parameters
| In | Type | ||
|---|---|---|---|
id (required) | path | number | Alert id. |
Answers
200(application/json):AlertInfo403(application/json):ErrorBody404(application/json):ErrorBody
Shapes
The JSON bodies the operations above take and return.
AlertDetail
| Field | Type | |
|---|---|---|
alert | AlertInfo | |
events | list of AlertEvent | Newest first (the last 100). |
AlertEvent
| Field | Type | |
|---|---|---|
alert | number | |
at | time (RFC 3339) | |
by (optional) | text or null | Who acknowledged or silenced it. |
failed | number | |
id | number | |
key | text | |
kind | text | fired, escalated, reminded, recovered, acknowledged, silenced, unsilenced. |
sent | number | Its 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 null | The state then (for recovered: what it recovered from). |
summary (optional) | text or null | |
waiting | number |
AlertInfo
| Field | Type | |
|---|---|---|
acknowledged_at (optional) | time (RFC 3339) or null | |
acknowledged_by (optional) | text or null | |
destination (optional) | text or null | The destination; for a certificate, host:port. |
id | number | |
interface | text | The interface; for a certificate, its resource. |
key | text | iface:<interface>, dest:<interface>/<destination> or cert:<resource>:<host>:<port>. |
last_notified_at (optional) | time (RFC 3339) or null | When people were last told (or will be, once quiet hours end). |
muted | true or false | Its interface says alert: { mute: true }: nothing is ever sent about it. |
notifications | number | Notifications sent since it fired. |
recovering_since (optional) | time (RFC 3339) or null | Fine again since then, but not for alerts.settle yet (it may still reopen). |
severity (optional) | text or null | warning or critical while open. |
silence_reason (optional) | text or null | |
silenced_by (optional) | text or null | |
silenced_until (optional) | time (RFC 3339) or null | Set while silenced: nothing is sent until then. |
since (optional) | time (RFC 3339) or null | Bad since. |
state | text | ok, or what is wrong: error, stalled, behind (a destination), stopped, silent, missed (an interface), certificate (expiring or expired). |
summary (optional) | text or null | The health view's words, as of the last check. |
AlertsOverview
| Field | Type | |
|---|---|---|
alerts | list of AlertInfo | Every interface and destination, open alerts first (critical, then oldest). |
configured | true or false | alerts: is in ductus.yaml. Without it nothing is judged or sent. |
recent | list of AlertEvent | The latest events, newest first. |
ChannelCounts
| Field | Type | |
|---|---|---|
failed | number | |
last_error (optional) | text or null | |
sent | number | |
waiting | number |
ChannelInfo
| Field | Type | |
|---|---|---|
email | list of text | |
failed | number | |
last_error (optional) | text or null | |
last_sent (optional) | time (RFC 3339) or null | |
name | text | |
sent | number | Notifications in the last 7 days: sent, still to send, given up on. |
waiting | number | |
webhook (optional) | text or null | Scheme and host only: the rest of a webhook URL is often its secret. |
webhook_format (optional) | text or null | json, slack or teams. |
ChannelsOverview
| Field | Type | |
|---|---|---|
channels | list of ChannelInfo | |
configured | true or false | |
heartbeat (optional) | text or null | The heartbeat URL (scheme and host) and how often. |
owner_notifications (optional) | ChannelCounts or null | |
owners | list of OwnerReach | Owners 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 null | Reminders: 4h, or none. |
smtp (optional) | SmtpInfo or null |
ErrorBody
| Field | Type | |
|---|---|---|
error | text | What went wrong, in plain words. |
OwnerReach
| Field | Type | |
|---|---|---|
addresses | list of text | Where alerts for this owner go; empty: nowhere (no user with that name and an email, not in owners:, not an address). |
owner | text |
SilenceRequest
| Field | Type | |
|---|---|---|
for | text | How long: 30m, 4h, 2d (at most 30 days). |
reason (optional) | text or null | Why (planned work, a known outage): shown to everyone who sees the alert. |
SmtpInfo
| Field | Type | |
|---|---|---|
from | text | |
host | text | |
port | number | |
tls | text | starttls, tls or none. |
username (optional) | text or null |
TestResult
| Field | Type | |
|---|---|---|
channel | text | |
sent | true 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
| In | Type | ||
|---|---|---|---|
actor | query | text | A user's or token's name (a user's tokens count as theirs). |
action | query | text | An action (message.dismiss) or a group of them (message, user, session). |
interface | query | text | |
message | query | number | A message id. |
from | query | time (RFC 3339) | From this time (inclusive). |
to | query | time (RFC 3339) | Until this time (exclusive). |
before | query | number | Entries older than this id (the previous page's next_before). |
after | query | number | Entries newer than this id, oldest first (what an export follows). |
format | query | text | json (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. |
limit | query | number | Page size, at most 500 (default 100). |
Answers
200Withformat=ndjson:application/x-ndjson, one entry (as inentries) per line (application/json):AuditPage400(application/json):ErrorBody403(application/json):ErrorBody
Shapes
The JSON bodies the operations above take and return.
AuditEntry
| Field | Type | |
|---|---|---|
action | text | message.payload.read, message.dismiss, user.create, ... |
actor | text | The user's or token's name when it happened. |
actor_kind | text | user, token, system (the engine, or someone at the server's command line) or anonymous (a sign-in as a name nobody has). |
at | time (RFC 3339) | |
detail (optional) | object or null | More about it, by action (never a password, token or payload). |
id | number | |
interface (optional) | text or null | |
ip (optional) | text or null | The address the request came from (a proxy's, if there is one). |
message (optional) | number or null | |
outcome | text | ok, denied or failed. |
subject (optional) | text or null | What it was done to: a username, a token, interface/destination. |
via (optional) | text or null | session (the web UI), token, cli or engine; none for entries from before the log. |
AuditPage
| Field | Type | |
|---|---|---|
entries | list of AuditEntry | Newest first. |
next_before (optional) | number or null | Pass as before for the next (older) page; absent on the last page. |
ErrorBody
| Field | Type | |
|---|---|---|
error | text | What 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
200(application/json):RetentionReport
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
| In | Type | ||
|---|---|---|---|
name (required) | path | text | Interface name. |
Answers
200(application/json):InterfaceRetention404(application/json):ErrorBody409(application/json):ErrorBody
Shapes
The JSON bodies the operations above take and return.
Change
| Field | Type | |
|---|---|---|
from | number | |
rows_left (optional) | number or null | Messages still to move or delete (an estimate). |
started_at | time (RFC 3339) | |
to | number |
ErrorBody
| Field | Type | |
|---|---|---|
error | text | What went wrong, in plain words. |
Held
| Field | Type | |
|---|---|---|
bytes | number | Disk it uses, indexes included. |
interfaces | list of HeldInterface | Interfaces with unfinished messages in it. |
messages | number | Messages in it, finished ones included (they go when their month drops). |
months | list of HeldMonth | By month: a month is dropped once nothing in it is unfinished. |
pending | number | |
quarantined | number |
HeldInterface
| Field | Type | |
|---|---|---|
name | text | |
pending | number | |
quarantined | number |
HeldMonth
| Field | Type | |
|---|---|---|
bytes | number | |
messages | number | |
month | text | The month, 2026-09. |
partition | text | |
pending | number | |
quarantined | number |
InterfaceRetention
| Field | Type | |
|---|---|---|
change (optional) | Change or null | |
configured | number | What interface.yaml (or ductus.yaml) says, in days. |
days | number | The retention in force: its new messages are kept this many days. |
name | text | |
needs_confirmation | true or false | The waiting value deletes messages: confirm it (POST /retention/{name}/confirm). |
waiting (optional) | number or null | A configured value not applied yet: a change is running, or it's shorter and needs a confirmation. |
would_delete (optional) | number or null | How many messages the waiting value would delete now, when it needs a confirmation. |
KeptPartition
| Field | Type | |
|---|---|---|
kept_for | list of text | Interfaces that now keep their messages longer, with messages still in it. |
partition | text | |
pending | number | |
quarantined | number | |
tier | number | Its retention tier, in days. |
RetentionReport
| Field | Type | |
|---|---|---|
held | Held | The 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). |
interfaces | list of InterfaceRetention | |
kept | list of KeptPartition | Expired partitions kept at the last drop, and why. |
last_drop_at (optional) | time (RFC 3339) or null | When 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
200(application/json):CreatedToken400(application/json):ErrorBody403(application/json):ErrorBody
DELETE /api/v1/tokens/{name}
Revoke a token: it stops working at once. Admin.
Who may call it: an admin.
Parameters
| In | Type | ||
|---|---|---|---|
name (required) | path | text | Token name. |
Answers
Shapes
The JSON bodies the operations above take and return.
CreatedToken
| Field | Type | |
|---|---|---|
secret | text | The token itself: shown this once, never stored (only its hash is). |
token | TokenInfo |
ErrorBody
| Field | Type | |
|---|---|---|
error | text | What went wrong, in plain words. |
NewToken
| Field | Type | |
|---|---|---|
name | text | Who or what uses it, e.g. ci or monitoring. |
permissions (optional) | list of text or null | payloads and/or audit. None by default: a token can't read message payloads unless it is given the permission. |
role (optional) | text or null | viewer (default), operator or admin. |
TokenInfo
| Field | Type | |
|---|---|---|
created_at | time (RFC 3339) | |
last_used_at (optional) | time (RFC 3339) or null | To the minute. |
name | text | |
owner (optional) | text or null | The user it belongs to; none for a service token made by an admin. |
permissions | list of text | payloads, audit (admins' tokens have all). |
role | text | viewer, 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.
| File | What it holds |
|---|---|
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. |
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. |
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. |
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. |
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. |
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, answer202 Acceptedonce 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 (workersof 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: withstrictit can't send faster than one message per disk flush (about 400 a second at 2.4 ms a flush;ductus doctormeasures 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 atper_minuteon average, and up toburstmay go at once after a quiet spell (a token bucket).hard: never more thanper_minutein 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 atper_minuteon average, and up toburstmay go at once after a quiet spell (a token bucket).hard: never more thanper_minutein 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 forhost. 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,userandpassword.windows: a Windows login. Withoutuser, as the account the engine runs as (the Windows service's account; Windows only, Kerberos where the domain allows it); withuserandpassword, 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 itsmonitor.behind.stopped: an interface's code was stopped (it ran away or ran out of memory).silent: nothing received withinmonitor.active_within.missed: nothing received bymonitor.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: everythingductus doctorchecks, 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 ofvalues.date: a date, written2026-10-30.url: anhttp(s)://address, shown as a link.person: who, as text: best an email address.list: several texts (each one ofvalues, 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.
| Metric | Type | Labels | What it measures |
|---|---|---|---|
ductus_start_time_seconds | gauge | When the engine started (Unix time). | |
ductus_draining | gauge | 1 while draining or drained (not taking new messages). | |
ductus_alerts_open | gauge | severity | Open alerts, by severity. Only with alerts: in ductus.yaml. |
ductus_alert_send_failures_total | counter | channel | Alert 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_seconds | gauge | side, resource, host, port | Seconds 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_stopped | gauge | interface | 1 if the interface was stopped because its code ran away. |
ductus_messages_received_total | counter | interface | Messages stored and queued. |
ductus_messages_duplicate_total | counter | interface | Messages stored as duplicates (not queued). |
ductus_messages_store_failures_total | counter | interface | Messages that couldn't be stored (the sender was told to retry). |
ductus_deliveries_total | counter | interface, destination, outcome | Messages a destination finished, by outcome (sent, filtered, quarantined). |
ductus_delivery_retries_total | counter | interface, destination | Temporary failures (each retry counts). |
ductus_delivery_latency_seconds | histogram | interface, destination | From receipt to sent, waiting in the queue included. |
ductus_send_seconds | histogram | interface, destination | Each successful send: the receiver's time. |
ductus_transform_seconds | histogram | interface, destination | The destination's function, per message. |
ductus_queue_pending | gauge | interface, destination | Messages waiting for a destination. |
ductus_queue_quarantined | gauge | interface, destination | Messages quarantined by a destination (needing a person). |
ductus_queue_oldest_seconds | gauge | interface, destination | How long the oldest message waiting for a destination has waited. |
ductus_destination_state | gauge | interface, destination | 0 ok, 1 behind (past its behind limit), 2 error (quarantined messages), 3 stalled, 4 paused. |
ductus_interface_state | gauge | interface | 0 ok, 1 silent (nothing within active_within), 2 missed (nothing by active_by), 3 stopped. |
ductus_destination_paused | gauge | interface, destination | 1 while a destination is paused on purpose (ductus pause). |
ductus_held_messages | gauge | interface, status | Unfinished messages past their retention, in the held area (as of the hourly retention run). |
ductus_held_bytes | gauge | Disk used by the held area (as of the hourly retention run). | |
ductus_http_request_seconds | histogram | interface | Respond-mode HTTP requests: time in the engine. |
ductus_http_request_errors_total | counter | interface | Respond-mode HTTP requests that failed. |
ductus_resource_call_seconds | histogram | kind, resource | Calls from interface code to a database or API. |
ductus_resource_call_errors_total | counter | kind, resource | Calls 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).
| Action | What happened |
|---|---|
session.sign_in | Someone signed in to the web UI, with a password or a token. |
session.failed | A 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_out | Someone signed out (or signed out their other sessions). |
password.change | Someone changed their own password. |
password.reset | An admin set another person's password (they must change it at their next sign-in). |
user.create | An 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.update | An admin changed an account's role, permissions, name or email. |
user.disable | An admin disabled or enabled an account (a disabled account's sessions and tokens stop at once). |
user.unlock | An admin unlocked an account locked by wrong passwords. |
user.directory_change | The 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_refused | The directory said no to an account (removed from the groups, disabled or deleted): its sessions and tokens stopped. |
token.create | An API token was made (by an admin, at the command line, or by a person for themselves). |
token.revoke | An API token was revoked. |
message.payload.read | Someone 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.release | Someone resent a message's quarantined deliveries. |
message.replay | Someone sent a message again to a destination that had already had it (sent or filtered). |
message.edit | Someone corrected a message and sent the corrected copy in its place. |
message.dismiss | Someone closed a quarantined delivery without sending it, with a reason. |
destination.pause | Someone paused a destination (with a reason, or added one later). |
destination.resume | Someone resumed a paused destination. |
engine.drain | Someone started a drain: the engine stopped taking messages to deliver what it has. |
engine.resume | Someone ended a drain: the engine takes messages again. |
retention.change | The engine started applying a new retention to an interface's stored messages. |
retention.confirm | Someone confirmed a shorter retention, which deletes messages past the new window. |
doctor.run | Someone ran the connection checks from the web UI or the API. |
audit.export | Someone exported the audit log. |
alert.ack | Someone acknowledged an alert: they're on it, and reminders stop. |
alert.silence | Someone silenced an alert (or a destination before planned work), with a reason and an end. |
alert.unsilence | Someone ended an alert's silence early. |
alert.test | Someone sent a test notification to a channel. |
engine.start | The 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.stop | The engine stopped cleanly (or handed over to a new engine). |
engine.handover | A new engine took over from the running one (systemctl reload: an upgrade or a deploy), with both versions and how long sending paused. |
engine.refused | A start was refused: another live engine runs some of its interfaces, its lanes would strand waiting messages, or a handover would lower lanes. |
interface.stopped | An interface's code was stopped (it ran away or ran out of memory): its messages wait. |
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. |
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 validateandductus typestype-check interface code with it
The notable ones
| Library | Version | License | What it does in Ductus | In |
|---|---|---|---|---|
v8 | 150.4.0 | MIT | Google's V8 JavaScript engine (V8 15.0.245.2): runs interface code | Linux, Windows |
deno_core | 0.412.0 | MIT | Embeds V8: modules, async calls, the bridge between interface code and the engine | Linux, Windows |
rustls | 0.23.45 | Apache-2.0 OR ISC OR MIT | TLS for every connection the engine makes or accepts | Linux, Windows |
aws-lc-rs | 1.18.1 | ISC AND (Apache-2.0 OR ISC) | The cryptography under TLS (AWS-LC; post-quantum key exchange preferred) | Linux, Windows |
rustls-platform-verifier | 0.7.1 | MIT OR Apache-2.0 | Checks certificates against the operating system's trusted roots | Linux, Windows |
sqlx | 0.9.0 | MIT OR Apache-2.0 | PostgreSQL: the engine's store and queue, and db() calls to Postgres | Linux, Windows |
tiberius (fork) | 0.13.0 | MIT/Apache-2.0 | SQL Server for db() calls (our fork: Windows logins) | Linux, Windows |
tokio | 1.53.2 | MIT | The asynchronous runtime: sockets, timers, tasks | Linux, Windows |
hyper | 1.11.1 | MIT | HTTP/1 and HTTP/2, under the API, HTTP sources and destinations | Linux, Windows |
axum | 0.8.9 | MIT | The REST API and the web UI's server | Linux, Windows |
reqwest | 0.13.5 | MIT OR Apache-2.0 | Outgoing HTTP: destinations, fetch(), webhooks | Linux, Windows |
russh | 0.64.1 | Apache-2.0 | SFTP sources and destinations (SSH) | Linux, Windows |
ldap3 | 0.12.1 | MIT/Apache-2.0 | Sign-in with LDAP or Active Directory | Linux, Windows |
lettre | 0.11.23 | MIT | Alert email (SMTP) | Linux, Windows |
argon2 | 0.5.3, 0.6.0 | MIT OR Apache-2.0 | Hashes local accounts' passwords (argon2id) | Linux, Windows |
chacha20poly1305 | 0.11.0 | Apache-2.0 OR MIT | Encrypts secrets files | Linux, Windows |
postgresql_embedded | 0.21.0 | (Apache-2.0 OR MIT) AND PostgreSQL | ductus dev: downloads and runs Postgres for a laptop | Linux, Windows |
serde_yaml_ng | 0.10.0 | MIT | Reads the project's YAML files | Linux, Windows |
clap | 4.6.7 | MIT OR Apache-2.0 | The ductus command line | Linux, Windows |
windows-service | 0.8.1 | MIT OR Apache-2.0 | Runs as a Windows service | Windows |
| TypeScript | 6.0.3 | Apache-2.0 | Type-checks interface code (validate, types); kept in vendor/typescript | Linux, Windows |
react (npm) | 19.3.0 | MIT | The web UI's components | the web UI |
react-dom (npm) | 19.3.0 | MIT | Renders them in the browser | the web UI |
@tanstack/react-router (npm) | 1.170.41 | MIT | The web UI's pages and links | the web UI |
@tanstack/react-query (npm) | 5.104.1 | MIT | Reads the REST API and keeps pages current | the web UI |
radix-ui (npm) | 1.7.0 | MIT | Accessible menus, dialogs and controls | the web UI |
openapi-fetch (npm) | 0.17.0 | MIT | The typed REST API client | the 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).
| License | Rust crates | npm packages |
|---|---|---|
| MIT OR Apache-2.0 | 257 | |
| MIT | 107 | 98 |
| Apache-2.0 OR MIT | 61 | |
| Apache-2.0 | 42 | 1 |
| Unicode-3.0 | 25 | |
| MIT/Apache-2.0 | 22 | |
| BSD-3-Clause | 7 | |
| ISC | 4 | 1 |
| Unlicense OR MIT | 5 | |
| Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | 4 | |
| (Apache-2.0 OR MIT) AND PostgreSQL | 3 | |
| Apache-2.0 OR ISC OR MIT | 3 | |
| (MIT OR Apache-2.0) AND Unicode-3.0 | 2 | |
| 0BSD | 1 | 1 |
| Apache-2.0 OR BSL-1.0 | 2 | |
| MIT OR Zlib OR Apache-2.0 | 2 | |
| Unlicense/MIT | 2 | |
| Zlib | 2 | |
| Zlib OR Apache-2.0 OR MIT | 2 | |
| (Apache-2.0 OR MIT) AND BSD-3-Clause | 1 | |
| 0BSD OR MIT OR Apache-2.0 | 1 | |
| Apache-2.0 / MIT | 1 | |
| Apache-2.0 OR BSL-1.0 OR MIT | 1 | |
| Apache-2.0 WITH LLVM-exception | 1 | |
| Apache-2.0/MIT | 1 | |
| BSD-2-Clause OR Apache-2.0 OR MIT | 1 | |
| CC0-1.0 OR MIT-0 OR Apache-2.0 | 1 | |
| CDLA-Permissive-2.0 | 1 | |
| 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-Clause | 1 | |
| MPL-2.0 | 1 | |
| MPL-2.0+ | 1 | |
| Unlicense | 1 |
Every Rust crate
| Crate | Version | License | Linux | Windows |
|---|---|---|---|---|
adler2 | 2.0.1 | 0BSD OR MIT OR Apache-2.0 | ✓ | ✓ |
aead | 0.6.1 | MIT OR Apache-2.0 | ✓ | ✓ |
aes | 0.9.3 | MIT OR Apache-2.0 | ✓ | ✓ |
aes-gcm | 0.11.1 | Apache-2.0 OR MIT | ✓ | ✓ |
ahash | 0.8.12 | MIT OR Apache-2.0 | ✓ | ✓ |
aho-corasick | 1.1.5 | Unlicense OR MIT | ✓ | ✓ |
allocator-api2 | 0.2.21 | MIT OR Apache-2.0 | ✓ | ✓ |
anstream | 1.0.0 | MIT OR Apache-2.0 | ✓ | ✓ |
anstyle | 1.0.14 | MIT OR Apache-2.0 | ✓ | ✓ |
anstyle-parse | 1.0.0 | MIT OR Apache-2.0 | ✓ | ✓ |
anstyle-query | 1.1.5 | MIT OR Apache-2.0 | ✓ | ✓ |
anstyle-wincon | 3.0.11 | MIT OR Apache-2.0 | ✓ | |
anyhow | 1.0.104 | MIT OR Apache-2.0 | ✓ | ✓ |
ar_archive_writer | 0.5.3 | Apache-2.0 WITH LLVM-exception | ✓ | ✓ |
argon2 | 0.5.3 | MIT OR Apache-2.0 | ✓ | ✓ |
argon2 | 0.6.0 | MIT OR Apache-2.0 | ✓ | ✓ |
ascii | 1.1.0 | Apache-2.0 OR MIT | ✓ | ✓ |
asn1-rs | 0.7.2 | MIT OR Apache-2.0 | ✓ | ✓ |
asn1-rs-derive | 0.6.0 | MIT OR Apache-2.0 | ✓ | ✓ |
asn1-rs-impl | 0.2.0 | MIT/Apache-2.0 | ✓ | ✓ |
ast_node | 5.0.0 | Apache-2.0 | ✓ | ✓ |
async-recursion | 1.2.0 | MIT OR Apache-2.0 | ✓ | |
async-trait | 0.1.92 | MIT OR Apache-2.0 | ✓ | ✓ |
asynchronous-codec | 0.7.0 | MIT | ✓ | ✓ |
atoi | 2.0.0 | MIT | ✓ | ✓ |
atomic-waker | 1.1.2 | Apache-2.0 OR MIT | ✓ | ✓ |
autocfg | 1.5.1 | Apache-2.0 OR MIT | ✓ | ✓ |
aws-lc-rs | 1.18.1 | ISC AND (Apache-2.0 OR ISC) | ✓ | ✓ |
aws-lc-sys | 0.45.0 | 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) | ✓ | ✓ |
axum | 0.8.9 | MIT | ✓ | ✓ |
axum-core | 0.5.6 | MIT | ✓ | ✓ |
base16ct | 1.0.0 | Apache-2.0 OR MIT | ✓ | ✓ |
base64 | 0.22.1 | MIT OR Apache-2.0 | ✓ | ✓ |
base64 | 0.23.1 | MIT OR Apache-2.0 | ✓ | ✓ |
base64-simd | 0.8.0 | MIT | ✓ | ✓ |
base64ct | 1.8.3 | Apache-2.0 OR MIT | ✓ | ✓ |
bcrypt-pbkdf | 0.11.0 | MIT OR Apache-2.0 | ✓ | ✓ |
better_scoped_tls | 1.0.1 | Apache-2.0 | ✓ | ✓ |
bindgen | 0.72.1 | BSD-3-Clause | ✓ | ✓ |
bit-set | 0.8.0 | Apache-2.0 OR MIT | ✓ | ✓ |
bit-vec | 0.8.0 | Apache-2.0 OR MIT | ✓ | ✓ |
bitflags | 2.13.2 | MIT OR Apache-2.0 | ✓ | ✓ |
bitvec | 1.1.1 | MIT | ✓ | ✓ |
blake2 | 0.10.6 | MIT OR Apache-2.0 | ✓ | ✓ |
blake2 | 0.11.0 | MIT OR Apache-2.0 | ✓ | ✓ |
block-buffer | 0.10.4 | MIT OR Apache-2.0 | ✓ | ✓ |
block-buffer | 0.12.1 | MIT OR Apache-2.0 | ✓ | ✓ |
block-padding | 0.4.2 | MIT OR Apache-2.0 | ✓ | ✓ |
blowfish | 0.10.0 | MIT OR Apache-2.0 | ✓ | ✓ |
boxed_error | 0.2.3 | MIT | ✓ | ✓ |
bumpalo | 3.20.3 | MIT OR Apache-2.0 | ✓ | ✓ |
bytemuck | 1.25.2 | Zlib OR Apache-2.0 OR MIT | ✓ | |
byteorder | 1.5.0 | Unlicense OR MIT | ✓ | ✓ |
bytes | 1.12.1 | MIT | ✓ | ✓ |
bytes-str | 0.2.9 | Apache-2.0 | ✓ | ✓ |
calendrical_calculations | 0.2.4 | Apache-2.0 | ✓ | ✓ |
capacity_builder | 0.5.0 | MIT | ✓ | ✓ |
capacity_builder_macros | 0.3.0 | MIT | ✓ | ✓ |
castaway | 0.2.4 | MIT | ✓ | ✓ |
cbc | 0.2.1 | MIT OR Apache-2.0 | ✓ | ✓ |
cc | 1.6.0 | MIT OR Apache-2.0 | ✓ | ✓ |
cexpr | 0.6.0 | Apache-2.0/MIT | ✓ | ✓ |
cfg_aliases | 0.2.2 | MIT | ✓ | |
cfg-if | 1.0.5 | MIT OR Apache-2.0 | ✓ | ✓ |
chacha20 | 0.10.2 | MIT OR Apache-2.0 | ✓ | ✓ |
chacha20poly1305 | 0.11.0 | Apache-2.0 OR MIT | ✓ | ✓ |
chrono | 0.4.45 | MIT OR Apache-2.0 | ✓ | ✓ |
cipher | 0.5.2 | MIT OR Apache-2.0 | ✓ | ✓ |
clang-sys | 1.9.1 | Apache-2.0 | ✓ | ✓ |
clap | 4.6.7 | MIT OR Apache-2.0 | ✓ | ✓ |
clap_builder | 4.6.7 | MIT OR Apache-2.0 | ✓ | ✓ |
clap_derive | 4.6.7 | MIT OR Apache-2.0 | ✓ | ✓ |
clap_lex | 1.1.1 | MIT OR Apache-2.0 | ✓ | ✓ |
cmake | 0.1.58 | MIT OR Apache-2.0 | ✓ | ✓ |
cmov | 0.5.4 | Apache-2.0 OR MIT | ✓ | ✓ |
colorchoice | 1.0.5 | MIT OR Apache-2.0 | ✓ | ✓ |
compact_str | 0.7.1 | MIT | ✓ | ✓ |
connection-string | 0.2.0 | MIT OR Apache-2.0 | ✓ | ✓ |
const-oid | 0.10.2 | Apache-2.0 OR MIT | ✓ | ✓ |
cooked-waker | 5.0.0 | MPL-2.0 | ✓ | ✓ |
core_detect | 1.0.0 | MIT/Apache-2.0 | ✓ | ✓ |
core_maths | 0.1.1 | MIT | ✓ | ✓ |
cpubits | 0.1.1 | MIT OR Apache-2.0 | ✓ | ✓ |
cpufeatures | 0.2.17 | MIT OR Apache-2.0 | ✓ | ✓ |
cpufeatures | 0.3.1 | MIT OR Apache-2.0 | ✓ | ✓ |
crc | 3.4.0 | MIT OR Apache-2.0 | ✓ | ✓ |
crc-catalog | 2.5.0 | MIT OR Apache-2.0 | ✓ | ✓ |
crc32fast | 1.5.2 | MIT OR Apache-2.0 | ✓ | ✓ |
croner | 4.0.1 | MIT | ✓ | ✓ |
crossbeam-channel | 0.5.17 | MIT OR Apache-2.0 | ✓ | |
crossbeam-queue | 0.3.14 | MIT OR Apache-2.0 | ✓ | ✓ |
crossbeam-utils | 0.8.23 | MIT OR Apache-2.0 | ✓ | ✓ |
crypto-bigint | 0.7.5 | Apache-2.0 OR MIT | ✓ | ✓ |
crypto-common | 0.1.7 | MIT OR Apache-2.0 | ✓ | ✓ |
crypto-common | 0.2.2 | MIT OR Apache-2.0 | ✓ | ✓ |
crypto-mac | 0.11.0 | MIT OR Apache-2.0 | ✓ | |
crypto-primes | 0.7.2 | Apache-2.0 OR MIT | ✓ | ✓ |
csv | 1.4.0 | Unlicense/MIT | ✓ | ✓ |
csv-core | 0.1.13 | Unlicense/MIT | ✓ | ✓ |
ctr | 0.10.1 | MIT OR Apache-2.0 | ✓ | ✓ |
ctutils | 0.4.3 | Apache-2.0 OR MIT | ✓ | ✓ |
curve25519-dalek | 5.0.0 | BSD-3-Clause | ✓ | ✓ |
curve25519-dalek-derive | 0.1.1 | MIT/Apache-2.0 | ✓ | ✓ |
darling | 0.20.11 | MIT | ✓ | ✓ |
darling_core | 0.20.11 | MIT | ✓ | ✓ |
darling_macro | 0.20.11 | MIT | ✓ | ✓ |
dashmap | 6.2.1 | MIT | ✓ | ✓ |
data-encoding | 2.11.1 | MIT | ✓ | ✓ |
data-url | 0.3.2 | MIT OR Apache-2.0 | ✓ | ✓ |
debugid | 0.8.0 | Apache-2.0 | ✓ | ✓ |
delegate | 0.13.5 | MIT OR Apache-2.0 | ✓ | ✓ |
deno_ast | 0.53.3 | MIT | ✓ | ✓ |
deno_core | 0.412.0 | MIT | ✓ | ✓ |
deno_error | 0.7.1 | MIT | ✓ | ✓ |
deno_error_macro | 0.7.1 | MIT | ✓ | ✓ |
deno_media_type | 0.4.0 | MIT | ✓ | ✓ |
deno_ops | 0.288.0 | MIT | ✓ | ✓ |
deno_path_util | 0.6.4 | MIT | ✓ | ✓ |
deno_terminal | 0.2.3 | MIT | ✓ | ✓ |
deno_unsync | 0.4.4 | MIT | ✓ | ✓ |
deno_v8 | 0.4.0 | MIT | ✓ | ✓ |
der | 0.8.2 | Apache-2.0 OR MIT | ✓ | ✓ |
der-parser | 10.0.0 | MIT OR Apache-2.0 | ✓ | ✓ |
deranged | 0.5.8 | MIT OR Apache-2.0 | ✓ | ✓ |
derive_builder | 0.20.2 | MIT OR Apache-2.0 | ✓ | ✓ |
derive_builder_core | 0.20.2 | MIT OR Apache-2.0 | ✓ | ✓ |
derive_builder_macro | 0.20.2 | MIT OR Apache-2.0 | ✓ | ✓ |
des | 0.9.0 | MIT OR Apache-2.0 | ✓ | |
digest | 0.10.7 | MIT OR Apache-2.0 | ✓ | ✓ |
digest | 0.11.3 | MIT OR Apache-2.0 | ✓ | ✓ |
diplomat | 0.16.1 | MIT OR Apache-2.0 | ✓ | ✓ |
diplomat_core | 0.16.1 | MIT OR Apache-2.0 | ✓ | ✓ |
diplomat-runtime | 0.16.0 | MIT OR Apache-2.0 | ✓ | ✓ |
displaydoc | 0.2.7 | MIT OR Apache-2.0 | ✓ | ✓ |
dotenvy | 0.15.7 | MIT | ✓ | ✓ |
dprint-swc-ext | 0.26.1 | MIT | ✓ | ✓ |
dunce | 1.0.5 | CC0-1.0 OR MIT-0 OR Apache-2.0 | ✓ | ✓ |
ecdsa | 0.17.0 | Apache-2.0 OR MIT | ✓ | ✓ |
ed25519 | 3.0.0 | Apache-2.0 OR MIT | ✓ | ✓ |
ed25519-dalek | 3.0.0 | BSD-3-Clause | ✓ | ✓ |
either | 1.18.0 | MIT OR Apache-2.0 | ✓ | ✓ |
elliptic-curve | 0.14.1 | Apache-2.0 OR MIT | ✓ | ✓ |
email_address | 0.2.9 | MIT | ✓ | ✓ |
email-encoding | 0.4.2 | MIT OR Apache-2.0 | ✓ | ✓ |
embed-resource | 3.0.12 | MIT | ✓ | ✓ |
encoding_rs | 0.8.42 | (Apache-2.0 OR MIT) AND BSD-3-Clause | ✓ | ✓ |
enum_dispatch | 0.3.13 | MIT OR Apache-2.0 | ✓ | ✓ |
enumflags2 | 0.7.12 | MIT OR Apache-2.0 | ✓ | ✓ |
enumflags2_derive | 0.7.12 | MIT OR Apache-2.0 | ✓ | ✓ |
equivalent | 1.0.2 | Apache-2.0 OR MIT | ✓ | ✓ |
errno | 0.3.14 | MIT OR Apache-2.0 | ✓ | ✓ |
etcetera | 0.11.0 | MIT OR Apache-2.0 | ✓ | |
event-listener | 5.4.2 | Apache-2.0 OR MIT | ✓ | ✓ |
fastrand | 2.5.0 | Apache-2.0 OR MIT | ✓ | ✓ |
ff | 0.14.0 | MIT/Apache-2.0 | ✓ | ✓ |
filetime | 0.2.29 | MIT/Apache-2.0 | ✓ | ✓ |
find-msvc-tools | 0.1.14 | MIT OR Apache-2.0 | ✓ | ✓ |
flate2 | 1.1.10 | MIT OR Apache-2.0 | ✓ | ✓ |
fnv | 1.0.7 | Apache-2.0 / MIT | ✓ | ✓ |
foldhash | 0.2.0 | Zlib | ✓ | ✓ |
form_urlencoded | 1.2.2 | MIT OR Apache-2.0 | ✓ | ✓ |
from_variant | 3.0.0 | Apache-2.0 | ✓ | ✓ |
fs_extra | 1.3.0 | MIT | ✓ | ✓ |
fslock | 0.2.1 | MIT | ✓ | ✓ |
funty | 2.0.0 | MIT | ✓ | ✓ |
futures | 0.3.34 | MIT OR Apache-2.0 | ✓ | ✓ |
futures-channel | 0.3.34 | MIT OR Apache-2.0 | ✓ | ✓ |
futures-core | 0.3.34 | MIT OR Apache-2.0 | ✓ | ✓ |
futures-executor | 0.3.34 | MIT OR Apache-2.0 | ✓ | ✓ |
futures-intrusive | 0.5.0 | MIT OR Apache-2.0 | ✓ | ✓ |
futures-io | 0.3.34 | MIT OR Apache-2.0 | ✓ | ✓ |
futures-macro | 0.3.34 | MIT OR Apache-2.0 | ✓ | ✓ |
futures-sink | 0.3.34 | MIT OR Apache-2.0 | ✓ | ✓ |
futures-task | 0.3.34 | MIT OR Apache-2.0 | ✓ | ✓ |
futures-timer | 3.0.4 | MIT/Apache-2.0 | ✓ | ✓ |
futures-util | 0.3.34 | MIT OR Apache-2.0 | ✓ | ✓ |
generic-array | 0.14.7 | MIT | ✓ | ✓ |
generic-array | 1.4.5 | MIT | ✓ | ✓ |
getrandom | 0.3.4 | MIT OR Apache-2.0 | ✓ | |
getrandom | 0.4.3 | MIT OR Apache-2.0 | ✓ | ✓ |
ghash | 0.6.0 | Apache-2.0 OR MIT | ✓ | ✓ |
glob | 0.3.4 | MIT OR Apache-2.0 | ✓ | ✓ |
group | 0.14.0 | MIT/Apache-2.0 | ✓ | ✓ |
gzip-header | 1.1.0 | MIT/Apache-2.0 | ✓ | ✓ |
h2 | 0.4.19 | MIT | ✓ | ✓ |
hashbrown | 0.14.5 | MIT OR Apache-2.0 | ✓ | ✓ |
hashbrown | 0.16.1 | MIT OR Apache-2.0 | ✓ | ✓ |
hashbrown | 0.17.1 | MIT OR Apache-2.0 | ✓ | ✓ |
hashlink | 0.11.1 | MIT OR Apache-2.0 | ✓ | ✓ |
heck | 0.5.0 | MIT OR Apache-2.0 | ✓ | ✓ |
hex | 0.4.3 | MIT OR Apache-2.0 | ✓ | ✓ |
hex-literal | 1.1.0 | MIT OR Apache-2.0 | ✓ | ✓ |
hkdf | 0.13.0 | MIT OR Apache-2.0 | ✓ | ✓ |
hmac | 0.13.0 | MIT OR Apache-2.0 | ✓ | ✓ |
home | 0.5.12 | MIT OR Apache-2.0 | ✓ | ✓ |
hostname | 0.4.2 | MIT | ✓ | ✓ |
hstr | 3.0.6 | Apache-2.0 | ✓ | ✓ |
http | 1.5.0 | MIT OR Apache-2.0 | ✓ | ✓ |
http-body | 1.1.0 | MIT | ✓ | ✓ |
http-body-util | 0.1.5 | MIT | ✓ | ✓ |
httparse | 1.10.1 | MIT OR Apache-2.0 | ✓ | ✓ |
httpdate | 1.0.3 | MIT OR Apache-2.0 | ✓ | ✓ |
hybrid-array | 0.4.15 | MIT OR Apache-2.0 | ✓ | ✓ |
hyper | 1.11.1 | MIT | ✓ | ✓ |
hyper-rustls | 0.27.10 | Apache-2.0 OR ISC OR MIT | ✓ | ✓ |
hyper-util | 0.1.21 | MIT | ✓ | ✓ |
iana-time-zone | 0.1.65 | MIT OR Apache-2.0 | ✓ | ✓ |
icu_calendar | 2.3.0 | Unicode-3.0 | ✓ | ✓ |
icu_calendar_data | 2.3.0 | Unicode-3.0 | ✓ | ✓ |
icu_collections | 2.3.0 | Unicode-3.0 | ✓ | ✓ |
icu_locale_core | 2.3.0 | Unicode-3.0 | ✓ | ✓ |
icu_locale_fallback | 2.3.0 | Unicode-3.0 | ✓ | ✓ |
icu_locale_fallback_data | 2.3.0 | Unicode-3.0 | ✓ | ✓ |
icu_normalizer | 2.3.0 | Unicode-3.0 | ✓ | ✓ |
icu_normalizer_data | 2.3.0 | Unicode-3.0 | ✓ | ✓ |
icu_properties | 2.3.0 | Unicode-3.0 | ✓ | ✓ |
icu_properties_data | 2.3.0 | Unicode-3.0 | ✓ | ✓ |
icu_provider | 2.3.1 | Unicode-3.0 | ✓ | ✓ |
ident_case | 1.0.1 | MIT/Apache-2.0 | ✓ | ✓ |
idna | 1.1.0 | MIT OR Apache-2.0 | ✓ | ✓ |
idna_adapter | 1.2.2 | Apache-2.0 OR MIT | ✓ | ✓ |
if_chain | 1.0.3 | MIT/Apache-2.0 | ✓ | ✓ |
indexmap | 2.14.2 | Apache-2.0 OR MIT | ✓ | ✓ |
inout | 0.2.2 | MIT OR Apache-2.0 | ✓ | ✓ |
inventory | 0.3.24 | MIT OR Apache-2.0 | ✓ | ✓ |
ipnet | 2.12.2 | MIT OR Apache-2.0 | ✓ | ✓ |
is_terminal_polyfill | 1.70.2 | MIT OR Apache-2.0 | ✓ | ✓ |
is-macro | 0.3.8 | Apache-2.0 | ✓ | ✓ |
itertools | 0.13.0 | MIT OR Apache-2.0 | ✓ | ✓ |
itoa | 1.0.18 | MIT OR Apache-2.0 | ✓ | ✓ |
ixdtf | 0.6.6 | Unicode-3.0 | ✓ | ✓ |
jobserver | 0.1.35 | MIT OR Apache-2.0 | ✓ | ✓ |
keccak | 0.2.2 | Apache-2.0 OR MIT | ✓ | ✓ |
kem | 0.3.0 | Apache-2.0 OR MIT | ✓ | ✓ |
lazy_static | 1.5.1 | MIT OR Apache-2.0 | ✓ | ✓ |
lber | 0.5.1 | MIT | ✓ | ✓ |
ldap3 | 0.12.1 | MIT/Apache-2.0 | ✓ | ✓ |
lettre | 0.11.23 | MIT | ✓ | ✓ |
libc | 0.2.190 | MIT OR Apache-2.0 | ✓ | ✓ |
libloading | 0.8.9 | ISC | ✓ | ✓ |
liblzma | 0.4.8 | MIT OR Apache-2.0 | ✓ | ✓ |
liblzma-sys | 0.4.9 | MIT OR Apache-2.0 | ✓ | ✓ |
libm | 0.2.16 | MIT | ✓ | ✓ |
linux-raw-sys | 0.4.15 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | ✓ | ✓ |
linux-raw-sys | 0.12.1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | ✓ | ✓ |
litemap | 0.8.3 | Unicode-3.0 | ✓ | ✓ |
lock_api | 0.4.14 | MIT OR Apache-2.0 | ✓ | ✓ |
log | 0.4.34 | MIT OR Apache-2.0 | ✓ | ✓ |
matchers | 0.2.0 | MIT | ✓ | ✓ |
matchit | 0.8.4 | MIT AND BSD-3-Clause | ✓ | ✓ |
md-5 | 0.10.6 | MIT OR Apache-2.0 | ✓ | ✓ |
md-5 | 0.11.0 | MIT OR Apache-2.0 | ✓ | ✓ |
md4 | 0.11.0 | MIT OR Apache-2.0 | ✓ | |
md5 | 0.8.1 | Apache-2.0 OR MIT | ✓ | ✓ |
memchr | 2.8.3 | Unlicense OR MIT | ✓ | ✓ |
mime | 0.3.17 | MIT OR Apache-2.0 | ✓ | ✓ |
minimal-lexical | 0.2.1 | MIT/Apache-2.0 | ✓ | ✓ |
miniz_oxide | 0.8.9 | MIT OR Zlib OR Apache-2.0 | ✓ | ✓ |
miniz_oxide | 0.9.1 | MIT OR Zlib OR Apache-2.0 | ✓ | ✓ |
mio | 1.2.4 | MIT | ✓ | ✓ |
ml-kem | 0.3.2 | Apache-2.0 OR MIT | ✓ | ✓ |
module-lattice | 0.2.3 | Apache-2.0 OR MIT | ✓ | ✓ |
multiversion_no_op | 1.0.0 | Apache-2.0 OR MIT | ✓ | ✓ |
new_debug_unreachable | 1.0.6 | MIT | ✓ | ✓ |
nix | 0.31.3 | MIT | ✓ | |
nom | 7.1.3 | MIT | ✓ | ✓ |
nom | 8.0.0 | MIT | ✓ | ✓ |
nu-ansi-term | 0.50.3 | MIT | ✓ | ✓ |
num_cpus | 1.17.0 | MIT OR Apache-2.0 | ✓ | ✓ |
num-bigint | 0.4.8 | MIT OR Apache-2.0 | ✓ | ✓ |
num-bigint | 0.5.1 | MIT OR Apache-2.0 | ✓ | ✓ |
num-conv | 0.2.2 | MIT OR Apache-2.0 | ✓ | ✓ |
num-derive | 0.5.1 | MIT OR Apache-2.0 | ✓ | |
num-integer | 0.1.47 | MIT OR Apache-2.0 | ✓ | ✓ |
num-traits | 0.2.19 | MIT OR Apache-2.0 | ✓ | ✓ |
object | 0.39.1 | Apache-2.0 OR MIT | ✓ | ✓ |
oid | 0.2.1 | MIT OR Apache-2.0 | ✓ | |
oid-registry | 0.8.1 | MIT OR Apache-2.0 | ✓ | ✓ |
once_cell | 1.21.4 | MIT OR Apache-2.0 | ✓ | ✓ |
once_cell_polyfill | 1.70.2 | MIT OR Apache-2.0 | ✓ | |
openssl-probe | 0.2.1 | MIT OR Apache-2.0 | ✓ | ✓ |
outref | 0.5.2 | MIT | ✓ | ✓ |
p256 | 0.14.0 | Apache-2.0 OR MIT | ✓ | ✓ |
p384 | 0.14.0 | Apache-2.0 OR MIT | ✓ | ✓ |
p521 | 0.14.0 | Apache-2.0 OR MIT | ✓ | ✓ |
pageant | 0.2.4 | Apache-2.0 | ✓ | |
par-core | 2.0.0 | Apache-2.0 | ✓ | ✓ |
parking | 2.2.1 | Apache-2.0 OR MIT | ✓ | ✓ |
parking_lot | 0.12.5 | MIT OR Apache-2.0 | ✓ | ✓ |
parking_lot_core | 0.9.12 | MIT OR Apache-2.0 | ✓ | ✓ |
password-hash | 0.5.0 | MIT OR Apache-2.0 | ✓ | ✓ |
paste | 1.0.15 | MIT OR Apache-2.0 | ✓ | ✓ |
pastey | 0.2.3 | MIT OR Apache-2.0 | ✓ | ✓ |
pathdiff | 0.2.3 | MIT/Apache-2.0 | ✓ | ✓ |
pbkdf2 | 0.13.0 | MIT OR Apache-2.0 | ✓ | ✓ |
pem | 4.0.0 | MIT | ✓ | ✓ |
pem-rfc7468 | 1.0.0 | Apache-2.0 OR MIT | ✓ | ✓ |
percent-encoding | 2.3.2 | MIT OR Apache-2.0 | ✓ | ✓ |
phf | 0.11.3 | MIT | ✓ | ✓ |
phf_generator | 0.11.3 | MIT | ✓ | ✓ |
phf_macros | 0.11.3 | MIT | ✓ | ✓ |
phf_shared | 0.11.3 | MIT | ✓ | ✓ |
picky | 7.0.0-rc.26 | MIT OR Apache-2.0 | ✓ | |
picky-asn1 | 0.10.1 | MIT OR Apache-2.0 | ✓ | |
picky-asn1-der | 0.5.7 | MIT OR Apache-2.0 | ✓ | |
picky-asn1-x509 | 0.15.5 | MIT OR Apache-2.0 | ✓ | |
picky-krb | 0.12.4 | MIT OR Apache-2.0 | ✓ | |
pin-project | 1.1.13 | Apache-2.0 OR MIT | ✓ | ✓ |
pin-project-internal | 1.1.13 | Apache-2.0 OR MIT | ✓ | ✓ |
pin-project-lite | 0.2.17 | Apache-2.0 OR MIT | ✓ | ✓ |
pkcs1 | 0.8.0-rc.4 | Apache-2.0 OR MIT | ✓ | ✓ |
pkcs5 | 0.8.1 | Apache-2.0 OR MIT | ✓ | ✓ |
pkcs8 | 0.11.0 | Apache-2.0 OR MIT | ✓ | ✓ |
pkg-config | 0.3.34 | MIT OR Apache-2.0 | ✓ | ✓ |
poly1305 | 0.9.1 | Apache-2.0 OR MIT | ✓ | ✓ |
polyval | 0.7.3 | Apache-2.0 OR MIT | ✓ | ✓ |
postgresql_archive | 0.21.0 | (Apache-2.0 OR MIT) AND PostgreSQL | ✓ | ✓ |
postgresql_commands | 0.21.0 | (Apache-2.0 OR MIT) AND PostgreSQL | ✓ | ✓ |
postgresql_embedded | 0.21.0 | (Apache-2.0 OR MIT) AND PostgreSQL | ✓ | ✓ |
potential_utf | 0.1.6 | Unicode-3.0 | ✓ | ✓ |
powerfmt | 0.2.1 | MIT OR Apache-2.0 | ✓ | ✓ |
pretty-hex | 0.4.2 | MIT | ✓ | ✓ |
prettyplease | 0.2.37 | MIT OR Apache-2.0 | ✓ | ✓ |
primefield | 0.14.0 | Apache-2.0 OR MIT | ✓ | ✓ |
primeorder | 0.14.0 | Apache-2.0 OR MIT | ✓ | ✓ |
proc-macro2 | 1.0.107 | MIT OR Apache-2.0 | ✓ | ✓ |
psm | 0.1.32 | MIT OR Apache-2.0 | ✓ | ✓ |
quick-xml | 0.41.0 | MIT | ✓ | ✓ |
quote | 1.0.47 | MIT OR Apache-2.0 | ✓ | ✓ |
quoted_printable | 0.5.2 | 0BSD | ✓ | ✓ |
radium | 0.7.0 | MIT | ✓ | ✓ |
rand | 0.8.8 | MIT OR Apache-2.0 | ✓ | ✓ |
rand | 0.10.3 | MIT OR Apache-2.0 | ✓ | ✓ |
rand_core | 0.6.4 | MIT OR Apache-2.0 | ✓ | ✓ |
rand_core | 0.10.1 | MIT OR Apache-2.0 | ✓ | ✓ |
rcgen | 0.14.10 | MIT OR Apache-2.0 | ✓ | ✓ |
regex | 1.13.1 | MIT OR Apache-2.0 | ✓ | ✓ |
regex-automata | 0.4.18 | MIT OR Apache-2.0 | ✓ | ✓ |
regex-lite | 0.1.9 | MIT OR Apache-2.0 | ✓ | ✓ |
regex-syntax | 0.8.11 | MIT OR Apache-2.0 | ✓ | ✓ |
reqwest | 0.13.5 | MIT OR Apache-2.0 | ✓ | ✓ |
reqwest-middleware | 0.5.2 | MIT OR Apache-2.0 | ✓ | ✓ |
reqwest-retry | 0.9.1 | MIT OR Apache-2.0 | ✓ | ✓ |
reqwest-tracing | 0.7.1 | MIT OR Apache-2.0 | ✓ | ✓ |
resb | 0.1.2 | Unicode-3.0 | ✓ | ✓ |
retry-policies | 0.5.2 | MIT OR Apache-2.0 | ✓ | ✓ |
rfc6979 | 0.6.0 | Apache-2.0 OR MIT | ✓ | ✓ |
rpassword | 7.5.4 | Apache-2.0 | ✓ | ✓ |
rsa | 0.10.0-rc.18 | MIT OR Apache-2.0 | ✓ | ✓ |
rtoolbox | 0.0.6 | Apache-2.0 | ✓ | ✓ |
russh | 0.64.1 | Apache-2.0 | ✓ | ✓ |
russh-cryptovec | 0.62.0 | Apache-2.0 | ✓ | ✓ |
russh-sftp | 3.0.1 | Apache-2.0 | ✓ | ✓ |
russh-util | 0.52.0 | Apache-2.0 | ✓ | ✓ |
rustc_version | 0.4.1 | MIT OR Apache-2.0 | ✓ | ✓ |
rustc-hash | 2.1.3 | Apache-2.0 OR MIT | ✓ | ✓ |
rusticata-macros | 4.1.0 | MIT/Apache-2.0 | ✓ | ✓ |
rustix | 0.38.44 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | ✓ | ✓ |
rustix | 1.1.5 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | ✓ | ✓ |
rustls | 0.23.45 | Apache-2.0 OR ISC OR MIT | ✓ | ✓ |
rustls-native-certs | 0.8.4 | Apache-2.0 OR ISC OR MIT | ✓ | ✓ |
rustls-pki-types | 1.15.1 | MIT OR Apache-2.0 | ✓ | ✓ |
rustls-platform-verifier | 0.7.1 | MIT OR Apache-2.0 | ✓ | ✓ |
rustls-webpki | 0.103.15 | ISC | ✓ | ✓ |
rustversion | 1.0.23 | MIT OR Apache-2.0 | ✓ | ✓ |
ryu | 1.0.23 | Apache-2.0 OR BSL-1.0 | ✓ | ✓ |
ryu-js | 1.0.3 | Apache-2.0 OR BSL-1.0 | ✓ | ✓ |
salsa20 | 0.11.0 | MIT OR Apache-2.0 | ✓ | ✓ |
schannel | 0.1.29 | MIT | ✓ | |
scoped-tls | 1.0.1 | MIT/Apache-2.0 | ✓ | ✓ |
scopeguard | 1.2.0 | MIT OR Apache-2.0 | ✓ | ✓ |
scrypt | 0.12.0 | MIT OR Apache-2.0 | ✓ | ✓ |
sec1 | 0.8.1 | Apache-2.0 OR MIT | ✓ | ✓ |
secrecy | 0.10.3 | Apache-2.0 OR MIT | ✓ | ✓ |
semver | 1.0.28 | MIT OR Apache-2.0 | ✓ | ✓ |
seq-macro | 0.3.6 | MIT OR Apache-2.0 | ✓ | ✓ |
serde | 1.0.229 | MIT OR Apache-2.0 | ✓ | ✓ |
serde_bytes | 0.11.19 | MIT OR Apache-2.0 | ✓ | ✓ |
serde_core | 1.0.229 | MIT OR Apache-2.0 | ✓ | ✓ |
serde_derive | 1.0.229 | MIT OR Apache-2.0 | ✓ | ✓ |
serde_json | 1.0.151 | MIT OR Apache-2.0 | ✓ | ✓ |
serde_path_to_error | 0.1.20 | MIT OR Apache-2.0 | ✓ | ✓ |
serde_spanned | 1.1.2 | MIT OR Apache-2.0 | ✓ | ✓ |
serde_urlencoded | 0.7.1 | MIT/Apache-2.0 | ✓ | ✓ |
serde_v8 | 0.321.0 | MIT | ✓ | ✓ |
serde_yaml_ng | 0.10.0 | MIT | ✓ | ✓ |
sha1 | 0.10.7 | MIT OR Apache-2.0 | ✓ | ✓ |
sha1 | 0.11.0 | MIT OR Apache-2.0 | ✓ | ✓ |
sha2 | 0.10.9 | MIT OR Apache-2.0 | ✓ | ✓ |
sha2 | 0.11.0 | MIT OR Apache-2.0 | ✓ | ✓ |
sha3 | 0.11.0 | MIT OR Apache-2.0 | ✓ | ✓ |
sha3 | 0.12.0 | MIT OR Apache-2.0 | ✓ | ✓ |
sharded-slab | 0.1.7 | MIT | ✓ | ✓ |
shlex | 1.3.0 | MIT OR Apache-2.0 | ✓ | ✓ |
shlex | 2.0.1 | MIT OR Apache-2.0 | ✓ | ✓ |
signal-hook-registry | 1.4.8 | MIT OR Apache-2.0 | ✓ | ✓ |
signature | 3.0.0 | Apache-2.0 OR MIT | ✓ | ✓ |
simd-adler32 | 0.3.10 | MIT | ✓ | ✓ |
simdutf8 | 0.1.5 | MIT OR Apache-2.0 | ✓ | ✓ |
siphasher | 0.3.11 | MIT/Apache-2.0 | ✓ | ✓ |
siphasher | 1.0.4 | MIT OR Apache-2.0 | ✓ | ✓ |
slab | 0.4.12 | MIT | ✓ | ✓ |
smallvec | 1.16.2 | MIT OR Apache-2.0 | ✓ | ✓ |
smartstring | 1.0.1 | MPL-2.0+ | ✓ | ✓ |
socket2 | 0.6.5 | MIT OR Apache-2.0 | ✓ | ✓ |
sourcemap | 9.3.2 | BSD-3-Clause | ✓ | ✓ |
spki | 0.8.1 | Apache-2.0 OR MIT | ✓ | ✓ |
sponge-cursor | 0.1.0 | MIT OR Apache-2.0 | ✓ | ✓ |
sqlx | 0.9.0 | MIT OR Apache-2.0 | ✓ | ✓ |
sqlx-core | 0.9.0 | MIT OR Apache-2.0 | ✓ | ✓ |
sqlx-macros | 0.9.0 | MIT OR Apache-2.0 | ✓ | ✓ |
sqlx-macros-core | 0.9.0 | MIT OR Apache-2.0 | ✓ | ✓ |
sqlx-postgres | 0.9.0 | MIT OR Apache-2.0 | ✓ | ✓ |
ssh-cipher | 0.3.0 | Apache-2.0 OR MIT | ✓ | ✓ |
ssh-encoding | 0.3.0 | Apache-2.0 OR MIT | ✓ | ✓ |
ssh-key | 0.7.0-rc.11 | Apache-2.0 OR MIT | ✓ | ✓ |
sspi | 0.23.0 | MIT OR Apache-2.0 | ✓ | |
stable_deref_trait | 1.2.1 | MIT OR Apache-2.0 | ✓ | ✓ |
stacker | 0.1.25 | MIT OR Apache-2.0 | ✓ | ✓ |
static_assertions | 1.1.0 | MIT OR Apache-2.0 | ✓ | ✓ |
strck | 1.0.0 | MIT | ✓ | ✓ |
string_enum | 1.0.2 | Apache-2.0 | ✓ | ✓ |
stringcase | 0.4.0 | MIT | ✓ | ✓ |
stringprep | 0.1.5 | MIT/Apache-2.0 | ✓ | ✓ |
strsim | 0.11.1 | MIT | ✓ | ✓ |
strum | 0.27.2 | MIT | ✓ | ✓ |
strum_macros | 0.27.2 | MIT | ✓ | ✓ |
subtle | 2.6.1 | BSD-3-Clause | ✓ | ✓ |
swc_allocator | 4.0.1 | Apache-2.0 | ✓ | ✓ |
swc_atoms | 9.0.0 | Apache-2.0 | ✓ | ✓ |
swc_common | 17.0.1 | Apache-2.0 | ✓ | ✓ |
swc_config | 3.1.2 | Apache-2.0 | ✓ | ✓ |
swc_config_macro | 1.0.1 | Apache-2.0 | ✓ | ✓ |
swc_ecma_ast | 18.0.0 | Apache-2.0 | ✓ | ✓ |
swc_ecma_codegen | 20.0.2 | Apache-2.0 | ✓ | ✓ |
swc_ecma_codegen_macros | 2.0.2 | Apache-2.0 | ✓ | ✓ |
swc_ecma_lexer | 26.0.0 | Apache-2.0 | ✓ | ✓ |
swc_ecma_loader | 17.0.0 | Apache-2.0 | ✓ | ✓ |
swc_ecma_parser | 27.0.7 | Apache-2.0 | ✓ | ✓ |
swc_ecma_transforms_base | 30.0.1 | Apache-2.0 | ✓ | ✓ |
swc_ecma_transforms_classes | 30.0.0 | Apache-2.0 | ✓ | ✓ |
swc_ecma_transforms_macros | 1.0.1 | Apache-2.0 | ✓ | ✓ |
swc_ecma_transforms_proposal | 30.0.0 | Apache-2.0 | ✓ | ✓ |
swc_ecma_transforms_react | 33.0.0 | Apache-2.0 | ✓ | ✓ |
swc_ecma_transforms_typescript | 33.0.0 | Apache-2.0 | ✓ | ✓ |
swc_ecma_utils | 24.0.0 | Apache-2.0 | ✓ | ✓ |
swc_ecma_visit | 18.0.1 | Apache-2.0 | ✓ | ✓ |
swc_eq_ignore_macros | 1.0.1 | Apache-2.0 | ✓ | ✓ |
swc_macros_common | 1.0.1 | Apache-2.0 | ✓ | ✓ |
swc_sourcemap | 9.3.4 | BSD-3-Clause | ✓ | ✓ |
swc_visit | 2.0.1 | Apache-2.0 | ✓ | ✓ |
symlink | 0.1.0 | MIT/Apache-2.0 | ✓ | |
syn | 2.0.119 | MIT OR Apache-2.0 | ✓ | ✓ |
syn | 3.0.6 | MIT OR Apache-2.0 | ✓ | ✓ |
syn-match | 0.3.1 | MIT | ✓ | ✓ |
sync_wrapper | 1.0.2 | Apache-2.0 | ✓ | ✓ |
synstructure | 0.13.2 | MIT | ✓ | ✓ |
synstructure | 0.14.0 | MIT | ✓ | ✓ |
sys_traits | 0.1.29 | MIT | ✓ | ✓ |
sys_traits_macros | 0.1.0 | MIT | ✓ | ✓ |
tap | 1.0.1 | MIT | ✓ | ✓ |
tar | 0.4.46 | MIT OR Apache-2.0 | ✓ | ✓ |
target-triple | 1.0.1 | MIT OR Apache-2.0 | ✓ | ✓ |
tempfile | 3.27.0 | MIT OR Apache-2.0 | ✓ | ✓ |
temporal_capi | 0.2.6 | MIT OR Apache-2.0 | ✓ | ✓ |
temporal_rs | 0.2.6 | MIT OR Apache-2.0 | ✓ | ✓ |
termcolor | 1.4.1 | Unlicense OR MIT | ✓ | ✓ |
text_lines | 0.6.0 | MIT | ✓ | ✓ |
thiserror | 2.0.21 | MIT OR Apache-2.0 | ✓ | ✓ |
thiserror-impl | 2.0.21 | MIT OR Apache-2.0 | ✓ | ✓ |
thread_local | 1.1.10 | MIT OR Apache-2.0 | ✓ | ✓ |
tiberius (from https://github.com/thorst/tiberius?rev=94fea3f1491fe85862590fee8ce7cafa647a8e54) | 0.13.0 | MIT/Apache-2.0 | ✓ | ✓ |
time | 0.3.55 | MIT OR Apache-2.0 | ✓ | ✓ |
time-core | 0.1.9 | MIT OR Apache-2.0 | ✓ | ✓ |
time-macros | 0.2.32 | MIT OR Apache-2.0 | ✓ | ✓ |
timezone_provider | 0.2.6 | MIT OR Apache-2.0 | ✓ | ✓ |
tinystr | 0.8.4 | Unicode-3.0 | ✓ | ✓ |
tinyvec | 1.13.3 | Zlib OR Apache-2.0 OR MIT | ✓ | ✓ |
tokio | 1.53.2 | MIT | ✓ | ✓ |
tokio-macros | 2.7.2 | MIT | ✓ | ✓ |
tokio-rustls | 0.26.6 | MIT OR Apache-2.0 | ✓ | ✓ |
tokio-stream | 0.1.19 | MIT | ✓ | ✓ |
tokio-util | 0.7.19 | MIT | ✓ | ✓ |
toml | 1.1.8+spec-1.1.0 | MIT OR Apache-2.0 | ✓ | ✓ |
toml_datetime | 1.1.2+spec-1.1.0 | MIT OR Apache-2.0 | ✓ | ✓ |
toml_parser | 1.1.5+spec-1.1.0 | MIT OR Apache-2.0 | ✓ | ✓ |
toml_writer | 1.1.3+spec-1.1.0 | MIT OR Apache-2.0 | ✓ | ✓ |
tower | 0.5.3 | MIT | ✓ | ✓ |
tower-http | 0.6.11 | MIT | ✓ | ✓ |
tower-layer | 0.3.3 | MIT | ✓ | ✓ |
tower-service | 0.3.3 | MIT | ✓ | ✓ |
tracing | 0.1.44 | MIT | ✓ | ✓ |
tracing-appender | 0.2.5 | MIT | ✓ | |
tracing-attributes | 0.1.31 | MIT | ✓ | ✓ |
tracing-core | 0.1.36 | MIT | ✓ | ✓ |
tracing-log | 0.2.0 | MIT | ✓ | ✓ |
tracing-subscriber | 0.3.23 | MIT | ✓ | ✓ |
triomphe | 0.1.16 | MIT OR Apache-2.0 | ✓ | ✓ |
try-lock | 0.2.5 | MIT | ✓ | ✓ |
typed-path | 0.12.3 | MIT OR Apache-2.0 | ✓ | ✓ |
typenum | 1.20.1 | MIT OR Apache-2.0 | ✓ | ✓ |
unicode-bidi | 0.3.18 | MIT OR Apache-2.0 | ✓ | ✓ |
unicode-id-start | 1.5.0 | (MIT OR Apache-2.0) AND Unicode-3.0 | ✓ | ✓ |
unicode-ident | 1.0.26 | (MIT OR Apache-2.0) AND Unicode-3.0 | ✓ | ✓ |
unicode-normalization | 0.1.25 | MIT OR Apache-2.0 | ✓ | ✓ |
unicode-properties | 0.1.4 | MIT/Apache-2.0 | ✓ | ✓ |
unicode-width | 0.2.2 | MIT OR Apache-2.0 | ✓ | ✓ |
universal-hash | 0.6.1 | MIT OR Apache-2.0 | ✓ | ✓ |
unsafe-libyaml | 0.2.11 | MIT | ✓ | ✓ |
untrusted | 0.7.1 | ISC | ✓ | ✓ |
untrusted | 0.9.0 | ISC | ✓ | ✓ |
url | 2.5.8 | MIT OR Apache-2.0 | ✓ | ✓ |
utf8_iter | 1.0.4 | Apache-2.0 OR MIT | ✓ | ✓ |
utf8parse | 0.2.2 | Apache-2.0 OR MIT | ✓ | ✓ |
utoipa | 6.0.0 | MIT OR Apache-2.0 | ✓ | ✓ |
utoipa-axum | 0.3.0 | MIT OR Apache-2.0 | ✓ | ✓ |
utoipa-gen | 6.0.1 | MIT OR Apache-2.0 | ✓ | ✓ |
uuid | 1.27.0 | Apache-2.0 OR MIT | ✓ | ✓ |
v8 | 150.4.0 | MIT | ✓ | ✓ |
version_check | 0.9.5 | MIT/Apache-2.0 | ✓ | ✓ |
vsimd | 0.8.0 | MIT | ✓ | ✓ |
want | 0.3.2 | MIT | ✓ | ✓ |
wasm_dep_analyzer | 0.4.0 | MIT | ✓ | ✓ |
webpki-roots | 1.0.9 | CDLA-Permissive-2.0 | ✓ | ✓ |
which | 6.0.3 | MIT | ✓ | ✓ |
whoami | 2.1.3 | Apache-2.0 OR BSL-1.0 OR MIT | ✓ | ✓ |
widestring | 1.2.1 | MIT OR Apache-2.0 | ✓ | ✓ |
winapi-util | 0.1.11 | Unlicense OR MIT | ✓ | |
windows | 0.62.2 | MIT OR Apache-2.0 | ✓ | |
windows_x86_64_msvc | 0.52.6 | MIT OR Apache-2.0 | ✓ | |
windows-collections | 0.3.2 | MIT OR Apache-2.0 | ✓ | |
windows-core | 0.62.2 | MIT OR Apache-2.0 | ✓ | |
windows-future | 0.3.2 | MIT OR Apache-2.0 | ✓ | |
windows-implement | 0.60.2 | MIT OR Apache-2.0 | ✓ | |
windows-interface | 0.59.3 | MIT OR Apache-2.0 | ✓ | |
windows-link | 0.2.1 | MIT OR Apache-2.0 | ✓ | |
windows-numerics | 0.3.1 | MIT OR Apache-2.0 | ✓ | |
windows-result | 0.4.1 | MIT OR Apache-2.0 | ✓ | |
windows-service | 0.8.1 | MIT OR Apache-2.0 | ✓ | |
windows-strings | 0.5.1 | MIT OR Apache-2.0 | ✓ | |
windows-sys | 0.59.0 | MIT OR Apache-2.0 | ✓ | |
windows-sys | 0.61.2 | MIT OR Apache-2.0 | ✓ | |
windows-targets | 0.52.6 | MIT OR Apache-2.0 | ✓ | |
windows-threading | 0.2.1 | MIT OR Apache-2.0 | ✓ | |
winnow | 1.0.4 | MIT | ✓ | ✓ |
wnaf | 0.14.1 | Apache-2.0 OR MIT | ✓ | ✓ |
writeable | 0.6.4 | Unicode-3.0 | ✓ | ✓ |
wyz | 0.5.1 | MIT | ✓ | ✓ |
x25519-dalek | 3.0.0 | BSD-3-Clause | ✓ | |
x509-parser | 0.18.1 | MIT OR Apache-2.0 | ✓ | ✓ |
xattr | 1.6.1 | MIT OR Apache-2.0 | ✓ | ✓ |
yasna | 0.6.0 | MIT OR Apache-2.0 | ✓ | ✓ |
yoke | 0.8.3 | Unicode-3.0 | ✓ | ✓ |
yoke-derive | 0.8.4 | Unicode-3.0 | ✓ | ✓ |
zerocopy | 0.8.60 | BSD-2-Clause OR Apache-2.0 OR MIT | ✓ | ✓ |
zerofrom | 0.1.8 | Unicode-3.0 | ✓ | ✓ |
zerofrom-derive | 0.1.8 | Unicode-3.0 | ✓ | ✓ |
zeroize | 1.9.0 | Apache-2.0 OR MIT | ✓ | ✓ |
zeroize_derive | 1.5.0 | Apache-2.0 OR MIT | ✓ | |
zerotrie | 0.2.5 | Unicode-3.0 | ✓ | ✓ |
zerovec | 0.11.8 | Unicode-3.0 | ✓ | ✓ |
zerovec-derive | 0.11.6 | Unicode-3.0 | ✓ | ✓ |
zip | 8.6.0 | MIT | ✓ | ✓ |
zlib-rs | 0.6.8 | Zlib | ✓ | ✓ |
zmij | 1.0.23 | MIT | ✓ | ✓ |
zoneinfo64 | 0.3.0 | Unicode-3.0 | ✓ | ✓ |
zopfli | 0.8.4 | Apache-2.0 | ✓ | ✓ |
Every npm package in the web UI
The packages its production build bundles (the build tools and test tools aren't shipped).
| Package | Version | License |
|---|---|---|
@floating-ui/core | 1.8.0 | MIT |
@floating-ui/dom | 1.8.0 | MIT |
@floating-ui/react-dom | 2.1.9 | MIT |
@floating-ui/utils | 0.2.12 | MIT |
@radix-ui/number | 1.1.3 | MIT |
@radix-ui/primitive | 1.1.7 | MIT |
@radix-ui/react-accessible-icon | 1.1.16 | MIT |
@radix-ui/react-accordion | 1.2.21 | MIT |
@radix-ui/react-alert-dialog | 1.1.24 | MIT |
@radix-ui/react-arrow | 1.1.16 | MIT |
@radix-ui/react-aspect-ratio | 1.1.16 | MIT |
@radix-ui/react-avatar | 1.2.7 | MIT |
@radix-ui/react-checkbox | 1.3.12 | MIT |
@radix-ui/react-collapsible | 1.1.21 | MIT |
@radix-ui/react-collection | 1.1.16 | MIT |
@radix-ui/react-compose-refs | 1.1.5 | MIT |
@radix-ui/react-context | 1.2.2 | MIT |
@radix-ui/react-context-menu | 2.3.8 | MIT |
@radix-ui/react-dialog | 1.2.0 | MIT |
@radix-ui/react-direction | 1.1.5 | MIT |
@radix-ui/react-dismissable-layer | 1.1.20 | MIT |
@radix-ui/react-dropdown-menu | 2.1.25 | MIT |
@radix-ui/react-focus-guards | 1.1.6 | MIT |
@radix-ui/react-focus-scope | 1.2.0 | MIT |
@radix-ui/react-form | 0.2.0 | MIT |
@radix-ui/react-hover-card | 1.1.24 | MIT |
@radix-ui/react-id | 1.1.4 | MIT |
@radix-ui/react-label | 2.1.16 | MIT |
@radix-ui/react-menu | 2.1.25 | MIT |
@radix-ui/react-menubar | 1.1.25 | MIT |
@radix-ui/react-navigation-menu | 1.3.0 | MIT |
@radix-ui/react-one-time-password-field | 0.1.17 | MIT |
@radix-ui/react-password-toggle-field | 0.1.12 | MIT |
@radix-ui/react-popover | 1.2.0 | MIT |
@radix-ui/react-popper | 1.3.8 | MIT |
@radix-ui/react-portal | 1.1.18 | MIT |
@radix-ui/react-presence | 1.1.11 | MIT |
@radix-ui/react-primitive | 2.1.11 | MIT |
@radix-ui/react-progress | 1.1.17 | MIT |
@radix-ui/react-radio-group | 1.4.8 | MIT |
@radix-ui/react-roving-focus | 1.1.20 | MIT |
@radix-ui/react-scroll-area | 1.3.0 | MIT |
@radix-ui/react-select | 2.3.8 | MIT |
@radix-ui/react-separator | 1.1.16 | MIT |
@radix-ui/react-slider | 1.5.0 | MIT |
@radix-ui/react-slot | 1.4.0 | MIT |
@radix-ui/react-switch | 1.3.8 | MIT |
@radix-ui/react-tabs | 1.1.22 | MIT |
@radix-ui/react-toast | 1.2.24 | MIT |
@radix-ui/react-toggle | 1.1.19 | MIT |
@radix-ui/react-toggle-group | 1.1.20 | MIT |
@radix-ui/react-toolbar | 1.1.20 | MIT |
@radix-ui/react-tooltip | 1.3.0 | MIT |
@radix-ui/react-use-callback-ref | 1.1.4 | MIT |
@radix-ui/react-use-controllable-state | 1.2.6 | MIT |
@radix-ui/react-use-effect-event | 0.0.5 | MIT |
@radix-ui/react-use-escape-keydown | 1.1.5 | MIT |
@radix-ui/react-use-is-hydrated | 0.1.3 | MIT |
@radix-ui/react-use-layout-effect | 1.1.4 | MIT |
@radix-ui/react-use-previous | 1.1.4 | MIT |
@radix-ui/react-use-rect | 1.1.4 | MIT |
@radix-ui/react-use-size | 1.1.5 | MIT |
@radix-ui/react-visually-hidden | 1.2.12 | MIT |
@radix-ui/rect | 1.1.3 | MIT |
@tanstack/history | 1.162.4 | MIT |
@tanstack/query-core | 5.104.1 | MIT |
@tanstack/react-query | 5.104.1 | MIT |
@tanstack/react-router | 1.170.41 | MIT |
@tanstack/react-store | 0.11.2 | MIT |
@tanstack/react-table | 9.2.8 | MIT |
@tanstack/react-virtual | 3.14.14 | MIT |
@tanstack/router-core | 1.171.34 | MIT |
@tanstack/store | 0.11.2 | MIT |
@tanstack/table-core | 9.2.8 | MIT |
@tanstack/virtual-core | 3.18.0 | MIT |
@types/react | 19.3.0 | MIT |
@types/react-dom | 19.3.0 | MIT |
aria-hidden | 1.2.6 | MIT |
class-variance-authority | 0.7.1 | Apache-2.0 |
clsx | 2.1.1 | MIT |
cookie-es | 3.1.1 | MIT |
csstype | 3.2.3 | MIT |
detect-node-es | 1.1.0 | MIT |
get-nonce | 1.0.1 | MIT |
isbot | 5.2.2 | Unlicense |
lucide-react | 1.54.0 | ISC |
openapi-fetch | 0.17.0 | MIT |
openapi-typescript-helpers | 0.1.0 | MIT |
radix-ui | 1.7.0 | MIT |
react | 19.3.0 | MIT |
react-dom | 19.3.0 | MIT |
react-remove-scroll | 2.7.2 | MIT |
react-remove-scroll-bar | 2.3.8 | MIT |
react-style-singleton | 2.2.3 | MIT |
scheduler | 0.28.0 | MIT |
seroval | 1.6.9 | MIT |
seroval-plugins | 1.6.9 | MIT |
tailwind-merge | 3.7.0 | MIT |
tslib | 2.8.1 | 0BSD |
use-callback-ref | 1.3.3 | MIT |
use-sidecar | 1.1.3 | MIT |
use-sync-external-store | 1.7.0 | MIT |
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-256600576e5612a60c1d3e9015a30cf66069288187a656860e536f4562882ba566cui/package-lock.json, SHA-256aec4cea823c0e8b27156b1e5038d2521714f67461399e2431aebb321ea34ffa5