[S02] Duplicate People

Summary

The platform screen where a person decides whether two person records are the same human, and merges them if they are. It has two views: a queue of detected duplicate pairs, and a candidate detail that puts the two records side by side with the system’s own assessment of the evidence, a choice of which record survives, and the merge and reject actions.

It exists because ADR-0012 forbids any unattended process from converting resemblance into a merge. Every pair that an import files on weak evidence, and every pair a detection run raises, waits here for a person. Nothing else resolves them.

Actor & Context

Actor: platform administrator. Not a tenant role — see Scope and disclosure.
Frequency: a short working session after an import or a detection run; otherwise occasional.
Precondition: the session carries the super-admin grant, and the caller holds the platform authority the merge endpoints require.
Entry point: sidebar PLATFORM ▸ Duplicate people. A later entry point is the participant-import summary, which reports the candidates one import filed.

Scope and disclosure

Merging is a platform function, not a tenant one, for two reasons that both come from the ADR.

  • A merge reaches every organisation. The person pool is global by design. Absorbing one record into another repoints that person’s participations, memberships, orders, numbers, tags and results wherever they are — including at organisations the operator does not act for.

  • The comparison discloses personal detail. Deciding "same human?" needs identity numbers, dates of birth and contact details side by side. The organisation boundary governs disclosure of that detail; it does not govern which records may be matched.

Consequences for the screen:

  • It renders outside the organisation-scoped part of the portal and ignores the organisation in context for data scope.

  • The ALL TENANTS marker is mandatory beside the page title on both views. The organisation chip stays populated, so the marker is the only thing distinguishing this data from tenant-scoped data.

  • A tenant-scoped view, limited to pairs where at least one person was used in that tenant’s events or memberships, is a later feature and out of scope here.

Layout

Both views render inside the portal shell, which owns the sidebar and topbar. This screen owns the main content area.

Queue — /platform/duplicate-people

Region Content

Header

Title "Duplicate people", the ALL TENANTS marker, and the record count.

Filter strip

Status filter: Pending (default), Rejected, Merged. A status with no rows renders the empty state rather than an empty table.

Table

Columns: Person A (name + id) · Person B (name + id) · Why (detection reason) · Score · Detected. Row click opens the candidate detail. Default sort: score descending, then most recently detected. Pagination is server-driven.

Score cell

A band, not a bare number: strong at 80 and above, moderate from 50 to 79. A pair raised by a collision signal rather than by scoring carries no score, and the cell says so rather than showing a zero — the absence means "not scored", not "scored badly".

Why cell

The detection reason as a short label, with a tooltip stating what raised the pair — a shared identity number, an external-system collision, a name-only or birth-date-and-gender resemblance from an import, a fuzzy-scored resemblance, or one identity number that looks like a typo of the other.

Empty state

"No pairs are waiting for review" for the default filter, and the reason the list is empty for any other filter.

Candidate detail — /platform/duplicate-people/:candidateId

Region Content

Back link

← Back to duplicate people, returning to the queue with its filter and page preserved.

Header

Both people (name + id), the detection reason, the score band, when it was detected, and the ALL TENANTS marker.

Assessment panel

The system’s eligibility assessment: whether the pair assesses as the same human, and the evidence behind it — which details agree and which disagree, across identity number, email, date of birth, name and gender. A detail missing on either side appears in neither list, and the panel says that, so an operator does not read its absence as agreement. The panel states an assessment, never a decision: a positive assessment does not merge anything, and a negative one does not prevent a merge.

Comparison

The two records side by side, one column each, over the fields the API returns: names, date of birth, gender, identity number with its type and country, contact number, email, and when each record was created and last edited. A badge between the two columns says what the merge will do to each row — see What happens to each field. Created and last edited are record-keeping rather than identity evidence: they are greyed, never tinted, and carry no badge, so the badge keeps its meaning where it appears.

Survivor choice

One control per column: keep this record. The choice is explicit and has no default, because the stored pair order carries no survivor meaning. The merge action stays disabled until a survivor is chosen, and says why on hover.

Actions

Merge — primary, destructive styling; opens the confirmation. Reject — records that the two are different people, with an optional reason. Back.

What happens to each field

The operator’s question about any row is not "do these differ?" but "if I keep this record, what happens to the other side’s value — and can this clear a field?". The badge between the columns answers it, and changes what it states once a survivor is chosen.

Undecided — the badge states the relation, and the rows worth reading are tinted so they can be found in a table that is mostly agreement:

Badge Meaning Row

=

Both records hold the same value.

Untinted.

≠

Both hold a value and they disagree.

Red tint.

?

Only one record holds a value — the case that prompts the question.

Yellow tint.

Once a survivor is chosen the tint clears, because the question has changed from "where do these differ?" to "what will happen?", and each badge states the outcome:

Badge Meaning

✓

The surviving record keeps its value.

→ / ←

The value is copied to the surviving record, which has none. The arrow points at the survivor.

⚖

Decided by rule rather than by the choice of survivor.

Three rules sit behind this, and all must hold or the badge lies:

  • A merge never blanks a value on the surviving record. Where the survivor has no value, the absorbed record’s fills the gap. This is stated in the legend and again in the confirmation, which names the fields being filled — it is the reassurance the ? state exists to give.

  • Identity number, identity type and country, email, date of birth and gender are settled on the quality of the value, not on which record survives. A validating identity number beats one that fails its check digit, a passport is not overwritten by a national identity number, a real address beats a placeholder, and birth date and gender are re-derived from a winning national identity number. The screen cannot compute the winner without running the merge, so it says the rule decides rather than guessing a side.

  • A synthetic address is not a value. A @member.wpcycling.com email is a technical placeholder, minted so a WordPress user row could exist without a real address. Nobody can be reached at it and it carries no business meaning, so it is displayed as blank — and, because the same rule governs the relation, a placeholder facing a real address reads as one-sided rather than as a disagreement. The merge already treats such an address as absent, so the display and the behaviour agree.

Merge confirmation

The confirmation names the surviving record and the absorbed one, and states plainly what the merge does: the absorbed record is soft-deleted, its personal details are cleared, and every reference to it moves to the survivor. It cannot be undone from this screen.

Where the pair does not assess as eligible, the confirmation additionally shows the disagreeing evidence and requires an explicit confirmation before the merge is sent. An operator may merge past a negative assessment; they may not do so silently.

Main Flow

  1. Operator opens PLATFORM ▸ Duplicate people. The queue lists pending pairs, strongest evidence first.

  2. Operator opens a pair.

  3. The detail shows the assessment, the two records side by side, and which details agree and disagree.

  4. Operator decides:

    • The same human — chooses which record survives, clicks Merge, and confirms. The merge executes, the pair is recorded as merged, and the screen returns to the queue with the pair gone.

    • Different people — clicks Reject, optionally saying why. The pair is recorded as rejected and is not raised again.

    • Cannot tell yet — goes back. The pair stays pending.

Alternative Flows

  • AF-1 — negative assessment. The operator chose a survivor and clicked Merge, but the pair does not assess as eligible. The confirmation shows the disagreeing evidence and requires explicit confirmation. Without it the merge is refused and the screen says what would have to be confirmed.

  • AF-2 — already resolved. The pair was resolved elsewhere since the queue was loaded. The action is refused as a conflict; the screen reloads the candidate and shows its current state rather than retrying.

  • AF-3 — refusal the operator cannot confirm past. Some refusals are integrity rules, not judgements: the same person on both sides, or a record that has already been absorbed. These are reported as refusals with no confirmation path.

  • AF-4 — a pair naming an absorbed record. One side was merged away by an earlier merge. The pair is shown as no longer actionable, with the reason, rather than offering a merge that would fail.

  • AF-5 — not a platform administrator. The route explains that the screen is restricted to platform administrators rather than rendering an empty table or a bare error. The backend refuses the calls regardless of what the navigation shows.

Acceptance Criteria

  • Use-case page authored.

  • The queue lists pending pairs by default, filters by status, and sorts by score descending then most recently detected.

  • A pair with no score reads as "not scored", never as zero.

  • The detail shows the eligibility assessment with its agreeing and disagreeing evidence, and states that a detail absent on either side appears in neither list.

  • Differences between the two records are visible without comparing values by eye.

  • Each row states what the merge will do to it: the relation while undecided, the outcome once a survivor is chosen.

  • While undecided, differing rows carry a red tint and one-sided rows a yellow tint; both clear when a survivor is chosen.

  • A field the surviving record lacks is shown as copied across, not as a value at risk — no operator has to guess whether choosing a record clears a field.

  • Fields the merge settles by rule say so, rather than naming a winner the screen cannot know.

  • A placeholder email address is displayed as blank, and is treated as absent wherever the comparison reasons about the value — never as a difference.

  • The confirmation names the fields the absorbed record fills in.

  • The survivor choice is explicit, has no default, and gates the merge action, which explains itself while disabled.

  • A merge names the surviving record, and the absorbed record is the other one.

  • A pair that assesses negatively cannot be merged without an explicit confirmation, and the disagreeing evidence is shown at that moment.

  • A rejection may carry a reason, and a rejected pair does not reappear in the pending queue.

  • Acting on a pair that is no longer pending is reported as a conflict and refreshes the view.

  • The ALL TENANTS marker is present on both views.

  • Both views are unreachable, and their calls refused, for anyone without the platform grant.

  • An operator can work a real pair end to end against the development environment.

API Surface

Call Purpose

GET /api/admin/person-merge-candidates?status=&page=&size=&sort=

The queue. Returns each pair with both people’s ids and names, the detection reason, the score where there is one, the status, and when it was detected and resolved.

GET /api/admin/person-merge-candidates/{id}

The detail: both records' comparable fields, the reject reason where there is one, and the eligibility assessment with its agreeing and disagreeing signals.

POST /api/admin/person-merge-candidates/{id}/approve

Executes the merge. The body names the surviving person and carries the operator’s confirmation where the assessment is negative. Returns the merge result.

POST /api/admin/person-merge-candidates/{id}/reject

Records the pair as different people, with an optional reason.

(session)

Whether the caller is a platform administrator, which gates the navigation entry and the routes.

Out of Scope

  • Merge history and audit trail — a separate screen. It needs a decision first about what the merge log can still show, because a merge clears the absorbed record’s personal details and the log records only which side each surviving value came from, not the values themselves.

  • Merging any two people found by search — needs a person search the portal does not have. Until then this screen resolves only detected pairs.

  • Bulk approval from the queue. The API supports it, and the screen deliberately does not: the judgement this screen exists to make is per pair.

  • Overriding individual field outcomes before merging. The merge decides each field by policy, and there is no way to preview or override that per field.

  • Each person’s event and membership footprint alongside the comparison. It is the first thing to add once the screen is in use, because an entry history distinguishes a parent from a child more reliably than a name does.

  • Routing a suspected identity-number typo differently from a resemblance. That class asks "which number is wrong?", which is a data correction rather than a person judgement.

  • A tenant-scoped view of the queue.

  • Undoing a merge.

Design Decisions

  • Platform scope, not tenant — a merge rewrites records at every organisation, and the comparison discloses personal detail across the organisation boundary. See Scope and disclosure.

  • The merge goes through the candidate’s own approve action, which names the survivor and carries the confirmation, rather than through the direct person-merge call. One action closes the pair and records the merge together.

  • The survivor is chosen, never inferred. The stored order of a pair is a storage rule — the lower record id is stored first — and says nothing about which record should survive. A default here would look like a recommendation.

  • The row says what will happen, not merely what differs. A comparison that only marks differences leaves the operator’s real question unanswered — whether keeping one record clears a field the other holds. The badge answers it before it is asked, and the tints make the rows worth reading findable while the table is still undecided.

  • The assessment informs; the operator decides. A negative assessment is a prompt for a deliberate confirmation, not a veto, and a positive one is not an instruction to merge.

  • No bulk approval in the UI. Approving many pairs at once is indistinguishable from approving pairs nobody looked at.

  • The screen renders whatever evidence the API returns rather than encoding the scoring rule, which is expected to be replaced by a shared evidence model.

Design Anchors

Notes

Two things must exist before this screen can work, and neither is UI: the platform grant that makes the merge endpoints reachable and tells the portal who is a platform administrator, and the platform routing and navigation group this screen hangs from. Both are tracked against the super-admin work item in the header.

Two gaps in the backend affect what the screen can honestly show, and are tracked separately: an approval is currently recorded as though a machine had made it, and no merge records which person performed it. Neither blocks the queue or the comparison, but both matter to the history screen and to the audit trail this feature exists to keep.

The status filter offers only the states the flow produces. An approval executes its merge immediately, so no pair rests in an approved-but-unmerged state.

Appendix A: Claude Design Prompts

Persisted for the audit trail. Most recent first.

v1 — 2026-09-26

Status: designed in the project (folder S02-duplicate-people/). Source: this page. Covers: both views and the confirmation dialog.

Continuing the EMS admin portal design language you already have in this project
(C01 shell, E05 control centre, E01, C03) — JHipster 8 / Angular / PrimeNG /
ng-bootstrap. Match the fonts, spacing, palette and information density.

Design ONE platform-level screen with two views and one dialog. It is where a
member of platform staff decides whether two person records are the same human
and merges them. The decision is irreversible and occasionally wrong, so the
design's job is to slow the operator down at exactly one moment — the merge —
and to make the evidence legible before then.

This is NOT a tenant screen. It shows people from every organisation. The shell
keeps its organisation chip populated, so an "ALL TENANTS" pill beside the page
title is the only signal that this data is cross-tenant. It is mandatory on both
views. Use the existing near-black Tag treatment for it.

============================================================
View 1 — Queue: /platform/duplicate-people
============================================================

- Page header: "Duplicate people" + ALL TENANTS pill + record count.
- Status filter: Pending (default) | Rejected | Merged. One control, not tabs.
- Table, one row per candidate pair:
    Person A (name + #id) · Person B (name + #id) · Why · Score · Detected
  Row click opens view 2. Default sort: score desc, then newest.
- The Score cell is a band, not a number alone: "Strong" (80+) and "Moderate"
  (50-79). Some pairs have NO score — they were raised by a collision signal
  rather than scored. Those must read "Not scored", visibly different from a low
  score. Never render 0.
- The Why cell is a short label with a tooltip: shared identity number /
  external-system collision / likely typo in one identity number / name-only
  resemblance / birth date + gender resemblance / fuzzy resemblance.
- Empty state: "No pairs are waiting for review" for Pending; for any other
  filter, say which filter is emptying the list.

============================================================
View 2 — Candidate detail: /platform/duplicate-people/:id
============================================================

Back link "← Back to duplicate people". Header: both people (name + #id), the
Why label, the score band, when detected, ALL TENANTS pill.

Then three regions, in this order — the assessment first, because it frames how
the comparison should be read:

1. ASSESSMENT PANEL. The system's verdict: "These records assess as the same
   person" or "These records do NOT assess as the same person", plus two short
   lists — details that AGREE and details that DISAGREE, over: identity number,
   email, date of birth, name, gender. A detail missing on either side appears in
   neither list, and the panel must say so, so absence is not read as agreement.
   Make it unmistakably an assessment, not a decision: a negative one does not
   block the merge, a positive one does not recommend it.

2. COMPARISON. The two records side by side, one column each, rows for: first
   name, last name, date of birth, gender, identity number, identity type,
   identity country, contact number, email, created, last edited. Mark rows
   where the values differ. Where a value exists on one side only, mark it as
   "only on this record" rather than leaving a blank cell.

3. SURVIVOR CHOICE, at the head of each column: "Keep this record". No default
   selection. Show what each choice means in one line: the other record is
   absorbed and deleted. Until a choice is made the Merge button is disabled AND
   says why on hover.

Actions: Merge (primary, destructive styling) · Reject · Back.
Reject opens a small form with an optional reason.

============================================================
Dialog — Merge confirmation
============================================================

Two states of the same dialog.

A. Pair assesses as eligible. Names the surviving record and the absorbed one,
   and states plainly what happens: the absorbed record is deleted, its personal
   details are cleared, and everything that referenced it — event entries,
   memberships, orders, numbers, results — moves to the survivor. It cannot be
   undone here. Confirm / Cancel.

B. Pair does NOT assess as eligible. The same content, plus the disagreeing
   evidence repeated inside the dialog, and an explicit confirmation the operator
   must give before Confirm enables. This is the one place in the flow that
   deliberately adds friction. Do not make it dismissible by accident.

Also design the refusal states, which are not the same thing:
- The pair was resolved by someone else while it was open → the action is
  refused, the view reloads, and the pair shows its new state.
- A refusal the operator cannot confirm past (one record has already been
  absorbed, or both sides are the same record) → stated as a refusal with no
  confirmation affordance at all.
- The viewer is not platform staff → the route explains that rather than showing
  an empty table.

============================================================
Output
============================================================

For each view and the dialog: HTML/JSX + styles, and a README saying what it
does and which endpoints it reads. One cross-view README covering the ALL
TENANTS rule, the survivor-choice-gates-merge rule, and the two dialog states.