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.