ADR-0012: Only a human may resolve a weak person match

Status

Accepted

Date

2026-09-01

Deciders

Christhonie Geldenhuys (architect)

Related

Feature #686, Feature #373, US #773 (headless), US #1118 (interactive), US #378, US #379, Bug #1087, Bug #1129; Person Matching & Deduplication on Import; C05; E06; design-journal 2026-05/participant-identity-correlation.adoc, 2026-09/adr-0012-alignment-and-test-paths.adoc

1. Context

PersonService.matchPersonForImport resolves an imported row to a Person through five tiers, in descending order of confidence: an external system’s person id, a national identity number, date of birth plus gender, first plus last name, and finally create-new. The first tier that produces a candidate wins, and the EventParticipant is linked to whatever it produced.

The bottom two tiers do not establish identity. A shared birthday is a coincidence; a shared name is a weaker coincidence. Yet both currently link, with only a row warning to mark the difference, and a link is the strongest assertion the system can make about a person: EventParticipant.person is NOT NULL, it is half of the (event_id, person_id) unique constraint, and it is the spine for number assignment, results, leaderboards and security filtering.

The failure this produces is silent. A parent and an adult child sharing a name, each with their own valid identity number, entered the same event. The younger entrant’s number matched nobody, so the ladder fell through to the name tier and attached them to the elder’s Person. The unique constraint rejected the second participant insert and the row was dropped from the import — which is the lucky outcome. Across two different events there is no constraint, no error, and the younger entrant’s entry is silently absorbed into the elder’s record, carrying the identity number, date of birth, results history and any future external-reference write-back. Nothing in the data marks it as wrong.

Two forces pull against simply deleting the weak tiers.

The row must resolve to something. The participant cannot exist without a Person, so unlike an interactive lookup the importer cannot answer "I don’t know". Some outcome must be chosen for every row.

Weak evidence is still evidence. A returning entrant who mistyped their identity number on a later entry genuinely is the person the name tier finds. Discarding the signal entirely produces duplicates for a population that is largely correct — and duplicates cost real operator time to merge.

The resolution is to separate the signal from the authority to act on it. A weak tier may raise a suggestion; only a human may convert it into a link. Where an operator is present, ask them. Where none is, take the outcome that fails toward a recoverable duplicate rather than an unrecoverable merge.

A related question has to be settled at the same time, because it is repeatedly mistaken for a defect. Person lookup deliberately spans organisations: the system holds one global pool of mostly-unique person records, and a person added through any organisation reuses an existing record rather than minting a parallel one. The organisation boundary governs disclosure of personal details, not lookup. Documenting the absence of an organisation predicate as a limitation invites someone to "fix" it and fragment the pool.

2. Decision

Only a human may decide that a weak match is the same person. Six rules follow.

  1. Automated processing links an existing Person only on Tier 1 (external UID) or Tier 2 (identity number). These are deliberate, system-of-record claims about identity. No other tier may link an existing Person without a human.

  2. A weak tier never links an existing Person, in any flow. Tier 3 (date of birth + gender) and Tier 4 (first + last name) produce candidates. They may not set the participant’s Person.

  3. Where the flow is interactive, the operator resolves it at that point. The import mapping flow (C05) is classified interactive — an operator is present through column mapping and cell mapping — so weak matches are adjudicated there rather than deferred to a review queue. Headless means no operator at all: a direct API call or a scheduled run. The operator is offered every candidate the weak tiers found, and must always be able to:

    • pick one of the candidates,

    • create a new Person, or

    • skip the row.

  4. Where the flow is headless, create a new Person, link it, and file a merge candidate. With no operator to ask, the importer takes the outcome that is right in the large majority of cases and records the uncertainty. The resulting duplicate is visible and reversible through the merge queue. This is the fallback, not the default: it applies only where no operator is present.

  5. Skip is the ultimate fallback, and is forced when a Person cannot be created. A Person requires first name, last name, date of birth and gender — without them the person cannot be placed in an event at all. A row lacking any of them cannot take rule 4 and is reported in the import’s errors. Skip is otherwise an operator’s deliberate choice, never a silent outcome.

    Date of birth and gender may be derived rather than supplied: a validating national identity number yields both, so a row carrying names and a valid identity number satisfies the requirement without those columns.

  1. A weak match may not mint a Tier-1 fact. The Person-XID write-back records the (RegistrationSystem, external UID) → Person mapping that rule 1 later treats as a system-of-record claim, and an existing mapping is deliberately never repointed. The write-back therefore runs only for resolutions the row itself authorises — an identity number match (Tier 2), or a Person created from the row (Tier 5) — and never for a Tier 3 or Tier 4 resolution. Without this rule a weak guess would be promoted to the strongest tier, silently, permanently, and with the low-confidence warning never appearing again; that promotion was observed in production-shape data (Bug #1129) before the rule was added.

Person lookup is global across organisations, by design. The pool of Person records is shared; the organisation boundary constrains what personal detail is disclosed, not which records may be matched. The absence of an organisation predicate in the matching queries is intended and must not be treated as a defect.

3. Consequences

3.1. Positive

  • The silent failure disappears. Two different humans can no longer be merged by an unattended process, because no unattended process may act on the evidence that would merge them.

  • The error mode moves from an invisible wrong link to a visible duplicate pending merge — recoverable. (Recoverable is doing narrower work than it appears: the merge tooling serves the duplicate case, two records for one human. It cannot repair a wrong link — there the two records are different humans and merging them would itself be the over-merge; recovery is repointing the participant and any external references by hand. The rules above exist precisely so the wrong-link case cannot be produced by an unattended process.)

  • An operator who is present is asked, rather than having a decision made on their behalf and handed back as a warning to audit afterwards.

  • The rule is short enough to apply without re-deriving it: below the identity number, the machine may suggest but not conclude.

  • Stating the global pool as design closes a recurring misreading and stops the matching queries being narrowed by someone treating it as a leak.

3.2. Negative

  • The import mapping flow gains a stage that can pause on person ambiguity, not only on unresolved foreign-key values. That is new UI, a new pending-decision shape, and new state.

  • Headless imports will produce duplicates they previously did not, and someone must work the merge queue. The queue itself is not yet built.

  • A returning entrant whose identity number was mistyped now becomes a duplicate in a headless run instead of being silently re-attached. That is the intended trade — the same behaviour that re-attaches them correctly is the behaviour that merges a parent and child — but it is a real cost paid on every such row.

  • An operator adjudicating rows one at a time is slower than an import that decides for them. Large files with many weak matches become materially more work.

3.3. Neutral

  • Tier 2’s existing behaviour is unchanged, including failing a row outright when an identity number matches several Persons.

  • The external-UID versus identity-number collision path already follows this principle — it links on the stronger claim and files the weaker as a candidate — and needs no change.

  • The guard that suppresses the name tier for a validating, unmatched identity number remains correct and is subsumed rather than replaced: under this ADR the name tier does not link regardless. Until rules 2–4 are implemented, the guard (extended in Bug #1129 with a date-of-birth contradiction check and rule 6’s write-back suppression) is the live protection, and its residue — a malformed identity number with no contradicting date of birth still name-links — remains the accepted interim behaviour.

  • Nothing here requires EventParticipant.person to become nullable.

4. Alternatives Considered

4.1. Leave the participant unlinked until identity is known

The honest model: EventParticipant.person becomes nullable, meaning "identity not yet established", with a backfill workflow to resolve orphans later. Rejected on blast radius. person_id is NOT NULL, is half of the (event_id, person_id) unique constraint, and is read by number assignment, results, leaderboards and security filtering; making it nullable means null-handling in every consumer and a reworked uniqueness model. The create-new floor reaches a usable answer without the schema change. This would become the right choice if participants ever legitimately needed to exist before their identity was known — a genuinely anonymous entry, say.

4.2. Create a new Person on every weak match, in every flow

The simplest uniform rule, and what the earlier design proposed: never ask, always create, always file a candidate. Rejected because it wastes an operator who is already sitting in front of the import. It converts a question that could be answered in one click, with the file open and context to hand, into a queue item resolved later without that context. It remains the behaviour for headless flows, where there is no one to ask.

4.3. Carry person adjudication on the existing cell-mapping mechanism

ImportCellMapping already pauses the job, presents unresolved items and accepts operator corrections, so reusing it directly is attractive. Rejected because it is keyed on (target_field, source_value) with an occurrence count, and carries no row reference: one decision applies to every row sharing that value. Person identity is row-scoped in both directions — two rows with identical names may be different humans, and one human may appear under several spellings — so a value-scoped decision would collapse namesakes by construction, designing in the exact failure this ADR removes. The stage is the right place and should be reused; the entity cannot be.

4.4. Suppress the weak tiers entirely

Delete Tiers 3 and 4 and let anything below an identity number create a new Person. Rejected because it discards a signal that is usually right, producing duplicates in cases an operator could resolve instantly, and losing the candidate list that makes adjudication possible at all. It would become right if the merge queue proved cheaper to work than the adjudication dialog.

5. References

  • Design journal: design-journal/2026-05/participant-identity-correlation.adoc — "Person Matching Priority", "Weak-tier matches must suggest, not link (2026-06-08)", and the 2026-09-01 revision of that decision

  • Design: Person Matching & Deduplication on Import

  • Use cases: C05 Import Mapping Flow, E06 Import Participants

  • ADO: Feature #686 (Cross-System Participant Identity Correlation), Feature #373 (Person Deduplication & Merge), US #773 (headless half — create-new plus merge candidate), US #1118 (interactive half — adjudication in the mapping flow), US #378 (PersonMergeCandidate), US #379 (scheduled dedup and auto-merge — re-scope against rule 1), Bug #1087 (name-tier guard)

  • Code: admin-service/…​/service/PersonService.java (matchPersonForImport), …​/service/imports/ImportCellMappingService.java, database/…​/domain/ImportCellMapping.java