Demographic (Person, Patient, Provider) — How it works
Overview
The demographic engine owns the platform's people: the person master record and the patient and
provider roles layered on top, plus a person's names, addresses, identifiers, attributes, and
relationships. It is a generic identity foundation — no clinical knowledge — that other engines
reference by id (logical references, no cross-engine FK). It validates geography references
in-process against the metadata engine.
Data model & ownership
Owned tables (migrations under core/src/main/resources/db/changelog/demographic/):
| Table | Purpose |
|---|---|
person | Master person record; tenant-scoped; gender/birth date. |
person_name | Names (given/middle/family, prefix/suffix); one preferred per person. PII fields encrypted at rest — see below. |
person_address | Addresses; logical geography ids (country…community) validated via metadata; line1/line2/postal_code encrypted at rest. |
person_identifier_type / person_identifier | Person-level identifier types + values — a national ID, and the generated UHP_ID. These follow the human being between facilities. |
patient_identifier_type / patient_identifier | Patient-role identifier types + values (M23-005A) — a hospital number, issued by a facility because someone is a patient there. Carries facility_id, the issuing facility. |
provider_identifier_type / provider_identifier | Provider-role identifiers — MDCN, RN numbers. |
person_attribute_type / person_attribute | Typed attributes (JSONB value); the encrypted flag marks sensitive types (e.g. phone). |
person_relationship | Directed person-to-person relationships. |
patient / provider | Roles on a person; provider carries code + facility assignments. patient.confidentiality is the VIP flag (SEC-015). |
Key rules & invariants
- Provider facility assignment role (TERM-008R):
provider_facility_assignment.encounter_role_idis the sole role discriminator (FK to clinicalencounter_role). The legacy free-textrole_codewas dropped in migrationdemographic/024. New writes requireencounterRoleId; responses also carry read-onlyroleCode/roleNameresolved from the lookup. Migration 024 HALTs on unresolvedrole_codevalues (maps legacyCONSULTANT→ seededCONSULTING). - One preferred name and one preferred address per person (service logic + partial unique index).
- Geography ids on an address must form a valid country → state → county → city → community hierarchy.
- Cross-engine references (e.g. a clinical visit's
patient_id) are logical — no FK across engines. - Three identifier models, and they are not interchangeable. Which table an identifier belongs in
is decided by why the person has it: a national ID belongs to the human (
person_identifier), a hospital number belongs to them as a patient of a particular facility (patient_identifier), an MDCN number belongs to them as a provider (provider_identifier). TheUHP_IDis person-level and generated server-side at registration; it is never offered as a choice. - An identifier also carries how far to trust it — FHIR R4's
Identifier.use, one ofOFFICIAL(the default and the meaning of everything written before M37-001),USUAL,TEMPorSECONDARY. ⚠ This is a different question from which scheme issued it, which is the identifier type. A facility that issues its own hospital numbers can still issue a provisional one, and collapsing the two would make that inexpressible. - A temporary patient number is a real UHP number, from the same sequence as any other, flagged
use = TEMP(M37-001). ⚠ It is deliberately not a separate numbering scheme: the number goes on a wristband, into a referral and onto a lab form before anyone knows who the patient is, and every one of those references must still resolve after the temporary record is merged into the real one. An identifier that stops resolving is worse than no identifier at all. - A patient identifier is unique per issuing facility, not per tenant — enforced by a partial
unique index on
(type, COALESCE(facility_id, zero-uuid), lower(value))and by a service check scoped identically. Two facilities may legitimately issue the same hospital number; checking by type alone would refuse the second one, and ignoring the facility would let the database reject what the service accepted. A tenant-wide number leavesfacility_idnull and is checked tenant-wide. - Patient registration takes two identifier lists:
registration.identifiers(person-level) andpatientIdentifiers(patient-role). The latter is stamped with the session facility when the caller omits one, since the registrar is standing in the facility issuing the number. - PII is encrypted at rest when enabled (SEC-001): the services encrypt names, address lines + postal code, and phone-type attribute values before save and decrypt after fetch; name/phone search uses a blind index. See PII encryption at rest.
- A patient may be marked confidential (VIP) —
patient.confidentiality,STANDARDorRESTRICTED(SEC-015).RESTRICTEDmeans no facility-wide discoverability: marking a patient ends their facility visibility scope, and registration opens one only for aSTANDARDpatient, so re-registering a VIP cannot quietly un-mark them. Access continues only through an explicit access grant, provider scope, or break-glass — it never widens anything, and consent and OPA still run.- The flag is itself a disclosure, so it is deliberately absent from
PatientDtoand from every list and search response. It is served only by/patients/{patientId}/confidentiality, and every refusal about aRESTRICTEDpatient is byte-identical to the refusal for a patient who does not exist. - It has its own permission,
demographic.patient-confidentiality.{read,write}— notdemographic.patient.write. Anyone who may edit a patient must not thereby be able to un-VIP them, so the baselineCLINICIAN,NURSEandFRONT_DESKroles are excluded from it by name. - Marking takes effect on the next request: the flag and the visibility rows are read uncached
(the 45s
CACHE-001window covers per-user authorization facts, not patient facts).
- The flag is itself a disclosure, so it is deliberately absent from
Patient merge (M13-008)
POST /api/v1/demographic/person-merges folds one person into another.
The loser is voided with a forwarding address, never deleted. Clinical rows across other engines
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.
person.merged_into_person_id is what lets a stale reference be redirected rather than simply finding
nothing.
The merge is recorded. person_merge holds survivor, loser, the mandatory reason and a summary of
what moved — the only thing that can answer "why does this chart contain two people's history?" a
year later, and the only way a mistaken merge can be understood well enough to unpick.
What is refused, and why
| Case | Why |
|---|---|
| Same person on both sides | It would void the record and re-point its children at itself — silent damage |
| No reason given | The merges someone disputes later are exactly the ones that need one |
| Loser already merged away | The first merge was not applied; a second would move rows that are already gone |
| Both hold a patient record | Two patient roles mean two sets of clinical history. Which survives is a clinical decision about records, not a data-tidying one, so it is refused rather than guessed |
Everything happens in one transaction: a half-applied merge is the worst outcome available — some history moved, some not, and no way to tell which without comparing two charts by hand.
⚠ Other engines are not re-pointed in place. They resolve a merged person through
merged_into_person_id. Rewriting logical references across every engine would be a distributed write
with no transaction around it, and a partial one would be undetectable.
Finding the duplicates (M13-008B)
GET /api/v1/demographic/person-merges/candidates?personId={uuid}&limit=25 lists the people who may be
the same human as this one, strongest evidence first.
Exact match only, deliberately. Every candidate comes from a deterministic comparison, and each one says which:
matchSignal | Matched on |
|---|---|
IDENTIFIER | The same identifier type and value — the strongest signal, because an identifier type exists to be unique to one human |
CONTACT_ATTRIBUTE | The same blind index of an encrypted attribute — the same phone or email, compared as HMACs so neither value is ever decrypted. Suggestive, not conclusive: families share a phone |
NAME_AND_BIRTH_DATE | An exact given name, family name and date of birth. A subject with no birth date produces no name matches at all — name alone is not a duplicate signal in any population large enough to need a merge tool |
The signal is an enum, not a reason string, and it names the mechanism rather than the type.
NATIONAL_ID or PHONE would freeze one deployment's vocabulary into a platform contract — identifier
and attribute types are tenant-scoped CRUD rows that deployments create and rename.
Probabilistic matching is not built. A fuzzy matcher's threshold is the design: too loose and the merge screen buries real duplicates under near-strangers, too tight and it finds nothing exact match missed. Choosing it needs a corpus to measure against and a judgement about what a false merge costs. Exact match makes the screen usable now, and the fuzzy layer arrives as additional signals rather than as a redefinition of these.
The response carries no names. The list is reachable for any person the caller may read, so it returns the person id, the UHP id, the date of birth, whether the candidate holds a patient record (the merge is refused when both sides do) and the signals. Opening a candidate is an ordinary person read, which decrypts properly and takes its own object-level decision.
Candidates never cross the tenant, and a person id from another tenant, one that names nobody, and one
the caller may not see all answer the same 404 "Person not found" — the id alone must not confirm
that a person exists somewhere else. Its permission is demographic.person-merge.read, split from
demographic.person-merge.write: spotting that two records describe one person is a registration-desk
act, while folding two charts together is irreversible in practice.
Physical identifiers (M37-003)
When nobody can name the patient, the marks on their body are what staff have to go on. The meeting that produced this milestone named tribal marks, birthmarks, missing teeth and tattoos.
person_physical_identifier_type— the coded vocabulary. A rule-1 CRUD type, owned facility → tenant → country like every other reference type, seeded with eight markers onto the country's government tenant.person_physical_identifier— one marker on one person: the type, a free-textdetail, and abody_location.
⚠ The type is the point, not the description. A note reading "tribal marks on both cheeks"
reunites nobody with their record, because nobody can query it: the next clerk searches for a tribal
mark and finds nothing. POST /persons/{id}/physical-identifiers therefore refuses a marker with
no type — an accepted-but-unfindable record is worse than a rejected one, because the clerk believes
the patient can now be identified.
⚠ detail is encrypted (DIRECT_IDENTIFIER); "a star-shaped scar, left forearm" identifies one
person as surely as a name. Nothing queries into it, which is what makes encrypting it free: the
search is by marker type, a foreign key. A future free-text search over descriptions needs a blind
index, not a plaintext column.
⚠ body_location is deliberately plaintext, and it is the weaker of the two calls. A
low-cardinality anatomical term identifies nobody alone, and leaving it readable lets a marker search
narrow candidates without decrypting every row. Both rows are in the PHI data-class registry.
GET /physical-identifiers/search?typeIds=… returns person ids, access-filtered in the service.
The repository query is deliberately blind to scope, so without that filter the search would
enumerate people across every facility the caller cannot see.
Module re-point after patient merge (M37-005)
When M37-004 moves the 31 public patient references, imaging, lab, and pharmacy still hold their
own rows. Core orchestrates POST /api/v1/demographic/person-merges/module-repoint, which Feign-calls
each module's POST …/patient-merges/repoint. This is not atomic with the core merge transaction —
retry when a module returns PARTIAL and read incompleteTables in the response.
Merging two clinical histories (M37-004)
The merge used to refuse when both people held a patient record: two patient roles mean two sets of clinical history, and choosing which survives is a clinical decision about records rather than data tidying. That reasoning stands. M37-004 does not remove it, it answers it.
PersonMergeRequestDto.mergeClinicalHistory defaults to false, and without it the refusal is
unchanged. With it, the caller states they have taken the decision — and the surviving record is the
one they already named in survivorPersonId. The system still never guesses.
When consent is given, 28 public patient-referencing tables move to the surviving patient inside
the same transaction as the person merge. A half-applied merge is the worst outcome available:
some history moved, some not, and no way to tell which without comparing two charts by hand.
⚠ patient_identifier is among them, so a temporary UHP ID keeps resolving after the merge. It
was written on a wristband, quoted in a referral and typed into a lab form (M37-001).
⚠ The retired patient row is voided with a forwarding address, never deleted — the same reason
the person row is: engines hold logical patient references with no foreign key.
⚠ The seven tables owned by imaging, lab and pharmacy are not here. Core cannot write them;
they move through M37-005's per-module contract, which is explicitly not atomic with this.
The reconciliation report (PersonMergeResultDto.moved) names every table and the rows moved,
including tables where nothing moved — "nothing to move" and "never looked at" must not read the
same.
⚠ PatientRepointTableCoverageTest fails the build when a public table carries a patient_id that
PatientRepointTables does not name. A missed table does not error at merge time: the rows simply
stay pointed at the retired patient and surface months later as a chart with a hole in it.
Promoting a temporary record (M37-006)
The other outcome: the patient is nobody we knew. POST /persons/{id}/promote completes the identity
in place — real name, gender, date of birth, address.
⚠ It does not create a patient and merge into one. There is nothing to merge: the visits, observations and orders recorded while they were unidentified are already on the right chart. The record was always theirs; it just did not have their name on it. A needless merge is a needless chance to lose something.
Which is why the acceptance reads as two things not happening:
- The clinical history is unchanged — same
personId, so every clinical row still points here. - The UHP ID is the same one. Promotion changes a temporary identifier's
usefromTEMPtoOFFICIALand nothing else. The number went on a wristband, into a referral and onto a lab form (M37-001).
⚠ The anonymous placeholder name is voided, not deleted: "we asked and nobody knew" is a fact
about that admission, and it is what explains the anonymous entries in the chart. A real name is
inserted beside it with use = OFFICIAL, following the platform's parent-update / child-void edit
semantics.
⚠ birthDateEstimated is cleared. An unidentified registration stores an approximate age as an
estimated birth date; leaving the flag set would keep every age-based clinical rule downstream
treating a now-known date as a guess.
⚠ A record that was never temporary cannot be promoted. Without that refusal the endpoint is a way to overwrite any patient's name, gender and date of birth in one call, with the audit trail reading "promotion" rather than "identity changed". Correcting a known patient goes through the ordinary update, where it looks like what it is.
National identity lookup (M37-009)
IdentityLookupProvider (in exchange) is how the platform asks a national register who somebody is.
⚠⚠ An SPI, not a NIMC client. D5: the platform runs in Africa, the Americas and Europe, and a national identity service is the single most country-specific thing in this milestone. Nigeria has NIMC and a NIN, Ghana has the NIA and a Ghana Card, India has UIDAI and an Aadhaar, and none share a request shape, a legal basis or an availability promise. Hardcoding one would put a Nigerian integration on the critical path of every other country's deployment.
⚠ Selected by country, so a deployment in one country simply does not have another's adapter in play, rather than having it present and disabled. A disabled adapter is one flag away from making a foreign government query about a patient.
⚠ NOT_FOUND and UNAVAILABLE are different values on purpose. "The register holds no such
number" and "we could not reach the register" lead to opposite actions. Collapsing them into a
boolean has a clerk retyping a correct NIN because a service was down.
⚠ No provider is an answer, not a failure. Most deployments have no identity integration at all,
and registration proceeds exactly as it does today — M37-002's whole argument is that a patient is
treated before anybody knows who they are. An identity lookup must never be able to prevent that, so
a missing provider returns NOT_CONFIGURED and a throwing one returns UNAVAILABLE.
⚠ The Nigeria adapter ships without a live NIMC call, deliberately. What it does locally is real:
it rejects a malformed NIN (eleven digits, anchored) before anything leaves the building. What it does
not do is pretend — with no endpoint configured it answers NOT_CONFIGURED, never a fabricated match.
A simulator answering MATCHED would make every test and demo pass while the integration does not
exist, and the failure would surface the first time somebody trusted a name it invented.
⚠ A lookup is a disclosure. Asking a register "who holds this number" tells it that this platform
is interested in that person. reason is required for the same purpose it is on a merge.
Configuration: platform.identity-lookup.nigeria.country-id, platform.identity-lookup.nigeria.endpoint.
API
See the API Reference. Endpoint groups under /api/v1/demographic: persons, patient
and provider roles, names, addresses, identifiers, attributes, relationships, and demographic search
(GET /persons?..., paginated search).
Identifier endpoints follow the three models above:
| Endpoint | Holds |
|---|---|
/person-identifier-types, /persons/{personId}/identifiers | Person-level |
/patient-identifier-types, /patients/{patientId}/identifiers | Patient-role |
/provider-identifier-types, /providers/{providerId}/identifiers | Provider-role |
GET/PUT /patients/{patientId}/confidentiality read and set the VIP flag. Both need
demographic.patient-confidentiality.*; both answer 404 "Patient not found" for a patient the caller
may not see, which is the same answer an id that names nobody gets.
The single-patient read (GET /patients/{patientId}) embeds the patient's identifiers, each with its
type name resolved, so a patient screen never has to fetch the type lookup — or the person API — to
show a hospital number. The patient list read deliberately does not.
Person reads and search rows may include a read-only pronouns projection (M39-003) resolved from
the preferred Pronouns person attribute — never from gender code.
Configuration & feature flags
- PII encryption toggles (
pii.encryption.*) — see the PII encryption operations page.
Related features
- Denied record reads answer as not-found — the platform-wide version of the byte-identical refusal above; the clinical engine adopted it in SEC-011.
metadata(geography validation),access-control/identity-access(who may see a person),clinicalandprogram(reference patients by id),concept(attribute value coding).
Clinical history at registration (M39-001)
POST /api/v1/demographic/patient-registrations accepts optional clinical history alongside allergies:
conditions— known medical conditions (ConditionDtolist), same shape as theconditionWidgetclinicalHistory—RegistrationClinicalHistoryDtowith medical, medication and radiographic narrative text plus a coded food-insecurity answer (foodInsecurityAnswerConceptId)
History text is stored as concept-bound observations (question concepts configured under
clinical.history.* in ClinicalHistoryProperties). Conditions and observations are written in the
same transaction as the patient role — a rejected clinical row rolls the registration back.
Liquibase form/038-m39-001-clinical-history-forms.yaml extends the PATIENT_REGISTRATION engine form
and the triage OBS form with matching fields plus conditionWidget for triage review.
Why it is this way
A person exists only by registering a patient, a staff member or a provider. There is no standalone person write, and that is a security boundary rather than a UI convenience: a person reachable on its own would be a way to read or change someone's identity without the checks each registration applies.
person.tenant_id is provenance, not ownership. It records who first registered the person — not
who is allowed to see them. Visibility is decided by visibility scope and access grants, which is why
the same human can legitimately be a patient at one facility and staff at another.
Names are searched through a blind index when encryption is on. Encrypted values cannot be
matched with LIKE, so a separate hash column carries searchability without carrying the name.
Traps
⚠ Do not turn the provenance check into a tenant-equality check — PatientServiceImpl,
PersonServiceImpl and PersonRegistrationServiceImpl all say so, in the same words, because it
looks like a missing check to three different readers. Equality here would make a person invisible to
every tenant except the one that first registered them.
⚠ Order matters in patient registration — PatientRegistrationServiceImpl. The visibility scope
is written first, because adding the relationship is itself guarded. Reversing the two produces a
guard that refuses the very record being created.
⚠ One grant there is deliberately open-ended, unlike every other grant path — the source says so explicitly at the point it happens. Read the reason before narrowing it.
⚠ gender rows are tenant-scoped, so the database's foreign key alone does not prove the value
is usable in the current tenant — Person. Existence is not the test, the same trap the clinical
engine carries.
⚠ Enabling encryption without the one-time backfill leaves existing patients unfindable by name
while reads by id keep working — which is exactly what makes it survive a smoke test. See
operations/pii-encryption.md.