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.

In
PID|1||444333222^^^ACME&1.2.840.114398.1.100&ISO^MR||EVERYWOMAN^EVE^E||19620320|F
Out
PID|1||73f419a72093eda0^^^ACME&1.2.840.114398.1.100&ISO^MR||REDACTED^REDACTED^REDACTED||1962|F

The 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

Library
cargo add er7-redact
Command line
cargo install er7-redact

Tutorial 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

WrittenPATID1234 becomesUse for
keepPATID1234accepting a position, so a rejecting posture leaves it alone
clearan address, a phone number — anything a placeholder would not help
null""telling a receiver to clear its stored value
replace REDACTEDREDACTEDa name, where a placeholder reads better than a blank
mask **********a value whose length is wanted and whose content is not
first 4PATIa birth date reduced to its year
last 41234an account number reduced to the digits a human matches on
pseudonym73f419a72093eda0an identifier that must stay linkable across messages
(Rust only)whatever your function returnsa 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

OptionEffect
-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-allAccept every value no rule names; applied last, this switches off a policy file’s reject
--reject-allReject every value no rule names, the MSH header included
--all-but-the-headerReject 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
--reportWrite what would change, and not the message
--uncoveredWrite the positions no rule names, instead of the redacted message
--show-policyWrite 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.

ExampleShows
redact_a_messageThe built-in policy end to end: what goes, what stays, and why the message still works.
write_a_policyThree ways to say what to redact — in Rust, from a file, or by extending a built-in.
reject_by_defaultThe other posture: reject every value, with keep rules accepting what a test needs.
pseudonyms_and_linkageWhy an identifier becomes a pseudonym rather than a blank, and what that costs.
read_the_reportThe audit trail: one row per position changed, and no values in it.
redact_absent_empty_nullThe three states HL7® keeps apart, and why redaction leaves two of them alone.
date_shift_with_a_custom_actionA per-patient date shift built entirely on Action::custom — why there is no built-in ninth action for it.
cargo run --example redact_a_message

What 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.er7 and 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-3 comment 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.

Where to go next