chore(security): asymmetric PII encryption — persons master index stores subject names/DOB plaintext (resolve via ADR)

Summary

CRAIG's PII-at-rest encryption posture is asymmetric: PII captured at the intake edge (a reporter's name/phone, the narrative, and the children/adults/raw_submission JSONB) is application-layer encrypted, but the same subjects' PII becomes plaintext once it is materialized into the persons master index. A child's name is ciphertext in reports.children and plaintext in persons.first_name/last_name; subject date-of-birth is plaintext; gender/race/ethnicity are plaintext.

This is not a clear-cut bug — it follows a real technical boundary (see below) and the demographic fields are deliberately queryable for compliance reporting. But the subject name + DOB half of it is an under-finished encryption rollout, not a deliberate decision, and it deserves an explicit architectural ruling rather than sitting as an accident. Discovered while implementing NCANDS export D1 (#651 (closed)); not in scope for that work (NCANDS reads these fields as the architecture currently intends).

Evidence

  • persons.first_name, last_name, date_of_birth, gender, race, ethnicity are plaintext structured columns; the only encrypted person field is ssn_last_fourservices/craig-cases/src/store/models.rs (Person) + services/craig-cases/src/api/encryption/person.rs (decrypts SSN only).
  • Reporter PII is encrypted at the report/referral edge — services/craig-cases/src/api/encryption/report.rs (encrypt_report_pii: reporter_first/last/phone + narrative + the 3 JSONB envelopes) and .../encryption/referral.rs (encrypt_referral_pii).
  • A child's name flows plaintext into persons via the convert/auto-link + person-create path (services/craig-cases/src/matching/mod.rs, store/persons.rs::create_person).

Why it exists (the searchability boundary)

App-layer encryption uses a random nonce per value, so ciphertext is opaque to Postgres — no ILIKE, no pg_trgm similarity, no range/equality predicates. The persons table is the operational master index and must stay searchable:

  • Names are matched by fuzzy trigram ((first_name||' '||last_name) % $1, GIN index) for person dedup/matching (ADR-019) — store/report_persons.rs::prefilter_candidates_by_name. A blind index gives equality, not fuzzy, so it can't replace trigram on its own.
  • DOB is filtered by equality/range in store/persons.rs::search_persons.
  • gender/race/ethnicity are low-cardinality categorical attributes whose purpose is aggregate compliance reporting (AFCARS/NCANDS, dashboards, rules-engine inputs); encrypting them yields weak confidentiality (low cardinality leaks under frequency analysis) and breaks aggregate queries.
  • The pattern for "encrypt but keep searchable" already exists for SSN: encrypted value (ssn_last_four) + an HMAC blind index (ssn_hmac) for equality search. Names/DOB never got this treatment.

So gender/race/ethnicity plaintext is architecturally defensible and stable. Subject name + DOB plaintext is the genuinely-arguable part — DOB is a quasi-identifier and could take the SSN encrypt+blind-index treatment; names are the asymmetry vs. the encrypted reporter name.

The decision to make

Pick a posture and record it in an ADR (do not silently leave it accidental):

  1. Harden — extend the SSN encrypt+blind-index pattern to subject name + DOB. Equality search is preserved via blind index; fuzzy name matching needs a tokenized/normalized blind-index scheme (or a deliberate move of fuzzy matching off the encrypted column). Requires a migration + matching-engine rework.
  2. Formally accept — document the threat model in an ADR: persons is the authz-gated operational record; compensating controls are role-based access (craig-authz), audit, and at-rest disk/volume encryption; the intake edge stays encrypted because it is the broadest-exposure surface. Demographic categoricals stay plaintext by design for reporting.
  3. Hybrid — encrypt DOB with a blind index (equality suffices for matching), keep names plaintext (or tokenized) for trigram, keep categoricals plaintext. Closes the quasi-identifier gap at lowest cost.

Acceptance criteria

  • An ADR records the chosen posture (harden / accept / hybrid) with the threat model + rationale.
  • If harden/hybrid: a migration + blind-index plan and a matching-engine impact assessment, filed as follow-up implementation issue(s).
  • If accept: the compensating controls (authz, audit, at-rest encryption) are documented as the posture, and the coding-conventions/security Antora page reflects the deliberate boundary.
  • The reporter-edge vs. subject-master asymmetry is explicitly addressed (not left implicit).

Context

  • Type: design-first (ADR before any code).
  • Priority: real confidentiality consideration, but pre-1.0 with no production data and authz-gated — deliberate, not urgent.
  • Discovered during NCANDS export D1 (#651 (closed)); related, not blocking.