ADR-041: Searchable PII in the Persons Master Index — Hybrid (Blind-Indexed DOB, Accepted-Plaintext Name)
On this page
Status
Accepted (2026-06-23). Resolves #671. Implementation is tracked in follow-up issues (see Implementation scope); this ADR fixes the posture, not the code.
|
Amendment (2026-07-13, C3/ADR-049, #1021). The implementation path for the DOB hardening changed
with the capability-based search abstraction
(ADR-049): the |
Context
CRAIG encrypts PII at rest with craig-crypto (AES-256-GCM-SIV, random per-value nonce; crates/craig-crypto/src/lib.rs). But the persons master index stores the subject’s identifying fields in plaintext structured columns — only ssn_last_four is encrypted:
| Column | Storage | Why |
|---|---|---|
|
plaintext |
fuzzy name matching (below) |
|
plaintext |
DOB range/year matching (below) |
|
plaintext |
demographics / reporting |
|
AES-256-GCM-SIV ciphertext |
+ |
(services/craig-cases/migrations/20240201000000_create_case_tables.sql; SSN blind index added in 20260330000000_ssn_blind_index.sql.)
This is a genuine asymmetry: a child’s name is AES-256-GCM-SIV ciphertext at the report edge (reports.children, encrypted JSONB) but becomes plaintext in persons.first_name once a person record is materialized. The issue title frames it as "asymmetric PII encryption" — but see Alternatives §E: asymmetric (public-key) crypto is the wrong tool here.
Why the index fields are plaintext: matching is fuzzy and range-based, not exact
Person de-duplication / report-person linking (ADR-019) reads plaintext name and DOB in two phases, both of which require cleartext:
-
SQL trigram prefilter (fuzzy name).
services/craig-cases/src/store/report_persons.rsgathers candidates withWHERE (first_name || ' ' || last_name) % $1 ORDER BY similarity(first_name || ' ' || last_name, $1) DESC, backed by apg_trgmGIN index. This is trigram similarity, not equality. -
Rust signal compute (fuzzy name + range DOB).
crates/craig-matching/src/signals.rscompute_signalsruns on the decrypted candidate record and emitsname_similarity_score(trigram-Jaccard viastrsim, fuzzy), plus DOB signals that are explicitly range, not just equality:dob_exact_matchanddob_within_30_daysanddob_year_match.
A random-nonce AES-GCM-SIV ciphertext is opaque to Postgres — no ILIKE, no pg_trgm, no range comparison. That is precisely why the index fields are plaintext today, and it is the constraint any resolution must respect.
The crux: a name cannot be both fuzzy-matched and encrypted-at-rest without dismantling ADR-019’s matching design. A keyed-HMAC blind index (the proven ssn_hmac pattern, FieldEncryptor::hmac, HMAC-SHA256 over an HKDF-derived sub-key) gives equality only — it cannot serve trigram similarity or a DOB ±30-day range. So the two index fields are not equivalent: DOB has a closable exact-or-derived path; the name does not.
Decision
Adopt a hybrid posture: harden the closable gap (DOB), and formally, explicitly accept name plaintext as a documented residual risk under compensating controls.
-
Encrypt
date_of_birth; add adob_hmacblind index for exact-DOB lookup. This mirrors the shippedssn_last_four+ssn_hmacpattern exactly (sameFieldEncryptor, same HKDF purpose-key derivation, same fail-closed semantics from ADR-020). It removes the precise birth date — a strong quasi-identifier — from at-rest plaintext while preserving exact-DOB equality matching via the deterministic token. -
Store a derived plaintext
birth_year(coarse bucket) for the range/year signals.dob_within_30_daysanddob_year_matchcannot run on an encrypted column or an equality-only blind index. Re-source the year/coarse-range signals from a deliberately-coarsebirth_year(or a wider bucket) kept in plaintext; keep the precise day encrypted. This preserves ADR-019’s DOB signals at the cost of exposing only the birth year, not the full date. -
Accept
first_name/last_nameplaintext — fuzzy trigram de-duplication (a shipped, ADR-019-backed capability) requires it, and the equality-only alternatives (blind index, deterministic encryption) would either abandon fuzzy de-dup or demand a high-complexity token/n-gram searchable-encryption scheme that still degrades match quality and leaks more than a single blind index. For an authz-gated operational record with no production data and a P2 priority, that trade is not worth it. The acceptance is explicit and carries compensating controls:-
Authorization — access to person records is role-gated; the
craig-authzper-field permission capability (landed 2026-06-22) is the enforcement point for field-level read controls. -
Audit — person reads/writes are auditable through the existing per-service audit pipeline (ADR-022 outbox).
-
At-rest — production storage volume encryption is the baseline backstop for the plaintext name column.
-
-
Rule out asymmetric / public-key encryption despite the issue title. Public-key envelope encryption lets an ingest path write ciphertext without holding the decrypt key; it does not make data searchable (no trigram, no
ILIKE, no DOB range). It cannot solve the master-index search problem and is not adopted (see Alternatives §E).
ADR-020 is not amended — newly-encrypted DOB simply reuses encrypt_field/decrypt_field and inherits fail-closed for free. ADR-019’s matching surface (the DOB signal source) IS affected by item 2 above; the change is specified in the DOB-hardening follow-up and amends ADR-019’s signal-source note when it lands.
Alternatives considered
A. Full harden — encrypt name + DOB behind blind indexes (rejected)
Equality-only blind indexes break ADR-019’s trigram fuzzy name matching and DOB-range signals. Preserving fuzzy match would require n-gram/token searchable encryption — high complexity, more leakage than a single blind index, and still-degraded match quality. Disproportionate for a P2, no-production-data, authz-gated record. If the security stakeholder later rejects name-plaintext outright, a token/n-gram blind-index research spike is the path — filed deliberately and separately weighted, not folded into this ADR.
B. Formally accept all of it — names AND DOB stay plaintext (rejected as the primary, retained as the floor)
Zero code; a doc-only posture. Rejected as the primary because DOB is the genuinely-closable quasi-identifier and leaving the precise date in plaintext when the SSN pattern is right there is an avoidable gap. (The hybrid is "accept B for names, harden DOB.")
C. Hybrid (CHOSEN)
Encrypt DOB + dob_hmac + derived birth_year; accept name plaintext under compensating controls. Closes the strongest quasi-identifier exposure at low cost (copy the SSN pattern) without breaking ADR-019.
D. Deterministic encryption of the matched fields (rejected)
Equality only (same limit as a blind index) and leaks equality patterns in the stored ciphertext — strictly worse than an HMAC blind index for the same capability.
E. Asymmetric / public-key envelope (rejected — does not solve the problem)
The issue’s "asymmetric" framing. Public-key/envelope encryption enables write-without-decrypt-key, not search. It cannot serve trigram, ILIKE, or DOB-range, so it does nothing for the master-index matching problem. The real "asymmetric" tension here is the posture asymmetry (encrypted at the edge, plaintext in the index), not an asymmetric-key solution.
Consequences
Positive
-
The precise date of birth — a strong quasi-identifier — leaves at-rest plaintext, reusing the proven, fail-closed
ssn_hmacpattern (low implementation risk). -
Exact-DOB matching is preserved (blind index); year/coarse-range matching is preserved (derived
birth_year). -
The name-plaintext residual risk is now an explicit, documented, control-backed decision rather than an undocumented default — auditable from the public tree (Kerckhoffs).
Negative / residual risk
-
first_name/last_nameremain plaintext at rest; the compensating controls (authz, audit, volume encryption) mitigate but do not eliminate the exposure. A breach of the at-rest store still yields names (but not precise DOB or SSN). -
birth_yearin plaintext narrows the DOB protection to "year-granular" — the day/month are protected, the year is not. This is an intentional matching/privacy trade. -
ADR-019’s DOB signal compute must move from the encrypted column to
birth_year; a small matching-code change with its own test surface.
Implementation scope (follow-up issues, not this ADR)
This ADR is the decision; the code lands as separately-weighted follow-ups:
-
Migration — encrypt
date_of_birth, adddob_hmac(+ index) and a derived plaintextbirth_year; reseed (no backfill). -
Encryption module —
encrypt/decryptDOB +dob_hmaccompute in the persons create/update/search paths (mirror the SSN sites). -
Matching change —
search_personsexact-DOB →dob_hmac;compute_signalsrange/year signals →birth_yearsource;PersonRecordplumbing; amend ADR-019’s signal-source note. -
Tests — round-trip, blind-index equality, matching-still-works integration, a proptest on the
birth_yearderivation invariant.