Person Deduplication & Merge
Overview
The Person Deduplication & Merge feature combines duplicate Person records in the system into a single authoritative record. It updates all foreign key references, merges metadata intelligently, and provides an audit trail of all merge operations.
This feature builds on the existing Progressive Person Matching scoring infrastructure (PersonMatchConfig) and is a prerequisite for deploying progressive matching in production — duplicate records cause legitimate matches to return AMBIGUOUS.
Terminology
| Term | Definition |
|---|---|
Source Person |
The duplicate Person record that will be absorbed and soft-deleted after merge. |
Target Person |
The surviving Person record that receives all merged data and FK references. |
PersonWrapper |
Wraps |
PersonMergeLog |
Audit record capturing merge timestamp, source/target IDs, field provenance, trigger type, and operator. |
PersonMergeCandidate |
A detected potential duplicate pair awaiting review or auto-merge, with match score and status. |
Target Selection
The target is the Person record that survives the merge. The caller chooses it. A manual merge names the source and the target; approving a detected candidate names the surviving person. Nothing in the service infers a survivor, and the order in which a candidate stores its pair is a storage rule — the lower person id is stored first — which carries no survivor meaning.
Guidance for whoever chooses, in priority order:
-
The person with the most recently edited data (
last_editedmeta). -
If equal, the person with richer relationship data — more LinkedPerson, EventParticipant or Membership records.
Both are advice to the operator rather than behaviour of the service.
Merging Person Properties (UserMeta)
The merge uses PersonWrapper-aware logic rather than blind metadata key stacking. For each field:
-
Prefer valid over invalid — e.g., a Luhn-valid SA ID over an invalid one, a real email over a fake email (
@member.wpcycling.com) -
Prefer non-null over null — fill gaps from the source where the target has no value
-
Prefer most recently edited — when both have valid, non-null values of equal quality, keep the most recent
-
Record provenance — for each field, record which person (source or target) contributed the surviving value
PersonWrapper Fields Subject to Merge
Identity
first_name, last_name, person_email, title, language, date_of_birth, id_number, id_type, id_country, nationality_country, gender, contact_number
Foreign Key Migration
All database foreign keys referencing wp_users.ID must be updated from source to target. The merge is transactional — all updates succeed or all roll back.
| Table | Column | Records (prod) | Notes |
|---|---|---|---|
|
|
24,073 |
Conflict resolution needed if both source and target have participation in the same event (see Conflict Resolution) |
|
|
38,543 |
Conflict resolution for same MembershipType |
|
|
41,624 |
|
|
|
2,028 |
Deprecated — migrate but mark code |
|
|
30,343 |
|
|
|
188 |
|
|
|
14,235 |
|
|
|
0 |
|
|
|
12,904 |
|
|
|
18,367 |
|
|
|
6,406 |
Join table — update or deduplicate |
|
|
10,823 |
Deduplicate if target already linked to same principal |
|
|
21,702 |
|
|
|
3 |
1:1 extension — migrate or merge row |
|
|
808 |
Application-level reference (no FK constraint) |
|
|
The external-system identity mappings follow the surviving person; left behind, they would strand a system-of-record claim on a deleted record |
|
|
|
Open candidates naming the absorbed person are re-pointed at the survivor, or closed where both sides now resolve to one person. A rejection carries across, so a reviewer is not asked the same question twice |
Conflict Resolution
EventParticipant
When both source and target have an EventParticipant for the same Event:
-
Same EventCategory: Keep target’s record, delete source’s. Migrate any associated
ProcessData,OrderLineItem,RaceResult, andStartGroupParticipantfrom source’s record to target’s if they don’t conflict. -
Different EventCategory: Flag for manual review — both participations may be legitimate.
Membership
A person may have multiple Membership records for the same MembershipType across different years (annual renewals). The conflict key is MembershipType + MembershipPeriod — a person should appear in each period at most once.
When both source and target have a Membership for the same MembershipType and MembershipPeriod:
-
Keep the record with the most recent renewal or richer financial history.
-
Delete the other after migrating any linked financial records.
When they have the same MembershipType but different MembershipPeriod values, both records are legitimate (different renewal years) — simply re-point the source’s record to the target.
OrgUser Principal Merge
If the source Person is OrgUser.person (i.e., someone’s account principal):
-
Re-point
org_user.person_idfrom source to target. -
Multiple
OrgUserrecords pointing to the same Person is allowed — different session identifiers (e.g., anonymous UUID sessions, OIDC logins) may independently resolve to the same deduplicated person. No manual review needed for this case. -
Deduplicate
LinkedPersonrecords if target is now both a principal’s person and a linked person under the same principal.
Relationship Model Migration
managed_by Meta Retirement
The legacy managed_by UserMeta is replaced by LinkedPerson + OrgUser:
| Aspect | Legacy (managed_by) |
Current (LinkedPerson + OrgUser) |
|---|---|---|
Storage |
|
|
Principal identification |
Implicit (user with login) |
Explicit ( |
Access control |
None |
|
Relationship type |
None |
|
Temporal validity |
None |
|
Security integration |
None |
|
The new merge service operates entirely on LinkedPerson and OrgUser. It does not read or write managed_by meta. After merge migration is complete, managed_by will be deprecated and removed.
Merge Audit Trail
Source Person Metadata
The source (merged-away) person receives:
-
merge_date_time— ISO timestamp of the merge -
PersonWrapper.setPersonDeleted(true)— soft-delete viaUser.userStatusbitmask (PERSON_DELETE_STATUS_MASK = 0x01)
PersonMergeLog Table
A dedicated audit table captures the full merge history:
| Column | Type | Description |
|---|---|---|
|
BIGINT PK |
Auto-generated |
|
DATETIME |
When the merge was executed |
|
BIGINT |
The absorbed (now deleted) person |
|
BIGINT |
The surviving person |
|
VARCHAR |
|
|
VARCHAR |
Who performed the merge, or |
|
JSON |
Per-field record of which person contributed each surviving value |
|
JSON |
Summary of FK tables and record counts updated |
Match Classification
No merge happens without a person deciding it. Detection raises candidates; it never merges. That is ADR-0012's rule — a machine may act on identity, never on resemblance — applied at its strictest: automated merging was removed entirely rather than kept for the identity-grade subset.
| Outcome | Evidence | Action |
|---|---|---|
Raised for review |
A collision or typo signal, or a fuzzy score at or above the review threshold |
A |
No action |
Below the review threshold |
Nothing is filed. |
Detection reason
Every candidate carries one reason, from a closed set shared by every producer — the detection service and the import paths alike, so the review queue can be filtered and the producers cannot drift onto free text: SAID_COLLISION, EXTERNAL_UID_COLLISION, LOW_CONFIDENCE_MATCH, DOB_GENDER, FUZZY_MATCH, INVALID_SAID_DOB_MATCH.
A candidate raised by a collision or typo signal carries no score: the signal fired, rather than a comparison being scored. Absent is not zero.
Eligibility assessment
Separately from why a pair was raised, the service will assess whether the two records look like the same human, and report which details agree and which disagree across identity number, email, date of birth, name and gender. A detail missing on either side counts as neither agreement nor disagreement.
The assessment informs; it does not decide. A pair that does not assess as eligible can still be merged, but only with an explicit confirmation, which is recorded. The integrity refusals are not confirmable: the same person on both sides, or a source or target that has already been absorbed.
API
Manual Merge
POST /api/admin/person-merge
Content-Type: application/json
Request:
{
"sourcePersonId": 12345,
"targetPersonId": 67890,
"confirmed": false
}
Response:
{
"mergeLogId": 101,
"targetPersonId": 67890,
"fieldsUpdated": 14,
"fkTablesUpdated": 12,
"totalRecordsMigrated": 47
}
confirmed is the caller’s acceptance of a merge the eligibility assessment does not support. Without it, such a pair is refused and the response carries the assessment, so the caller can see what would have to be confirmed.
Merge Candidates
GET /api/admin/person-merge-candidates?status=PENDING&page=0&size=20&sort=score,desc
Response:
{
"content": [
{
"id": 1,
"sourcePersonId": 12345,
"sourcePersonName": "John Smith",
"targetPersonId": 67890,
"targetPersonName": "J. Smith",
"score": 72,
"detectionReason": "DOB_GENDER",
"status": "PENDING",
"detectedAt": "2026-03-30T10:00:00Z"
}
]
}
GET /api/admin/person-merge-candidates/{id}
POST /api/admin/person-merge-candidates/{id}/approve
POST /api/admin/person-merge-candidates/{id}/reject
POST /api/admin/person-merge-candidates/bulk-approve
The single-candidate response adds both records' comparable fields, the reject reason where there is one, and the eligibility assessment:
{
"id": 1,
"sourcePerson": { "id": 12345, "firstName": "…", "lastName": "…", "dateOfBirth": "…",
"gender": "…", "identityNumber": "…", "identityType": "…",
"identityCountry": "…", "contactNumber": "…", "email": "…",
"createdOn": "…", "lastEdited": "…" },
"targetPerson": { … },
"rejectReason": null,
"eligibility": {
"eligible": false,
"agreeing": ["NAME", "GENDER"],
"disagreeing": ["DATE_OF_BIRTH"]
}
}
Approve names the survivor and carries the operator’s confirmation. survivorPersonId must be one of the pair; omitting it keeps the stored target, which is the higher person id and carries no survivor meaning:
POST /api/admin/person-merge-candidates/{id}/approve
{ "survivorPersonId": 67890, "confirmed": false }
Reject takes an optional reason. Bulk approve merges only the pairs that assess as eligible and returns the rest, each with its assessment, for individual review. Acting on a candidate that is no longer pending is refused as a conflict.
Admin UI
The operator-facing screens are specified as a use case: S02 Duplicate People — the review queue and the candidate detail, where the survivor is chosen and the merge confirmed.
They are platform screens, not tenant ones, because a merge rewrites the absorbed person’s records at every organisation, and the comparison discloses personal detail across the organisation boundary. Person lookup is global by design; the organisation boundary governs disclosure, not matching.
Three further screens are described but not specified, and each waits on a decision rather than on effort:
-
Merge history and audit log — a list of completed merges from
person_merge_log, with per-field provenance. What such a screen can honestly show is limited by Limitations of the current design. -
Manual merge of any two records found by search, bypassing the queue and reusing the comparison.
-
Bulk approval from the queue. The API supports it; S02 deliberately does not, because the judgement is per pair.
Limitations of the current design
Stated as properties of the implementation, because each one shapes what a screen or an audit can show.
-
An approval is recorded as an automated merge. Approving a candidate writes the trigger type reserved for a machine acting on confidence — the value ADR-0012 forbids — rather than recording that a person decided. The audit trail therefore misattributes every reviewed merge.
-
No merge records who performed it. The operator is a fixed literal rather than the authenticated caller, so the log cannot answer "who merged these two".
-
The absorbed record’s details are cleared by the merge, for data minimisation. The audit row keeps both person ids, but a name read from the absorbed record afterwards is empty — so a history screen cannot show who was absorbed without the log itself retaining a description at merge time.
-
Provenance records which side won each field, not the values. It answers "did this value come from the survivor or the absorbed record", never "what was the value before".
-
A merge cannot repair a wrong link. The tooling serves the duplicate case, two records for one human. Where two records are different humans and a participation was attached to the wrong one, merging them would compound the error; recovery is re-pointing the records by hand.
Related Documentation
-
Progressive Person Matching — scoring infrastructure reused for deduplication
-
Registration Entities — EventParticipant, Membership entity relationships