Skip to main content

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/):

TablePurpose
personMaster person record; tenant-scoped; gender/birth date.
person_nameNames (given/middle/family, prefix/suffix); one preferred per person. PII fields encrypted at rest — see below.
person_addressAddresses; logical geography ids (country…community) validated via metadata; line1/line2/postal_code encrypted at rest.
person_identifier_type / person_identifierPerson-level identifier types + values — a national ID, and the generated UHP_ID. These follow the human being between facilities.
patient_identifier_type / patient_identifierPatient-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_identifierProvider-role identifiers — MDCN, RN numbers.
person_attribute_type / person_attributeTyped attributes (JSONB value); the encrypted flag marks sensitive types (e.g. phone).
person_relationshipDirected person-to-person relationships.
patient / providerRoles 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_id is the sole role discriminator (FK to clinical encounter_role). The legacy free-text role_code was dropped in migration demographic/024. New writes require encounterRoleId; responses also carry read-only roleCode / roleName resolved from the lookup. Migration 024 HALTs on unresolved role_code values (maps legacy CONSULTANT → seeded CONSULTING).
  • 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). The UHP_ID is 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 of OFFICIAL (the default and the meaning of everything written before M37-001), USUAL, TEMP or SECONDARY. ⚠ 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 leaves facility_id null and is checked tenant-wide.
  • Patient registration takes two identifier lists: registration.identifiers (person-level) and patientIdentifiers (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, STANDARD or RESTRICTED (SEC-015). RESTRICTED means no facility-wide discoverability: marking a patient ends their facility visibility scope, and registration opens one only for a STANDARD patient, 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 PatientDto and from every list and search response. It is served only by /patients/{patientId}/confidentiality, and every refusal about a RESTRICTED patient is byte-identical to the refusal for a patient who does not exist.
    • It has its own permission, demographic.patient-confidentiality.{read,write}not demographic.patient.write. Anyone who may edit a patient must not thereby be able to un-VIP them, so the baseline CLINICIAN, NURSE and FRONT_DESK roles 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-001 window covers per-user authorization facts, not patient facts).

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

CaseWhy
Same person on both sidesIt would void the record and re-point its children at itself — silent damage
No reason givenThe merges someone disputes later are exactly the ones that need one
Loser already merged awayThe first merge was not applied; a second would move rows that are already gone
Both hold a patient recordTwo 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:

matchSignalMatched on
IDENTIFIERThe same identifier type and value — the strongest signal, because an identifier type exists to be unique to one human
CONTACT_ATTRIBUTEThe 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_DATEAn 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-text detail, and a body_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 use from TEMP to OFFICIAL and 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:

EndpointHolds
/person-identifier-types, /persons/{personId}/identifiersPerson-level
/patient-identifier-types, /patients/{patientId}/identifiersPatient-role
/provider-identifier-types, /providers/{providerId}/identifiersProvider-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

  • 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), clinical and program (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 (ConditionDto list), same shape as the conditionWidget
  • clinicalHistoryRegistrationClinicalHistoryDto with 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 checkPatientServiceImpl, 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 registrationPatientRegistrationServiceImpl. 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.