This is the third and final post in the MDMbox merge series. The first introduced our client-driven design for $merge operation: the client sends a transaction Bundle describing every PUT, POST, and DELETE; the server wraps it with Task + Provenance and runs it atomically. The second explained why merge cannot be one algorithm: there are at least four independent policy axes, each with multiple shipped variants across MPI vendors, MDM platforms, EHRs, and national registries. And here we will finally talk about the opposite operation, $unmerge.
The FHIR spec gives us Patient/$merge at maturity level 0 and no $unmerge at all, yet every MPI and MDM in production has to support reversal, because every Master Patient Index eventually merges two records that shouldn't have been merged. The question is not whether an MDM platform needs unmerge, it is how unmerge should behave.
Why $unmerge Isn't Just $merge in Reverse
The temptation is to treat unmerge as "playing the merge transaction backwards." Read the Provenance, fetch the pre-merge versions from history, write them back. Done.
It works for the mechanical half but not the policy half of the problem. Let's see a couple of examples.
Temporal gap. Anna Schmidt is registered twice as Patient/anna-old and Patient/anna-new. On Monday, Patient/anna-old is merged into Patient/anna-new, recorded as Task/merge-anna with a paired Provenance. On Tuesday, three new Encounters are posted to Patient/anna-new (Encounter/visit-tue-1..3), visits the clinic recorded after the identity was unified. On Wednesday, an unmerge is requested because the original merge was wrong. Those three Encounters have no pre-merge version. They were never on Patient/anna-old. Where do they go now: back to a restored source, kept on the target, distributed by some clinical rule, or routed to a steward queue?
There is no single definitive algorithm for this. Every Encounter record might belong to either patient, depending on which one actually had the visit, and the server does not know.
Hard delete. If the source patient was hard-deleted at merge time (the VistA pattern from our previous post), the source record itself is gone from the live tables. The History API still has its prior versions, but recreating an active resource from a tombstone is a policy decision: do you create a new ID and preserve the mapping in the unmerge audit trail, restore the original ID and accept the gap, or refuse to unmerge at all? Different MDM implementations answer this differently for reasons that have nothing to do with FHIR: retention rules, downstream subscribers, jurisdictional audit requirements.
That boundary determines the interface: $unmerge is a client-supplied reversal plan, not a server-side undo command.
The client sends the original merge Task and a transaction Bundle describing the desired restored state. The server validates that plan, checks optimistic locks, applies it atomically, and writes the Task/Provenance audit trail.
The $unmerge operation is thus symmetric to $merge from our first post.
POST $unmerge
Content-Type: application/fhir+json
{
"resourceType": "Parameters",
"parameter": [
{"name": "task", "valueReference": {"reference": "Task/merge-anna"}},
{"name": "preview", "valueBoolean": false},
{
"name": "plan",
"resource": {
"resourceType": "Bundle",
"type": "transaction",
"entry": [
{
"resource": {"resourceType": "Patient", "id": "anna-old", "...": "restored from history"},
"request": {"method": "PUT", "url": "Patient/anna-old", "ifMatch": "W/\"3\""}
},
{
"resource": {"resourceType": "Patient", "id": "anna-new", "...": "rolled back to pre-merge state"},
"request": {"method": "PUT", "url": "Patient/anna-new", "ifMatch": "W/\"7\""}
},
{
"resource": {"resourceType": "Encounter", "id": "visit-jan",
"subject": {"reference": "Patient/anna-old"}},
"request": {"method": "PUT", "url": "Encounter/visit-jan", "ifMatch": "W/\"4\""}
},
{
"resource": {"resourceType": "Encounter", "id": "visit-tue-1",
"subject": {"reference": "Patient/anna-new"}},
"request": {"method": "PUT", "url": "Encounter/visit-tue-1", "ifMatch": "W/\"1\""}
}
]
}
}
]
}
The only required input is a reference to the original merge Task. Everything else (which version of which resource is restored where, what happens to data added after the merge, what to do with the source if it was deleted) lives in the plan Bundle.
preview: true performs a dry run of the same validation pipeline. The server returns the OperationOutcome it would have returned and the resulting Provenance, so the client can show a steward exactly what an unmerge will touch before authorizing it.
ifMatch headers carry optimistic locking, the same as in merge. If a resource changed between the time the client built the unmerge plan and the time the server executed it, the transaction fails. We do not silently overwrite work the steward did not see.
Building the Plan from Provenance
The client doesn't have to construct the plan from scratch. The merge Provenance already has the data the client needs.
For every resource that the merge touched, the merge Provenance recorded a versioned reference under entity[].what, for example Patient/anna-old/_history/2. That is the pre-merge state. The History API resolves it directly:
GET Patient/anna-old/_history/2
A naive default plan is then a one-pass transform:
- Read the merge Task, follow its
Provenance.targetandProvenance.entitylists. - For each affected resource, fetch its pre-merge version from history.
- Emit a
PUTentry restoring that version, withifMatchmatching the resource's current version (so concurrent edits are caught). - For resources created during the merge that had no pre-merge state (typically: a Linkage record or a matcher assertion), emit a
DELETE. Values copied into an existing resource (for example, identifiers added to the target Patient) disappear through the restorePUTfor that resource.
Most of MDMbox's Steward UI uses exactly this default. The steward sees the proposed reverse Bundle, edits the entries that need policy decisions, and submits.
What the default does not handle is the temporal gap. Resources created or modified after the merge timestamp are not in the merge Provenance. They are net-new state, and the policy slots below are how the steward says what to do with them.
The Audit Chain
Unmerge does not erase the merge — it appends. After $unmerge commits, three artifacts coexist for Task/merge-anna:
- The original merge Task, with
businessStatusflipped frommergedtounmergedinside the same transaction. TheifMatchon that flip catches concurrent unmerge attempts: with two parallel$unmergecalls for the same merge Task, one succeeds and the other gets a version conflict. - The merge Provenance, untouched. It still names the pre-merge versions in
entity[].what. The unmerge does not invalidate the merge's audit trail; it builds on top of it. - A new unmerge Task with
basedOn → Task/merge-anna, paired with an unmerge Provenance pointing at the pre-unmerge versions of everything the reverse plan touched.
The chain reads as a forward query:
GET /Task/merge-anna # the merge, businessStatus=unmerged
GET /Task?code=unmerge&based-on=Task/merge-anna # the unmerge that reversed it
GET /Provenance?target=Task/unmerge-anna # the unmerge's own audit trail
GET /Patient/anna-old/_history # full timeline of the source
That last call is the one auditors and stewards actually run when the source is restored under its original ID. Patient/anna-old/_history reads as a continuous narrative: created, lived, deleted at merge, recreated at unmerge, lived again. Every transition has a Provenance pinned to it, every Provenance cites a Task, every unmerge Task cites the merge Task it reversed. If a deployment creates a new ID instead, the continuity is explicit in the unmerge Provenance rather than in a single Patient history.
The same shape applies recursively. If Patient/anna-old is merged again next month and unmerged again, the timeline grows another merge–unmerge cycle anchored to its own Task pair. Nothing collapses, nothing rewrites — the History API is the unrolled audit log.
Four Policy Slots: the Merge Axes, Inverted
A policy slot is the place in the unmerge plan where the client must make an explicit decision. Some slots change the transaction Bundle itself. Others create follow-up work for stewards, matchers, or downstream systems. Together, they make the restored state intentional rather than just historically reversible.
Survivorship → field reassignment. Merge asked which value wins when two records conflict. Unmerge asks: for fields written after the merge, which restored record gets them? A new phone number recorded on Tuesday: does it go on the source, the target, or both? MDMbox treats this as a per-field client decision, with a safe default of "stay on the target" (the record that was active when the field was written) plus a flag in the Provenance so a steward can revisit.
Reference handling → reference reassignment. Merge asked what to do with pointers to the source. Unmerge asks: where do new references point now, references created after the merge that were always pointing at the target? A claim filed Tuesday referencing Patient/anna-new will now have two patients in scope, and the spec gives no guidance. We make this an explicit plan decision per reference type. The default is to keep new references on the target unless the client overrides.
Source disposition → source restoration. Merge asked what becomes of the source. Unmerge asks: what comes back? If the source was kept inactive with replaced-by, restoration is mechanical: flip active back to true, drop the link. If the source was hard-deleted, the client has to choose: restore the original ID from history (and accept the version gap), create a new ID with unmerge Provenance that maps the old versioned source to the new Patient ID, or refuse to unmerge.
Downstream effects → re-evaluation queue. Merge said cumulative calculations and link graphs need re-evaluation. Unmerge says the same, with one more wrinkle: anything built during the merged period (re-derived radiation dose, recomputed cohort membership, third-party caches, the MPI's own probabilistic link graph) can no longer be trusted without review for either restored patient. The unmerge Task and its Provenance are the subscription anchor: downstream consumers (Steward UI, cumulative-dose recalculators, the matcher) watch Task?code=unmerge and re-evaluate the resources listed in Provenance.target. The server publishes the audit event; what to recompute is a downstream decision.
The pattern is the same as in $merge. The server does not have the clinical context to make these decisions. The client side should make them.
After Unmerge: Re-Merge and Matcher Feedback
Once $unmerge commits, the merge Task's businessStatus reads unmerged. MDMbox's pre-merge guard (the query that prevents merging an already-merged source) looks for Task?code=merge&business-status=merged&subject={ref} and no longer finds the old Task. Patient/anna-old is eligible for a new merge. That is intended: unmerge fully reverses the merge lifecycle, including its block on re-merging.
If a human just decided that two patients are different, the matcher should not silently re-merge them on the next batch run. The reasoning against making it automatic is sound: an unmerge might be undoing a technical mistake (wrong identifiers in the merge plan, a source pointing at the wrong record) rather than asserting a clinical one (these really are different people). The server cannot tell which.
MDMbox keeps the two signals separate. The unmerge Task and Provenance are the public record of the reversal, queryable by the matcher, the Steward UI, or any downstream subscriber. A do-not-merge assertion, when an MDM deployment wants one, is the client's responsibility to write, and since the unmerge plan is a standard transaction Bundle, the client drops a POST for that assertion alongside the reverse plan entries.
The natural FHIR carrier is a Linkage resource pointing at both restored patients, with the deployment's "do-not-merge" code in an extension (built-in Linkage.item.type codes like source/alternate/historical don't cover matcher assertions):
{
"resource": {
"resourceType": "Linkage",
"active": true,
"item": [
{"type": "alternate", "resource": {"reference": "Patient/anna-old"}},
{"type": "alternate", "resource": {"reference": "Patient/anna-new"}}
],
"extension": [{
"url": "http://mdmbox.dev/fhir/StructureDefinition/linkage-assertion",
"valueCode": "do-not-merge"
}]
},
"request": {"method": "POST", "url": "Linkage"}
}
An in-place alternative is Patient.link with type=seealso plus the same extension on the link: fewer resources, but matcher state ends up mixed into demographics on the Patient version chain. Most deployments will prefer the standalone Linkage so the matcher's "don't fuse these" rules live in their own resource and can be queried, expired, or revoked without touching Patient versions.
It has the same atomicity as everything else: the unmerge and the assertion commit together, or neither does. The server's contract ends at the Bundle boundary; what shape the assertion takes and which records it covers is the client's call.
The same principle covers merge chains. A target can have been merged into more than once (A → B, then C → B); the server does not prevent it. When the client later unmerges one of them, the reverse plan has to account for B's current state reflecting both merges, not just the one being reversed. Each merge has its own Provenance, so the data is there, but applying it correctly across overlapping merges is a client decision, not a server one.
Some unmerges also need a policy gate before the reverse plan is allowed to commit. The canonical case is cumulative clinical state: lifetime radiation dose, cumulative opioid totals, or any signed calculation that would be invalidated by redistributing events across two patients. The portable contract is not "always refuse" or "always proceed." The merge Task is the attachment point for that lock; preview can surface it as an OperationOutcome; the deployment decides whether the unmerge is blocked inline, routed to a human review queue, or allowed while derived totals remain quarantined.
Closing the Series
This post completes the merge trilogy. The first defined a client-driven $merge and argued for moving policy out of the server. The second showed that policy is not one decision but four, and that every MPI vendor in production ships a different combination. This one mirrors the contract for the reverse direction: same interface, same four axes inverted into unmerge questions, same division of labor between server invariants and client policy.
The full $merge / $unmerge implementation is available in MDMbox today, a FHIR-native Master Patient Index and MDM platform.




