Participant Identity & External Identifiers (XID)

Related reading

  • Event Participant Import Design — how EP import consumes this model

  • Person Matching & Deduplication on Import — the tier ladder that resolves a row to a Person using these identifiers

  • Results Import Design — how result import resolves participants by XID

  • design-journal/2026-05/participant-identity-correlation.adoc — the working design record (alternatives weighed, decisions dated, ADO Feature #686)

  • This document — the durable "how it is built and why" for identity correlation

1. Why this exists

Events are fed by external systems — a legacy WPCA registration database, a timing provider, a partner federation. Each of those systems has its own primary keys for the same human being and the same entry. When we import participants or results we must answer two questions without creating duplicates and without trusting a number blindly:

  1. Which person is this? — the same athlete may enter through several source systems over years, each with a different internal id.

  2. Which entry (EventParticipant) is this? — within one event, a source system identifies its own registration row by its own key.

The model below pins both answers to durable, queryable identifiers rather than to fragile matching on names or bib numbers.

2. Vocabulary: the two XIDs

"XID" is shorthand for external identifier — an id minted by a system other than ours. There are exactly two, and keeping them distinct is the single most important thing in this design.

Term Grain Stored as Means

Person-XID

Person (across all events)

PersonExternalReference.external_uid, scoped by registration_system_id

"In source system S, this human being is known as U." A person accumulates many Person-XIDs — one per source system they have ever come through. 1:N.

Participant-XID

EventParticipant (one event)

EventParticipant.registrationId

"In the source system, this entry into this event is row R." Scoped to the event; the same value may recur in a different event without collision. 1:1 with the EP.

The distinction is not academic. A result file that carries the source system’s person id needs the Person-XID path (resolve person → that event’s EP). A file that carries the source system’s entry id needs the Participant-XID path (resolve the EP directly). Picking the wrong one silently fails to resolve — see the mode-mismatch guard.

3. Schema

person_external_reference
  id                       bigint PK
  person_id                bigint FK → wp_users(id)            (the Person / User)
  registration_system_id   bigint FK → registration_system(id) (the source system S)
  external_uid             varchar                              (the Person-XID U)
  UNIQUE (registration_system_id, external_uid)                (U is unique *within* S, never globally)

event_participant
  ...
  registration_id          varchar                              (the Participant-XID, source EP key)
  UNIQUE (event_id, person_id)                                  (one entry per person per event)

event
  ...
  source_registration_system_id  bigint FK → registration_system(id)  (US #769; nullable)

Three points carry the design:

  • PersonExternalReference is 1:N, not a column on wp_users. A single column could only hold one source id; an athlete who came through WPCA in 2019 and a partner federation in 2024 has two, both valid, both needed for future resolution.

  • Uniqueness is (registration_system_id, external_uid), never external_uid alone. Two unrelated source systems are free to mint the same string; only the pair identifies a person.

  • Event.source_registration_system_id records which external system this event’s field came from, so result import can default and scope Person-XID resolution without the operator re-typing it every time (US #769 — see Identity update policy).

4. The four-field identity DTO

Participant import accepts up to four identity inputs per row; the importer uses the first that resolves, in trust order. This replaced an earlier single-"externalId" column that conflated person identity with entry identity.

DTO field XID Resolves

ourEventParticipantId

(ours)

Directly to EventParticipant.id — our own PK, fully trusted.

ourPersonId

(ours)

To Person/User.id — our own PK; the EP is then the person’s entry in the event.

sourceSystemParticipantId

Participant-XID

To the EP via registrationId within the event (first lookup). Written onto EventParticipant.registrationId. It is not the final word: the entry follows the person (see Import: Event Participant).

sourceSystemId + person fields

Person-XID

sourceSystemId names the source system S; on a confirmed person match the source’s person id is written back as a PersonExternalReference (Person-XID), so the next import resolves in one hop.

4.1. Trust matrix: (is_self, trustPKs)

Whether a supplied PK is trusted depends on whose system it is and whether the operator vouched for it:

RegistrationSystem.is_self trustPKs Behaviour

true (the id is ours)

—

ourEventParticipantId / ourPersonId are trusted directly — they are our own keys.

false (a foreign system)

true

Rejected. The upload fails fast with 400 error.matrixRejected before any row is read (EventParticipantResourceEx.importEventParticipants → validateImportMatrix). A foreign system’s primary keys are never trusted directly, so this combination cannot be expressed — trustPKs is only meaningful for a is_self = true system. Passing trustPKs=true with no sourceSystemId at all is rejected for the same reason: without the flag there is nothing to resolve the matrix against, so it fails closed.

false

false (default)

Treat foreign PKs as hints only; fall through to identity matching (ID number, then name+DOB) and a sample-based fingerprint check before trusting the link.

The fingerprint check (a sampled field-by-field comparison of the incoming row against the stored person) guards against a bad foreign mapping silently merging two different humans. The full matrix, the fingerprint scoring, and the merge-candidate path are recorded in the design journal (participant-identity-correlation.adoc).

5. Identity update policy

PersonIdentityUpdatePolicy decides what an import row may change on the person it resolves to. It treats the identity number, the fields derived from a South African ID, and ordinary personal details under three separate regimes. The person is resolved first (see Person Matching): when a different rider has taken over an entry, the row resolves to that rider’s own person, so nothing below is ever applied to the entry’s previous person.

5.1. Identity number and type

  • A blank stored identity is always filled from the row, whatever updatePII is set to.

  • The type is never taken from the row as given. A number that validates as a South African ID is NATIONAL. Otherwise an explicit PASSPORT hint is honoured, and anything else is OTHER.

  • Upgrade. When the stored identity does not validate (a truncated ID, a wrong check digit, a passport or a placeholder) and the row carries a validating South African ID, the row’s ID replaces it, whatever updatePII is set to. This is identity reassignment, not a correction of personal details. The type becomes NATIONAL, and date of birth, gender and identity country (ZA) are re-derived from the ID in the same step.

  • No upgrade to an ID another person holds. When another person already carries the row’s SA ID, the upgrade is refused (skip reason SAID_HELD_BY_OTHER_PERSON) and the identity is left as stored. Landing it would leave two persons sharing one ID, and every later row carrying it would fail as a duplicate. The row is warned with the other person’s id, and the pair is filed for merge review. This also covers an external-UID collision: the row’s Person-XID names one person and its SA ID another, so the SA ID is not copied onto the Person-XID person before a human has decided.

  • Never downgrade. A validating stored ID is never replaced by a non-validating one (skip reason WOULD_REPLACE_VALID_WITH_INVALID), whatever updatePII is set to.

  • Replacing a validating ID with a different validating ID is not an upgrade. It is gated on updatePII (skip reason UPDATE_PII_DISABLED).

5.2. Fields derived from a South African ID

When a validating ID is on the person, date of birth, gender and identity country belong to it. When the row’s ID lands, by blank-fill, upgrade or an updatePII overwrite, they are re-derived from it and any values the row supplies for them are ignored. When the stored ID validates and the row’s does not land, a row value that contradicts the ID is rejected (skip reason SAID_ATOMIC).

5.3. Personal details

Names, and date of birth, gender and identity country when no validating ID is in play, are filled when blank. A populated value is overwritten only when updatePII is true. A blank row value never clears a stored one.

5.4. Reporting

Row warning Meaning

IDENTITY_UPGRADED

The upgrade landed and the person was saved. Names the previous and new identity and the derived fields that changed. An identity of ten characters or more is masked to its first and last two characters; a shorter one to its last two only.

IDENTITY_UPGRADE_NOT_SAVED

The upgrade was applied but saving the person failed, so nothing changed. Re-import the row.

SAID_HELD_BY_OTHER_PERSON

The upgrade was refused because another person holds the SA ID. The pair is filed for merge review.

IDENTITY_UPGRADE_CATEGORY_MISMATCH

The re-derived date of birth or gender falls outside the entry’s category (its age window or gender restriction), checked the same way as a category change. The category is left unchanged for an organiser to review.

An upgraded existing entry is reported UPDATED; a new entry CREATED.

5.5. The entry’s copy

The matched event_participant row keeps its own copy of identity number, identity type, date of birth and gender. The import refreshes that copy from the person in the same transaction, so it follows any change the policy makes.

5.6. Scope

The policy governs the participant import (create and update paths, and the legacy direct-add path, which upgrades too). The membership import and the person merge apply their own identity rules and do not yet route through it; in particular, the person merge keeps a passport or OTHER identity rather than upgrading it.

6. Event source registration system (US #769)

Event.sourceRegistrationSystem is set once, the first time participants are imported from a non-self source (set-if-absent during EP import). It then becomes the default for that event’s result imports:

  • Result import defaults its source-system selector from the event; the operator does not re-pick it on every file.

  • The operator may override it on a result import. Changing it warns and asks for confirmation, then persists the new value back onto the event — the design is deliberately a hybrid of "remembered on the event" and "settable per import" so a correction sticks without forcing a separate event-edit step.

  • When EXTERNAL_PERSON_UID resolution runs (see below), the source system scopes the Person-XID lookup: PersonExternalReference WHERE registration_system_id = <event/import source> AND external_uid = <file value>.

7. How the importers consume this

Importer Identity use

Event Participant import

Reads the four-field DTO; matches/creates the Person; writes the Participant-XID onto registrationId; on a confirmed foreign match writes back the Person-XID; sets Event.sourceRegistrationSystem if absent.

Result import

Resolves the finisher to an EP by one of four modes. EXTERNAL_PERSON_UID is the Person-XID path (source system + file value → PersonExternalReference → person → that event’s EP); REGISTRATION_ID is the Participant-XID path; EPID / PERSON_ID use our own keys.

8. Design rationale — alternatives weighed

Rejected alternative Why this design instead

Single external_id column on Person

Holds only one source system; can’t represent an athlete known to several. Conflates person identity with per-event entry identity. The 1:N PersonExternalReference + separate Participant-XID splits the two grains cleanly.

Globally-unique external_uid

Different source systems legitimately reuse ids. Uniqueness must be the (registration_system_id, external_uid) pair, or imports from a second system would collide.

Trust every foreign PK in the file

A single bad mapping merges two people irreversibly. The (is_self, trustPKs) matrix plus the fingerprint check make trust explicit and verified.

Re-prompt the source system on every result import

Tedious and error-prone across many category files for one event. Event.sourceRegistrationSystem remembers it; the per-import override + confirm keeps it correctable.