Subproject · Tool
er7-redact
Remove patient detail from a message — without moving its shape, so everything downstream still behaves the way it did on the original.
What it is
A bug report arrives with a real message attached, and somebody has to turn it into something
that can be committed to a repository, pasted into a ticket, or fed to a test suite. er7-redact does that: a policy of HL7 paths and actions, applied
to a message, plus a report of every position it changed.
The design constraint is that the redacted message has to survive everything that was going to be done to the original. So redaction rewrites leaf text only: the same segments, fields, repetitions, components, and subcomponents come back out, and every path that resolved to a value still resolves to one.
PID|1||444333222^^^ACME&1.2.840.114398.1.100&ISO^MR||EVERYWOMAN^EVE^E||19620320|FPID|1||73f419a72093eda0^^^ACME&1.2.840.114398.1.100&ISO^MR||REDACTED^REDACTED^REDACTED||1962|FThe record number is a stable pseudonym, the name is a placeholder, the birth date is a year — and the assigning authority, the identifier type, and the sex are untouched, because none of them identifies the patient.
Install
cargo add er7-redactcargo install er7-redactTutorial From a message you cannot share to one you can
1. Redact with the built-in policy
er7-redact message.er7 > redacted.er7 Or, in Rust:
use er7_redact::{Policy, Redactor};
let mut message = er7::parse(text)?;
let report = Redactor::new(Policy::patient_identifiers()).redact(&mut message);
println!("{}", message.to_er7()); The built-in policy names about forty positions across PID, NK1, PV1, GT1, and IN1 — the fields that carry a patient
identifier in every HL7 v2 release from 2.3 to 2.9.
2. Check what it did, before sharing anything
er7-redact --report message.er7 PID[1]-3[1].1.1 pseudonym
PID[1]-5[1].1.1 replace REDACTED
PID[1]-5[1].2.1 replace REDACTED
PID[1]-5[1].3.1 replace REDACTED
PID[1]-7[1].1.1 first 4 One row per position that actually changed, each a fully qualified path you can paste into er7 --query to see what is there now. A report carries no values — not the old text, not the new — so it is safe to paste into a ticket. An empty report means
nothing was found to redact, which is either good news or a wrong policy.
3. Say what you want redacted
er7-redact -r "PID-5 replace REDACTED" -r "PID-7 first 4" message.er7 Naming your own rules replaces the built-in policy rather than adding to it, so what runs can be predicted from the arguments. In Rust the same policy reads:
use er7_redact::{Action, Policy};
let policy = Policy::accept_all()
.with("PID-5", Action::redacted())?
.with("PID-7", Action::First(4))?; accept_all is the starting point that redacts nothing until a rule says so. Every
policy states one of two postures — accept by default, or reject by default —
and there is no way to leave it unstated.
Paths are er7 paths, so everything that notation does works
here: OBX-5 covers every OBX in the message, PID-13[2] names one repetition, PID-5.1 reaches one component.
4. Keep the built-in list, and pin it
er7-redact --show-policy > de-identify.policy # the built-in list, as a file
$EDITOR de-identify.policy
er7-redact --policy de-identify.policy message.er7 The built-in list may grow in a minor release — if a position turns out to carry patient detail, waiting for a major version to start redacting it would be the wrong trade — so a repository that needs the same redaction next year should check the file in.
5. When you do not trust the list
er7-redact --all-but-the-header message.er7 Invert the posture: reject by default, keep the MSH header so the
message stays routable, and name what to accept. This is the only thing that covers a local Z segment nobody has documented, and the only honest answer to “is there
anything else in there?”.
let policy = Policy::all_but_the_header()
.with("OBX-2", Action::Keep)?
.with("OBX-3", Action::Keep)?
.with("OBX-5", Action::Keep)?; A keep rule accepts the position it names; any other action rejects it. Where a position is named by both, the reject wins — whichever
order the two rules are in, and at whatever depth. A field in both lists is a policy somebody
got wrong, and redacting it is the direction that fails safely.
--reject-all goes one step further and takes the header too, which leaves the
message unroutable. Reach for it when nothing about the input is trusted.
Help Reference
The nine actions
| Written | PATID1234 becomes | Use for |
|---|---|---|
keep | PATID1234 | accepting a position, so a rejecting posture leaves it alone |
clear | | an address, a phone number — anything a placeholder would not help |
null | "" | telling a receiver to clear its stored value |
replace REDACTED | REDACTED | a name, where a placeholder reads better than a blank |
mask * | ********* | a value whose length is wanted and whose content is not |
first 4 | PATI | a birth date reduced to its year |
last 4 | 1234 | an account number reduced to the digits a human matches on |
pseudonym | 73f419a72093eda0 | an identifier that must stay linkable across messages |
(Rust only) | whatever your function returns | a real MAC, a lookup table, or a date shift — Action::custom, with no policy-file spelling |
clear and null are not interchangeable. An empty field says
“the sender said nothing about this”; "" says “delete your
stored value”. A redacted copy for testing should not be telling anything to delete a
record.
The policy file
# de-identify.policy
PID-3.1 pseudonym # keep linkage, lose the record number
PID-5 replace REDACTED
PID-7 first 4 # a birth date reduced to its year
PID-11 clear
NTE-3 clear # free text, where identifiers hide
accept # ...or "reject ACTION" for the other posture
unrecognised refuse # a payload that is not ER7 fails the run
known-values on # sweep every repeat of a value already redacted One rule per line: a path, whitespace, an action. Blank lines are ignored, # starts a comment, and rules apply in order. Four reserved first words — accept, reject, unrecognised, and known-values — set what the policy does by default rather than naming a
position, and a policy written back out states all of them, so a reader never has to infer
which default was the quiet one.
known-values on (the default) sweeps every position for a value already
redacted at a named position elsewhere in the same message, case-insensitively and on whole
words; known-values off turns that sweep off, so only the rules themselves act.
reject ACTION turns the policy from “redact these” into “redact
everything except these”, and keep is how a position is accepted back out
of it. unrecognised covers input that is not ER7 at all: it has no positions in
it, so no rule can speak to it, and the policy says outright whether it is refused, passed
through, or masked whole.
A malformed line is refused at load time, with the line number. That strictness is deliberate: a typo in a policy means a value that silently was not redacted.
Command-line options
| Option | Effect |
|---|---|
-p, --policy <FILE> | Read rules from a policy file; may be repeated |
-r, --rule <RULE> | Add one rule, e.g. "PID-5 replace REDACTED"; may be repeated |
--accept-all | Accept every value no rule names; applied last, this switches off a policy file’s reject |
--reject-all | Reject every value no rule names, the MSH header included |
--all-but-the-header | Reject every value no rule names, but keep the MSH header so the message stays routable |
-k, --key <KEY> | Pseudonym key, a number; default 0 |
-m, --message <N> | Use only the Nth message of the input |
-t, --terminator <KIND> | Segment terminator to write: cr (default), lf, crlf |
-o, --output <FILE> | Write to FILE instead of standard output |
--report | Write what would change, and not the message |
--uncovered | Write the positions no rule names, instead of the redacted message |
--show-policy | Write the policy that would be applied, and exit |
Pseudonyms
Clearing an identifier destroys the message as test data: nothing ties the patient here to the
same patient in the next message. pseudonym replaces it with a stable stand-in
instead, so every message redacted with the same key maps PATID1234 the same way, and different keys produce unrelated mappings.
A pseudonym is not a cryptographic guarantee. It is a keyed hash that preserves equality on purpose — that is what makes messages joinable, and it is also what leaks. Anyone holding the key can invert the mapping by trying every candidate identifier, and record numbers come from small spaces.
Use it inside your own trust boundary — test environments, reproductions, CI fixtures. For
data leaving it, use clear or replace, which leak nothing but the
fact that a value was there.
Examples
Seven runnable programs in the crate's examples/ directory. Each asserts its own results, so a clean exit means it passed.
| Example | Shows |
|---|---|
redact_a_message | The built-in policy end to end: what goes, what stays, and why the message still works. |
write_a_policy | Three ways to say what to redact — in Rust, from a file, or by extending a built-in. |
reject_by_default | The other posture: reject every value, with keep rules accepting what a test needs. |
pseudonyms_and_linkage | Why an identifier becomes a pseudonym rather than a blank, and what that costs. |
read_the_report | The audit trail: one row per position changed, and no values in it. |
redact_absent_empty_null | The three states HL7® keeps apart, and why redaction leaves two of them alone. |
date_shift_with_a_custom_action | A per-patient date shift built entirely on Action::custom — why there is no built-in ninth action for it. |
cargo run --example redact_a_messageWhat it does not do
This is a positional editor, not a compliance tool.
- It cannot tell you whether the result is de-identified. That is a judgement about a whole data set, its recipients, and what else they hold — made by a person who is accountable for it.
- It does not know which positions your senders use. Run
er7 message.er7and read what is actually in there before trusting any policy, including the built-in one. - It does not find an identifier written into free text unless that exact value
already turned up at a named position — the known-values sweep catches a repeat,
not a first mention. A name that appears in an
NTE-3comment and nowhere else still survives every positional policy; name that position explicitly, or reject by default. - There is no way back: no mapping table, no key escrow, no undo.
A message this crate has redacted is a message with less in it, which is progress, and is not the same thing as a safe one.