PHI data-class registry (M33-006)
Every column in the tables below is classified here, and
PhiDataClassConformanceTest fails the build when the live schema contains a
column this file does not classify.
⚠ That test is the deliverable, not this document. A registry nothing enforces
drifts from the columns it claims to describe, which is the state this ticket
found: audit_event.contains_phi was a flag nothing checked.
Classes
| Class | Meaning |
|---|
DIRECT_IDENTIFIER | names, identifiers, dates of birth or death, precise geolocation |
CLINICAL | observations, notes, diagnoses, care plans |
CONTACT | address, phone, email |
FREE_TEXT | operator-entered prose that may contain any of the above |
TECHNICAL | keys, flags, timestamps and foreign keys that carry no PHI |
Dispositions
| Disposition | Meaning |
|---|
ENCRYPTED | stored as a pii:v1: envelope, decrypted on read |
BLIND_INDEXED | stored as an HMAC so equality search works without decryption |
PLAINTEXT_BY_DECISION | readable, and that is deliberate. The reason is stated |
REDACTED | must never contain PHI; enforced where the value is written |
PLAINTEXT_PENDING | ⚠ plaintext today and should not be. Names the ticket that fixes it |
⚠ PLAINTEXT_PENDING exists so this file records what is true rather than what is
intended. A registry that describes the desired state is the wish this ticket
exists to replace.
Shared audit columns
Present on every table below. Classified once rather than repeated 16 times.
| Column | Class | Disposition | Note |
|---|
id, uuid, version | TECHNICAL | PLAINTEXT_BY_DECISION | surrogate keys |
created_at, last_modified_at, voided_at | TECHNICAL | PLAINTEXT_BY_DECISION | timestamps |
created_by, last_modified_by, voided_by | TECHNICAL | PLAINTEXT_BY_DECISION | actor ids, not names |
voided | TECHNICAL | PLAINTEXT_BY_DECISION | flag |
tenant_id | TECHNICAL | PLAINTEXT_BY_DECISION | scoping key |
void_reason | FREE_TEXT | REDACTED | ⚠ operator-entered prose on every table, including person_name and observation. "Voided because the patient asked to be removed" puts PHI in a column nobody thinks of as clinical. VARCHAR(255) on 189 tables |
person_name
| Column | Class | Disposition | Note |
|---|
person_name_id, person_id | TECHNICAL | PLAINTEXT_BY_DECISION | |
given_name | DIRECT_IDENTIFIER | ENCRYPTED | SEC-001 |
middle_name | DIRECT_IDENTIFIER | ENCRYPTED | SEC-001 |
family_name | DIRECT_IDENTIFIER | ENCRYPTED | SEC-001 |
given_name_hash | DIRECT_IDENTIFIER | BLIND_INDEXED | ⚠ VARCHAR(64); carries no key id, so rotating the blind-index key zeroes search silently |
family_name_hash | DIRECT_IDENTIFIER | BLIND_INDEXED | as above |
prefix, suffix | DIRECT_IDENTIFIER | PLAINTEXT_BY_DECISION | "Dr", "Jr". Identifying only in combination, and needed for display |
use | TECHNICAL | PLAINTEXT_BY_DECISION | FHIR HumanName.use — what kind of name this is, not the name itself (M37-002). ⚠ Must stay readable: ANONYMOUS is how search, duplicate detection and every display tell a generated placeholder from a real name, and encrypting it would make a placeholder indistinguishable from the invented name this ticket exists to stop. |
data_absent_reason | TECHNICAL | PLAINTEXT_BY_DECISION | FHIR data-absent-reason — why a name part is missing (M37-002). A closed vocabulary (unknown, masked, …), never operator prose: it says that nobody knew, never anything about the person. ⚠ If it ever becomes free text it is FREE_TEXT / REDACTED like void_reason, for the reason recorded there. |
preferred, start_date, end_date | TECHNICAL | PLAINTEXT_BY_DECISION | |
person
| Column | Class | Disposition | Note |
|---|
person_id, gender_id, death_cause_concept_id, merged_into_person_id | TECHNICAL | PLAINTEXT_BY_DECISION | foreign keys |
gender | DIRECT_IDENTIFIER | PLAINTEXT_BY_DECISION | needed for clinical decision support and dosing |
birth_date | DIRECT_IDENTIFIER | PLAINTEXT_PENDING | ⚠ a date of birth is a HIPAA identifier and is plaintext today. M33-008 |
death_date | DIRECT_IDENTIFIER | PLAINTEXT_PENDING | ⚠ as above. M33-008 |
birth_date_estimated, dead, active | TECHNICAL | PLAINTEXT_BY_DECISION | flags |
person_address
| Column | Class | Disposition | Note |
|---|
person_address_id, person_id, country_id, division_id | TECHNICAL | PLAINTEXT_BY_DECISION | |
address_line1, address_line2 | CONTACT | ENCRYPTED | SEC-001 |
postal_code | CONTACT | ENCRYPTED | SEC-001 |
latitude, longitude | DIRECT_IDENTIFIER | PLAINTEXT_PENDING | ⚠ precise geolocation identifies a household more sharply than a postal code, which is encrypted beside it. Inconsistent today |
division_path | CONTACT | PLAINTEXT_BY_DECISION | administrative hierarchy, not a street address |
geolocation_metadata_jsonb | FREE_TEXT | PLAINTEXT_PENDING | ⚠ unbounded JSON next to encrypted address lines |
preferred, start_date, end_date | TECHNICAL | PLAINTEXT_BY_DECISION | |
person_attribute
| Column | Class | Disposition | Note |
|---|
person_attribute_id, person_id, attribute_type_id | TECHNICAL | PLAINTEXT_BY_DECISION | |
value_jsonb | CONTACT | ENCRYPTED | holds phone, email and the signature reference |
value_hash | CONTACT | BLIND_INDEXED | ⚠ VARCHAR(64), no key id. Enforces phone uniqueness (demographic/021) |
active, preferred, start_date, end_date | TECHNICAL | PLAINTEXT_BY_DECISION | |
person_physical_identifier
M37-003. The marks on a person's body, recorded so an unidentified patient can be found again.
| Column | Class | Disposition | Note |
|---|
person_physical_identifier_id, person_id, physical_identifier_type_id | TECHNICAL | PLAINTEXT_BY_DECISION | ⚠ the TYPE is the searchable half, and it is a foreign key to a shared vocabulary, not a description of anyone. Encrypting it would defeat the ticket: the acceptance is that a search by marker finds the record |
detail | DIRECT_IDENTIFIER | ENCRYPTED | ⚠ "a star-shaped scar, left forearm" identifies one person as surely as a name does. Encrypted like the clinical free text M33-007 covers; nothing queries into it, so nothing breaks |
body_location | DIRECT_IDENTIFIER | PLAINTEXT_BY_DECISION | ⚠ deliberate, and the weaker of the two calls. A low-cardinality anatomical term ("left cheek") identifies nobody on its own, and leaving it readable lets a marker search narrow candidates without decrypting every row. It becomes identifying only in combination with detail, which is encrypted. Revisit if free-text descriptions start appearing here |
person_physical_identifier_type
| Column | Class | Disposition | Note |
|---|
person_physical_identifier_type_id, tenant_id, facility_id | TECHNICAL | PLAINTEXT_BY_DECISION | |
name, description | TECHNICAL | PLAINTEXT_BY_DECISION | shared reference vocabulary ("Tribal mark"), published by a tenant. Describes no person |
patient / patient_identifier / provider
| Column | Class | Disposition | Note |
|---|
patient.patient_id, patient.active | TECHNICAL | PLAINTEXT_BY_DECISION | |
patient.confidentiality | TECHNICAL | PLAINTEXT_BY_DECISION | a policy label, not the data it protects |
patient.extra_payload_jsonb | FREE_TEXT | PLAINTEXT_PENDING | ⚠ unbounded JSON on the patient row |
patient_identifier.identifier_value | DIRECT_IDENTIFIER | PLAINTEXT_PENDING | ⚠⚠ the MRN or national id, in plaintext. M33-008 |
patient_identifier.assigning_authority | TECHNICAL | PLAINTEXT_BY_DECISION | names the issuer, not the person |
patient_identifier.identifier_value_hash | DIRECT_IDENTIFIER | BLIND_INDEXED | ⚠ a keyed HMAC of the normalised identifier (M33-008), stored so uniqueness and lookup survive encryption of the value beside it. Plaintext by necessity — an index cannot search ciphertext — and safe only while the key is secret: it is a blind index, not a redaction, so anyone holding the key can confirm a guessed identifier. Never log it, never expose it on an API. |
patient_identifier.metadata_jsonb | FREE_TEXT | PLAINTEXT_PENDING | ⚠ beside the identifier it describes |
patient_identifier.use | TECHNICAL | PLAINTEXT_BY_DECISION | FHIR Identifier.use — how far to trust the identifier, not anything about the person (M37-001). ⚠ Not inert, though: TEMP reveals that this patient could not be identified at intake, which is a fact about the circumstances of their care. It stays plaintext because attribution, uniqueness and merge all depend on reading it, and encrypting a four-value enum protects nothing while breaking every one of them. Do not surface it in an export that is not already patient-scoped. |
patient_identifier.patient_identifier_id, patient_identifier.patient_id, patient_identifier.identifier_type_id, patient_identifier.facility_id, patient_identifier.preferred, patient_identifier.active | TECHNICAL | PLAINTEXT_BY_DECISION | |
provider.provider_code | TECHNICAL | PLAINTEXT_BY_DECISION | staff, not patient |
provider.credentials_metadata_jsonb | FREE_TEXT | PLAINTEXT_BY_DECISION | professional credentials, not patient data |
provider.provider_id, provider.provider_type_id, provider.active | TECHNICAL | PLAINTEXT_BY_DECISION | |
observation / clinical_note / care_plan
All clinical PHI is plaintext today; M33-007 encrypts it. Recorded as pending
rather than encrypted, because this file states what is true.
| Column | Class | Disposition | Note |
|---|
observation.value_text | CLINICAL | PLAINTEXT_PENDING | VARCHAR(4000). M33-007 |
observation.value_jsonb | CLINICAL | PLAINTEXT_PENDING | M33-007 |
observation.comment | CLINICAL | PLAINTEXT_PENDING | VARCHAR(1000), free prose. M33-007 |
observation.value_coded_non_coded | CLINICAL | PLAINTEXT_BY_DECISION | TERM-010, the one sanctioned free-text case in a platform whose rule is that clinical vocabulary comes from the concept dictionary. It sits beside value_coded_concept_id, null until a terminologist maps it. ⚠⚠ Must not be encrypted. TERM-011 triage groups these terms by lower(btrim(...)) and ranks them by unmapped occurrence count; PiiCipher uses a random IV per value, so identical terms would encrypt differently, every group would collapse to one row, and the backlog would silently stop ranking. Encrypting it would disable the very mechanism that makes the free-text fallback acceptable |
observation.value_number, value_boolean, value_datetime | CLINICAL | PLAINTEXT_BY_DECISION | a numeric result is clinical but not identifying, and encrypting it removes range queries |
observation.interpretation, status | CLINICAL | PLAINTEXT_BY_DECISION | coded vocabularies |
observation.observation_id, observation.encounter_id, observation.patient_id, observation.facility_id, observation.concept_id, observation.obs_group_id, observation.value_coded_concept_id, observation.previous_version, observation.observed_at | TECHNICAL | PLAINTEXT_BY_DECISION | |
clinical_note.content | CLINICAL | PLAINTEXT_PENDING | VARCHAR(20000). M33-007 |
clinical_note.note_type | TECHNICAL | PLAINTEXT_BY_DECISION | ⚠ a coded type, not PHI. The Avoid section names this pair: content is PHI, note_type is not |
clinical_note.clinical_note_id, clinical_note.encounter_id, clinical_note.patient_id, clinical_note.facility_id, clinical_note.provider_id, clinical_note.note_datetime | TECHNICAL | PLAINTEXT_BY_DECISION | |
care_plan.description | CLINICAL | PLAINTEXT_PENDING | VARCHAR(20000). M33-007 |
care_plan.title | CLINICAL | PLAINTEXT_PENDING | ⚠ VARCHAR(255). "Diabetes management for J. Smith" is a natural title. Not in M33-007's column list |
care_plan.status, care_plan.start_date, care_plan.end_date, care_plan.care_plan_id, care_plan.encounter_id, care_plan.patient_id, care_plan.facility_id, care_plan.provider_id | TECHNICAL | PLAINTEXT_BY_DECISION | |
notification_event
M33-010. ⚠⚠ This table is the second plaintext duplicate of data protected elsewhere: the phone
number SEC-001 encrypts in person_attribute was sitting here in the clear, alongside the rendered
message a patient actually receives. The entity's own javadoc said the payload "may contain PHI and
must never be logged" — and then stored it unencrypted.
| Column | Class | Disposition | Note |
|---|
notification_event.recipient | DIRECT_IDENTIFIER | ENCRYPTED | ⚠ the patient's own email address or phone number |
notification_event.recipient_hash | DIRECT_IDENTIFIER | BLIND_INDEXED | ⚠ keyed HMAC of the normalised recipient, so M10-009's SUPPRESSED opt-out matching and delivery de-duplication survive encryption. A blind index, not a redaction: anyone holding the key can confirm a guessed address. Never log it, never expose it on an API |
notification_event.subject | FREE_TEXT | ENCRYPTED | rendered patient-facing content |
notification_event.body | FREE_TEXT | ENCRYPTED | ⚠ the whole message the patient receives — appointment times, results-ready notices, clinic names |
notification_event.payload_jsonb | FREE_TEXT | ENCRYPTED | ⚠ unbounded template variables; the javadoc already called this PHI |
notification_event.channel, notification_event.status, notification_event.enqueued_at | TECHNICAL | PLAINTEXT_BY_DECISION | ⚠⚠ deliberately NOT encrypted: idx_notification_event_status_channel drives the delivery queue scan, and encrypting any of them turns it into a sequential scan of every notification ever queued |
notification_event.claimed_at, notification_event.next_attempt_at | TECHNICAL | PLAINTEXT_BY_DECISION | dispatch claim and backoff (NOTIF-001) |
notification_event.read_at | TECHNICAL | PLAINTEXT_BY_DECISION | M34-009's in-app read marker. A state timestamp carrying no content — it says that a notification was read, never what it said. ⚠ Classified here rather than by the ticket that added it, because M33-010 is what brings this table under the registry: a column added to a governed table between tickets is invisible until someone governs it. It must stay plaintext to serve unread-count and ordering queries |
notification_event.notification_event_id, notification_event.notification_template_id, notification_event.patient_id, notification_event.person_id, notification_event.facility_id | TECHNICAL | PLAINTEXT_BY_DECISION | ⚠ patient_id/person_id link a message to a person, but they are opaque ids the rest of the schema already carries in the clear; encrypting them here would break every join without hiding anything new |
health_card
SEC-403. ⚠⚠ This table sat outside the registry entirely until 2026-08-28, so no conformance check
had ever looked at it — while holding a health-plan card number, which is one of HIPAA's 18
identifiers, in plaintext. It was found while writing the M33-016 compliance matrix, which is the
argument for writing such a matrix at all.
| Column | Class | Disposition | Note |
|---|
health_card.card_number | DIRECT_IDENTIFIER | ENCRYPTED | ⚠ a HIPAA identifier — the patient's health-plan card number |
health_card.card_number_hash | DIRECT_IDENTIFIER | BLIND_INDEXED | ⚠⚠ not decoration: the partial unique index on the plaintext goes vacuous the moment the column holds random-IV ciphertext, so without this two people could be issued the same card number and nothing would say so |
health_card.qr_code_value | DIRECT_IDENTIFIER | ENCRYPTED | ⚠ encodes the same identity for scanning — and being designed to be read by a device makes it the value most likely to be logged or screenshotted |
health_card.qr_code_value_hash | DIRECT_IDENTIFIER | BLIND_INDEXED | keeps scan-by-QR working against ciphertext |
health_card.nfc_identifier | DIRECT_IDENTIFIER | ENCRYPTED | as above, for NFC |
health_card.nfc_identifier_hash | DIRECT_IDENTIFIER | BLIND_INDEXED | keeps tap-to-read working |
health_card.card_type, health_card.issued_date, health_card.expiry_date, health_card.active | TECHNICAL | PLAINTEXT_BY_DECISION | card lifecycle; none identifies a person on its own, and the dates drive expiry queries |
health_card.card_metadata_jsonb | FREE_TEXT | ⚠ PLAINTEXT_BY_DECISION | Not encrypted, and this is a known gap rather than a judgement that it is safe. The column is jsonb, so a pii:v1: envelope — which is not valid JSON — cannot be stored in it without changing the column type. Whole-blob encryption is also what M33-009 proved breaks per-key queries. The correct fix is per-value encryption in M33-009's shape, tracked separately; until then this column must not be used for PHI. ⚠ HealthCardDto.cardMetadataJson is a writable API field, so a caller can put anything in it — the constraint is a rule, not a mechanism, which is precisely why it is recorded as a gap |
health_card.health_card_id, health_card.person_id, health_card.patient_id | TECHNICAL | PLAINTEXT_BY_DECISION | ⚠ person_id/patient_id link a card to a person, but they are opaque ids the rest of the schema already carries in the clear; encrypting them here would break every join without hiding anything new |
⚠ The plaintext unique indexes (uq_health_card_number, uq_health_card_qr, uq_health_card_nfc)
are deliberately left in place. They still constrain rows that are still plaintext and become
inert rather than wrong once a row is encrypted. Dropping them belongs in a forward migration after
the backfill has completed everywhere.
audit_event / access_log / security_event
⚠ Audit free text is REDACTED, never ENCRYPTED. Encrypting it would make the
audit log unreadable to the people investigating an incident, and audit rows are the
one place you want to read under pressure. The control is that PHI must never
arrive, enforced at the recorder.
⚠ Audit is append-only. Remediation is forward-only: rows are never edited or
deleted to "clean" them.
| Column | Class | Disposition | Note |
|---|
audit_event.display_summary | FREE_TEXT | REDACTED | VARCHAR(500), written from call sites |
audit_event.resource_summary | FREE_TEXT | REDACTED | ⚠ VARCHAR(255). The most exposed of the five: "Patient: John Smith" is the natural thing to write |
audit_event.actor_summary | FREE_TEXT | REDACTED | ⚠ a clinician's name is personal data even when the patient's is absent |
audit_event.delegated_actor_summary | FREE_TEXT | REDACTED | as above |
audit_event.policy_decision_summary | FREE_TEXT | REDACTED | VARCHAR(500), can quote the resource it decided about |
audit_event.contains_phi | TECHNICAL | PLAINTEXT_BY_DECISION | the flag this ticket makes load-bearing |
audit_event.resource_id | TECHNICAL | PLAINTEXT_BY_DECISION | an id, not a summary |
audit_event.event_category, event_type, action, resource_type, outcome, initiator_type, service_identity | TECHNICAL | PLAINTEXT_BY_DECISION | coded vocabularies |
audit_event.audit_event_id, facility_id, occurred_at, request_id | TECHNICAL | PLAINTEXT_BY_DECISION | |
access_log.display_summary, access_log.actor_summary | FREE_TEXT | REDACTED | as above |
access_log.ip_address | DIRECT_IDENTIFIER | PLAINTEXT_BY_DECISION | ⚠ an IP address is personal data under GDPR. Kept readable for the same reason the summaries are: an incident is investigated from these rows, and the lawful basis is that investigation |
access_log.user_agent | DIRECT_IDENTIFIER | PLAINTEXT_BY_DECISION | ⚠ a fingerprinting vector, not merely a browser label. Same reasoning as the IP |
access_log.access_log_id, event_type, outcome, facility_id, occurred_at, request_id | TECHNICAL | PLAINTEXT_BY_DECISION | |
security_event.display_summary, security_event.actor_summary | FREE_TEXT | REDACTED | as above |
security_event.ip_address | DIRECT_IDENTIFIER | PLAINTEXT_BY_DECISION | as access_log.ip_address |
security_event.user_agent | DIRECT_IDENTIFIER | PLAINTEXT_BY_DECISION | as access_log.user_agent |
security_event.reason_category, event_type, outcome, resource_type, service_identity | TECHNICAL | PLAINTEXT_BY_DECISION | coded vocabularies |
security_event.resource_id, security_event_id, facility_id, occurred_at, request_id | TECHNICAL | PLAINTEXT_BY_DECISION | |