Skip to main content

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_identifier is 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:

  1. 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.
  2. Re-run the module re-point. It is resumable and reports what it did not finish.
  3. 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

StateMeaningAction
PENDINGRecorded, not yet attemptedNone; the sweep will take it
COMPLETEDThe module moved everything it heldNone
NO_OPThe module held nothing for the retired patientNone
PARTIALSome rows moved, the module named tables still outstandingRetried automatically
FAILEDThe call failedRetried up to max-attempts, then needs a person
NOT_DEPLOYEDThis deployment does not run the moduleNone; 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

KeyDefaultWhat it does
uhp.merge.module-repoint.max-attempts5Attempts before a row becomes visibly stuck
uhp.merge.module-repoint.batch-size50Obligations attempted per sweep
uhp.merge.module-repoint.poll-delay-ms60000Delay between sweeps