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 encrypt_field/decrypt_field helpers named below are retired, and DOB encryption is now an additive registry flip in craig-cases-fields::personsdate_of_birth is modeled Plaintext{Date} today, and BlindIndexSpec is Date-ready (Canon::IsoDate canonicalizes to %Y-%m-%d bytes), so the follow-up flips the scheme to BlindIndex (+ a dob_hmac sibling + reseed) and the registry-driven write/decrypt/search/seeder/verify-seed paths pick it up with no new hand-written sites. Fail-closed is inherited from the craig_search::row walk (ADR-020 as amended), and the blind index inherits the per-field HKDF domain separation (hmac_domain) the SSN index migrated to under C3 — so a dob_hmac cannot be correlated against ssn_hmac or any other column. The posture decided here (hybrid: DOB hardened, name plaintext accepted under compensating controls) is unchanged.

Related: ADR-019 (report-person linking + pluggable jurisdictional matching — the consumer of plaintext name/DOB; a harden/hybrid choice touches its matching surface), ADR-020 (encryption fail-closed semantics — the runtime contract any newly-encrypted field inherits).

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

first_name, last_name

plaintext TEXT

fuzzy name matching (below)

date_of_birth

plaintext DATE

DOB range/year matching (below)

gender / race / ethnicity

plaintext TEXT

demographics / reporting

ssn_last_four

AES-256-GCM-SIV ciphertext

+ ssn_hmac blind index for exact lookup

(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:

  1. SQL trigram prefilter (fuzzy name). services/craig-cases/src/store/report_persons.rs gathers candidates with WHERE (first_name || ' ' || last_name) % $1 ORDER BY similarity(first_name || ' ' || last_name, $1) DESC, backed by a pg_trgm GIN index. This is trigram similarity, not equality.

  2. Rust signal compute (fuzzy name + range DOB). crates/craig-matching/src/signals.rs compute_signals runs on the decrypted candidate record and emits name_similarity_score (trigram-Jaccard via strsim, fuzzy), plus DOB signals that are explicitly range, not just equality: dob_exact_match and dob_within_30_days and dob_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.

  1. Encrypt date_of_birth; add a dob_hmac blind index for exact-DOB lookup. This mirrors the shipped ssn_last_four + ssn_hmac pattern exactly (same FieldEncryptor, 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.

  2. Store a derived plaintext birth_year (coarse bucket) for the range/year signals. dob_within_30_days and dob_year_match cannot run on an encrypted column or an equality-only blind index. Re-source the year/coarse-range signals from a deliberately-coarse birth_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.

  3. Accept first_name / last_name plaintext — 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:

    1. Authorization — access to person records is role-gated; the craig-authz per-field permission capability (landed 2026-06-22) is the enforcement point for field-level read controls.

    2. Audit — person reads/writes are auditable through the existing per-service audit pipeline (ADR-022 outbox).

    3. At-rest — production storage volume encryption is the baseline backstop for the plaintext name column.

  4. 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_hmac pattern (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_name remain 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_year in 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.

Neutral

  • Pre-1.0, no production data (per project record + the report-PII migration’s own note): the DOB encryption is a destructive migration + reseed, no backfill machinery required.

  • No change to the SSN handling, the report-edge encryption, or ADR-020’s runtime contract.

Implementation scope (follow-up issues, not this ADR)

This ADR is the decision; the code lands as separately-weighted follow-ups:

  1. Migration — encrypt date_of_birth, add dob_hmac (+ index) and a derived plaintext birth_year; reseed (no backfill).

  2. Encryption moduleencrypt/decrypt DOB + dob_hmac compute in the persons create/update/search paths (mirror the SSN sites).

  3. Matching changesearch_persons exact-DOB → dob_hmac; compute_signals range/year signals → birth_year source; PersonRecord plumbing; amend ADR-019’s signal-source note.

  4. Tests — round-trip, blind-index equality, matching-still-works integration, a proptest on the birth_year derivation invariant.

  • ADR-019 — the matching consumer; item 2 of the Decision amends its DOB signal source when the follow-up lands.

  • ADR-020 — the fail-closed contract DOB encryption inherits unchanged.

  • ADR-022 — the audit-outbox pipeline that is one of the name-plaintext compensating controls.

Edit this page · latest