Merging two patient records
M37-011. How to run a merge, how to read what it reports, and what to do when part of it has not finished.
Before you start
⚠⚠ A merge is a clinical decision, not data tidying. When both records hold clinical history, one chart absorbs the other's visits, observations and orders. The platform refuses that merge unless the caller states they have taken the decision, and it never picks the survivor itself.
You need:
- which record survives — the caller names it; the system does not guess;
- why they are the same person — required, and recorded. It is exactly the merges somebody later disputes that need a reason;
- confirmation that clinical histories may be combined (
mergeClinicalHistory), when both sides are patients.
Running one
POST /api/v1/demographic/person-merges
{
"survivorPersonId": "…",
"loserPersonId": "…",
"reason": "Temporary record identified: her sister attended and confirmed the NIN",
"mergeClinicalHistory": true
}
Without mergeClinicalHistory, a merge of two patient records is refused with that stated. That
refusal is not a bug — see above.
Reading the reconciliation report
The response's moved map names every table and the rows moved, including tables where nothing
moved.
"moved": {
"names": 2, "addresses": 1, "identifiers": 3,
"patient.visit": 4, "patient.encounter": 11, "patient.observation": 96,
"patient.appointment": 0, "patient.invoice": 2, …
}
⚠ A zero is information. "This table had nothing to move" and "this table was never looked at" are different facts, and a report that cannot tell them apart is the one the original design called the worst outcome available: some history moved, some did not, and no way to tell which without comparing two charts by hand.
⚠ 28 public tables appear. If a table you expect is missing from the map entirely, that is a
defect, not a quiet success — PatientRepointTableCoverageTest exists to fail the build before it
reaches you.
What happens to the retired record
- The person row is voided with a forwarding address (
merged_into_person_id), never deleted. Engines across the platform hold logical person and patient references with no foreign key; deleting the row would strand every one of them, and the strandings would surface one at a time, months apart. - The patient row is voided the same way.
- ⚠ The temporary UHP ID keeps resolving.
patient_identifieris re-pointed, so a number written on a wristband, quoted in a referral or typed into a lab form still finds the patient.
⚠ When M37-005 has not finished
The core merge and the module re-point are not atomic, and cannot be.
Core owns the 28 public tables and moves them in one transaction. Seven tables belong to
imaging, lab and pharmacy — core cannot write them. Those move through the per-module re-point
contract, which runs after the core merge and can be interrupted.
So between the two, a study or a dispensing record may still reference the retired patient. That is expected, not corruption.
What to do:
- Do not re-run the core merge. It refuses a second merge of the same loser, and it is right to: applying another on top would move rows that are already gone.
- Re-run the module re-point. It is resumable and reports what it did not finish.
- Until it completes, a result arriving for the retired patient still resolves — the forwarding address is what makes that true. It is a stale reference, not a lost one.
⚠ Telling downstream systems
Lab and imaging systems keep their own patient index. ADT^A40 is what tells them two patients
are one; MRG-1 names the identifier they should retire.
Until the A40 send is wired to fire on a completed merge (M37-008 ships the builder and its contract, not the trigger), assume downstream indexes do not know. A result can come back against a record that no longer exists there. Check with the receiving system before relying on their side being current.
Promoting instead of merging
⚠ If the patient turns out to be nobody we already knew, do not create a record and merge into it.
Use POST /persons/{id}/promote: the record is already theirs, the history is already on the right
chart, and a needless merge is a needless chance to lose something.
Module re-point: what a merge still owes (M37-005)
imaging, lab and pharmacy hold seven patient-keyed tables that core cannot write. Their rows
move after the merge commits, through each module's own re-point endpoint. That step is not
atomic with the merge and cannot be, so the platform records the obligation instead of assuming it.
One row per module is written into patient_merge_module_repoint inside the merge transaction,
in state PENDING. PatientMergeRepointListener attempts them the moment the merge commits;
PatientMergeModuleRepointScheduler (profile scheduler) sweeps anything still outstanding.
Reading the states
| State | Meaning | Action |
|---|---|---|
PENDING | Recorded, not yet attempted | None; the sweep will take it |
COMPLETED | The module moved everything it held | None |
NO_OP | The module held nothing for the retired patient | None |
PARTIAL | Some rows moved, the module named tables still outstanding | Retried automatically |
FAILED | The call failed | Retried up to max-attempts, then needs a person |
NOT_DEPLOYED | This deployment does not run the module | None; there is no schema to move rows in |
⚠ FAILED and NOT_DEPLOYED are deliberately different states. Before M37-005 both produced an
entry missing from a list and a log line, which meant a clinician could open the survivor's chart,
see no lab results, and have no way to tell "there are none" from "they are still attached to the
record we retired".
Checking and retrying
GET /api/v1/demographic/person-merges/{personMergeId}/module-repoint
POST /api/v1/demographic/person-merges/{personMergeId}/module-repoint/retry
The retry is safe to press repeatedly: each module's statement is WHERE patient_id = :loser, so a
module that already finished matches no rows.
When a row is stuck
A row that reaches uhp.merge.module-repoint.max-attempts (default 5) stops being swept and is
logged every pass as needing attention. It does not disappear — "no longer retried" and "done"
are different things. Investigate the module named in module_key using last_error, fix the
cause, then call the retry endpoint above; the attempt counter is not a permanent ban.
Settings
| Key | Default | What it does |
|---|---|---|
uhp.merge.module-repoint.max-attempts | 5 | Attempts before a row becomes visibly stuck |
uhp.merge.module-repoint.batch-size | 50 | Obligations attempted per sweep |
uhp.merge.module-repoint.poll-delay-ms | 60000 | Delay between sweeps |