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::new()
    .with("PID-5", Action::redacted())?
    .with("PID-7", Action::First(4))?;

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 message.er7

Redact everything except the MSH header, then name what to keep. 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::everything()
    .with("OBX-2", Action::Keep)?
    .with("OBX-3", Action::Keep)?
    .with("OBX-5", Action::Keep)?;

Help Reference

The eight actions

WrittenPATID1234 becomesUse for
keepPATID1234exempting a position from a fallback
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

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

*        replace REDACTED   # optional: the fallback, always applied last

One rule per line: a path, whitespace, an action. Blank lines are ignored, # starts a comment, and rules apply in order. A path of * sets the fallback, which turns the policy from “redact these” into “redact everything except these” — and keep is how a position is exempted from it.

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
-a, --allRedact everything except the MSH header
-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
--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

Six 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.
redact_all_butA fallback over everything, with keep rules for 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.
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. A name in an NTE-3 comment survives every positional policy; name that position, or use --all.
  • 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