Person Identifiers
Social Security numbers, passports, national IDs, driver's licences, student and employee numbers. They live in core.person_identifiers, are encrypted at rest, and every reveal is audited.
Encryption
Identifier values are stored AES-GCM encrypted in identifier_value_encrypted, alongside identifier_search_hash — an HMAC that makes exact-match lookup possible without decrypting anything.
Two server keys are required:
IDENTIFIER_AES_KEY=...
IDENTIFIER_HMAC_KEY=...On the server these take the SIS_-prefixed forms (SIS_IDENTIFIER_AES_KEY, SIS_IDENTIFIER_HMAC_KEY), and the demo tier has its own pair.
The plaintext column is empty. All 85,112 identifiers on file are encrypted; the legacy identifier_value column holds nothing, and migration 121 added a guard against plaintext being written back into it.
Revealing a value
Viewing an identifier is a distinct, separately granted action — three nodes, none of them menu items:
| Node | Grants |
|---|---|
PEOPLE.IDENTIFIER.REVEAL_LAST4 | See the last four digits |
PEOPLE.IDENTIFIER.REVEAL_FULL | See the whole value |
PEOPLE.IDENTIFIER.ACCESS_LOG | Read the reveal audit log |
Values are click-to-reveal in the interface, and each reveal writes to core.identifier_access_log (migration 120c). Migration 146a made that log survive deletion of the identifier it refers to — otherwise removing a record would erase the evidence that someone had looked at it.
Identifier types are configurable
core.lookup_identifier_type is an extended lookup: it carries behavior, not just a label, and has its own screen at Administration → Identifier Types (ADMIN.IDENTIFIER_TYPES, migration 193).
| Column | Effect |
|---|---|
is_sensitive | Whether reveal gating and audit apply |
uses_dates | Whether issue and expiry dates are collected |
allow_multiple | Whether a person may hold more than one of this type |
country_mode | NONE, OPTIONAL, REQUIRED — is an issuing country collected? |
state_mode | Likewise for the state or province |
input_mask | Entry formatting |
validation_regex | Accepted format |
The seeded types:
| Code | Label | Sensitive | Dates | Multiple | Country | State |
|---|---|---|---|---|---|---|
SSN | Social Security Number | Yes | No | No | NONE | NONE |
PASSPORT | Passport Number | No | Yes | Yes | REQUIRED | NONE |
NATIONAL_ID | National ID | No | No | Yes | REQUIRED | OPTIONAL |
DRIVER_LICENSE | Driver's License | No | Yes | Yes | REQUIRED | REQUIRED |
STUDENT_ID | Student ID | No | No | No | NONE | NONE |
EMPLOYEE_ID | Employee ID | No | No | No | NONE | NONE |
The generic lookup editor refuses to write this type
POST, PATCH and DELETE on /core/lookup-values return 409 for IDENTIFIER_TYPE, naming /people/identifier-types. The generic INSERT would leave allow_multiple at its TRUE default — silently permitting duplicate SSNs — and the generic UPDATE rewrites code, which migrations and the importer key on. Create and delete exist only on the owning endpoint.
Where an identifier was issued
issuing_country_id (→ core.countries) and issuing_state_province_id (→ core.state_provinces, migration 194). A state is always chosen underneath a country, never instead of one.
state_mode = 'REQUIRED' is conditional in practice
Migration 171 seeded subdivisions for the United States and Canada only. So "required" means required where that country's subdivisions are on file — a driver's licence from a country with no subdivisions loaded does not demand one.
Two independent uniqueness rules
These are often confused, and they do different jobs.
1. allow_multiple = false — one of this type per person, wherever issued. Enforced in the service layer.
2. uq_person_identifiers_type_location (migration 195) — enforced by the database for every type:
CREATE UNIQUE INDEX uq_person_identifiers_type_location
ON core.person_identifiers
(person_id, identifier_type_id,
COALESCE(issuing_country_id, 0), COALESCE(issuing_state_province_id, 0));So a dual national keeps two passports — different issuing countries — but not two passports from the same country.
Production is missing this index, deliberately
Migration 195 skips creating the index and warns when stored data would refuse it, rather than aborting a deploy. Production is in that state: one person has two SSN rows that must be adjudicated before the index can be created.
The index exists in this development database. Do not assume its presence when reasoning about production data.
What was dropped
Migration 176 dropped is_primary. There is no "primary identifier" concept — the type plus the issuing location identifies a row, and allow_multiple decides whether there can be more than one.
Related
- Person Records — the record these hang off
- Lookup Tables — the extended-lookup pattern
- Audit Logs — including the reveal log
- People API —
/people/person-identifiers,/people/identifier-types