Skip to main content

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

ClassMeaning
DIRECT_IDENTIFIERnames, identifiers, dates of birth or death, precise geolocation
CLINICALobservations, notes, diagnoses, care plans
CONTACTaddress, phone, email
FREE_TEXToperator-entered prose that may contain any of the above
TECHNICALkeys, flags, timestamps and foreign keys that carry no PHI

Dispositions

DispositionMeaning
ENCRYPTEDstored as a pii:v1: envelope, decrypted on read
BLIND_INDEXEDstored as an HMAC so equality search works without decryption
PLAINTEXT_BY_DECISIONreadable, and that is deliberate. The reason is stated
REDACTEDmust 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.

ColumnClassDispositionNote
id, uuid, versionTECHNICALPLAINTEXT_BY_DECISIONsurrogate keys
created_at, last_modified_at, voided_atTECHNICALPLAINTEXT_BY_DECISIONtimestamps
created_by, last_modified_by, voided_byTECHNICALPLAINTEXT_BY_DECISIONactor ids, not names
voidedTECHNICALPLAINTEXT_BY_DECISIONflag
tenant_idTECHNICALPLAINTEXT_BY_DECISIONscoping key
void_reasonFREE_TEXTREDACTED⚠ 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

ColumnClassDispositionNote
person_name_id, person_idTECHNICALPLAINTEXT_BY_DECISION
given_nameDIRECT_IDENTIFIERENCRYPTEDSEC-001
middle_nameDIRECT_IDENTIFIERENCRYPTEDSEC-001
family_nameDIRECT_IDENTIFIERENCRYPTEDSEC-001
given_name_hashDIRECT_IDENTIFIERBLIND_INDEXEDVARCHAR(64); carries no key id, so rotating the blind-index key zeroes search silently
family_name_hashDIRECT_IDENTIFIERBLIND_INDEXEDas above
prefix, suffixDIRECT_IDENTIFIERPLAINTEXT_BY_DECISION"Dr", "Jr". Identifying only in combination, and needed for display
useTECHNICALPLAINTEXT_BY_DECISIONFHIR 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_reasonTECHNICALPLAINTEXT_BY_DECISIONFHIR 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_dateTECHNICALPLAINTEXT_BY_DECISION

person

ColumnClassDispositionNote
person_id, gender_id, death_cause_concept_id, merged_into_person_idTECHNICALPLAINTEXT_BY_DECISIONforeign keys
genderDIRECT_IDENTIFIERPLAINTEXT_BY_DECISIONneeded for clinical decision support and dosing
birth_dateDIRECT_IDENTIFIERPLAINTEXT_PENDING⚠ a date of birth is a HIPAA identifier and is plaintext today. M33-008
death_dateDIRECT_IDENTIFIERPLAINTEXT_PENDING⚠ as above. M33-008
birth_date_estimated, dead, activeTECHNICALPLAINTEXT_BY_DECISIONflags

person_address

ColumnClassDispositionNote
person_address_id, person_id, country_id, division_idTECHNICALPLAINTEXT_BY_DECISION
address_line1, address_line2CONTACTENCRYPTEDSEC-001
postal_codeCONTACTENCRYPTEDSEC-001
latitude, longitudeDIRECT_IDENTIFIERPLAINTEXT_PENDING⚠ precise geolocation identifies a household more sharply than a postal code, which is encrypted beside it. Inconsistent today
division_pathCONTACTPLAINTEXT_BY_DECISIONadministrative hierarchy, not a street address
geolocation_metadata_jsonbFREE_TEXTPLAINTEXT_PENDING⚠ unbounded JSON next to encrypted address lines
preferred, start_date, end_dateTECHNICALPLAINTEXT_BY_DECISION

person_attribute

ColumnClassDispositionNote
person_attribute_id, person_id, attribute_type_idTECHNICALPLAINTEXT_BY_DECISION
value_jsonbCONTACTENCRYPTEDholds phone, email and the signature reference
value_hashCONTACTBLIND_INDEXEDVARCHAR(64), no key id. Enforces phone uniqueness (demographic/021)
active, preferred, start_date, end_dateTECHNICALPLAINTEXT_BY_DECISION

person_physical_identifier

M37-003. The marks on a person's body, recorded so an unidentified patient can be found again.

ColumnClassDispositionNote
person_physical_identifier_id, person_id, physical_identifier_type_idTECHNICALPLAINTEXT_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
detailDIRECT_IDENTIFIERENCRYPTED⚠ "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_locationDIRECT_IDENTIFIERPLAINTEXT_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

ColumnClassDispositionNote
person_physical_identifier_type_id, tenant_id, facility_idTECHNICALPLAINTEXT_BY_DECISION
name, descriptionTECHNICALPLAINTEXT_BY_DECISIONshared reference vocabulary ("Tribal mark"), published by a tenant. Describes no person

patient / patient_identifier / provider

ColumnClassDispositionNote
patient.patient_id, patient.activeTECHNICALPLAINTEXT_BY_DECISION
patient.confidentialityTECHNICALPLAINTEXT_BY_DECISIONa policy label, not the data it protects
patient.extra_payload_jsonbFREE_TEXTPLAINTEXT_PENDING⚠ unbounded JSON on the patient row
patient_identifier.identifier_valueDIRECT_IDENTIFIERPLAINTEXT_PENDING⚠⚠ the MRN or national id, in plaintext. M33-008
patient_identifier.assigning_authorityTECHNICALPLAINTEXT_BY_DECISIONnames the issuer, not the person
patient_identifier.identifier_value_hashDIRECT_IDENTIFIERBLIND_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_jsonbFREE_TEXTPLAINTEXT_PENDING⚠ beside the identifier it describes
patient_identifier.useTECHNICALPLAINTEXT_BY_DECISIONFHIR 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.activeTECHNICALPLAINTEXT_BY_DECISION
provider.provider_codeTECHNICALPLAINTEXT_BY_DECISIONstaff, not patient
provider.credentials_metadata_jsonbFREE_TEXTPLAINTEXT_BY_DECISIONprofessional credentials, not patient data
provider.provider_id, provider.provider_type_id, provider.activeTECHNICALPLAINTEXT_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.

ColumnClassDispositionNote
observation.value_textCLINICALPLAINTEXT_PENDINGVARCHAR(4000). M33-007
observation.value_jsonbCLINICALPLAINTEXT_PENDINGM33-007
observation.commentCLINICALPLAINTEXT_PENDINGVARCHAR(1000), free prose. M33-007
observation.value_coded_non_codedCLINICALPLAINTEXT_BY_DECISIONTERM-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_datetimeCLINICALPLAINTEXT_BY_DECISIONa numeric result is clinical but not identifying, and encrypting it removes range queries
observation.interpretation, statusCLINICALPLAINTEXT_BY_DECISIONcoded 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_atTECHNICALPLAINTEXT_BY_DECISION
clinical_note.contentCLINICALPLAINTEXT_PENDINGVARCHAR(20000). M33-007
clinical_note.note_typeTECHNICALPLAINTEXT_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_datetimeTECHNICALPLAINTEXT_BY_DECISION
care_plan.descriptionCLINICALPLAINTEXT_PENDINGVARCHAR(20000). M33-007
care_plan.titleCLINICALPLAINTEXT_PENDINGVARCHAR(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_idTECHNICALPLAINTEXT_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.

ColumnClassDispositionNote
notification_event.recipientDIRECT_IDENTIFIERENCRYPTED⚠ the patient's own email address or phone number
notification_event.recipient_hashDIRECT_IDENTIFIERBLIND_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.subjectFREE_TEXTENCRYPTEDrendered patient-facing content
notification_event.bodyFREE_TEXTENCRYPTED⚠ the whole message the patient receives — appointment times, results-ready notices, clinic names
notification_event.payload_jsonbFREE_TEXTENCRYPTED⚠ unbounded template variables; the javadoc already called this PHI
notification_event.channel, notification_event.status, notification_event.enqueued_atTECHNICALPLAINTEXT_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_atTECHNICALPLAINTEXT_BY_DECISIONdispatch claim and backoff (NOTIF-001)
notification_event.read_atTECHNICALPLAINTEXT_BY_DECISIONM34-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_idTECHNICALPLAINTEXT_BY_DECISIONpatient_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.

ColumnClassDispositionNote
health_card.card_numberDIRECT_IDENTIFIERENCRYPTED⚠ a HIPAA identifier — the patient's health-plan card number
health_card.card_number_hashDIRECT_IDENTIFIERBLIND_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_valueDIRECT_IDENTIFIERENCRYPTED⚠ 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_hashDIRECT_IDENTIFIERBLIND_INDEXEDkeeps scan-by-QR working against ciphertext
health_card.nfc_identifierDIRECT_IDENTIFIERENCRYPTEDas above, for NFC
health_card.nfc_identifier_hashDIRECT_IDENTIFIERBLIND_INDEXEDkeeps tap-to-read working
health_card.card_type, health_card.issued_date, health_card.expiry_date, health_card.activeTECHNICALPLAINTEXT_BY_DECISIONcard lifecycle; none identifies a person on its own, and the dates drive expiry queries
health_card.card_metadata_jsonbFREE_TEXT⚠ PLAINTEXT_BY_DECISIONNot 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_idTECHNICALPLAINTEXT_BY_DECISIONperson_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.

ColumnClassDispositionNote
audit_event.display_summaryFREE_TEXTREDACTEDVARCHAR(500), written from call sites
audit_event.resource_summaryFREE_TEXTREDACTEDVARCHAR(255). The most exposed of the five: "Patient: John Smith" is the natural thing to write
audit_event.actor_summaryFREE_TEXTREDACTED⚠ a clinician's name is personal data even when the patient's is absent
audit_event.delegated_actor_summaryFREE_TEXTREDACTEDas above
audit_event.policy_decision_summaryFREE_TEXTREDACTEDVARCHAR(500), can quote the resource it decided about
audit_event.contains_phiTECHNICALPLAINTEXT_BY_DECISIONthe flag this ticket makes load-bearing
audit_event.resource_idTECHNICALPLAINTEXT_BY_DECISIONan id, not a summary
audit_event.event_category, event_type, action, resource_type, outcome, initiator_type, service_identityTECHNICALPLAINTEXT_BY_DECISIONcoded vocabularies
audit_event.audit_event_id, facility_id, occurred_at, request_idTECHNICALPLAINTEXT_BY_DECISION
access_log.display_summary, access_log.actor_summaryFREE_TEXTREDACTEDas above
access_log.ip_addressDIRECT_IDENTIFIERPLAINTEXT_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_agentDIRECT_IDENTIFIERPLAINTEXT_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_idTECHNICALPLAINTEXT_BY_DECISION
security_event.display_summary, security_event.actor_summaryFREE_TEXTREDACTEDas above
security_event.ip_addressDIRECT_IDENTIFIERPLAINTEXT_BY_DECISIONas access_log.ip_address
security_event.user_agentDIRECT_IDENTIFIERPLAINTEXT_BY_DECISIONas access_log.user_agent
security_event.reason_category, event_type, outcome, resource_type, service_identityTECHNICALPLAINTEXT_BY_DECISIONcoded vocabularies
security_event.resource_id, security_event_id, facility_id, occurred_at, request_idTECHNICALPLAINTEXT_BY_DECISION