Plan: Make craig-cases Field Encryption Work End-to-End (+ Activate in Devstack)

On this page

Status

Epic-level decomposition; each child issue gets its own committed plan when picked up (just-in-time). The steps below are the epic’s child issues in dependency order.

Step Description Status

C1 (#969)

fix: idempotent-convert decrypt (authored) + stale 409→200 conversion contract + concurrent-racer test

Done (2026-07-10) — merged bf9c3691

C2 (#982)

fix: report-person consumers (suggestions + manual linking) encryption-aware, decrypt after authz + craig-cases lib+bin refactor for an in-process keyed-router test

Done (2026-07-12) — !966 (in-process keyed harness; quality-budget buy-down, no lock raise)

C3 (&67)

feat: report search under encryption — decision + implementation. Decomposed into its own sub-epic &67 (capability-based search abstraction) governed by Plan: Capability-Based Encrypted Search (C3) and ADR-049

Done (2026-07-13) — sub-epic &67 complete: !970–!977 (MR0–MR7); plan archived

C4 (#984)

chore: key-lifecycle infra (re-decomposed 2026-07-11) — determinism prereq; C4a schema; C4-floor D1 volume + per-phase seed floor (local-dev); C4-gate completion gate + garage order + CI variable-first key (landed 2026-07-14); D5 provisioning

Done (2026-07-14) — C4a 3ca146fb; determinism 1dfc8a83; C4-floor #1011; C4d !967; host-KCV staleness with C7; C4-gate #1012 (compose completion gate + in-graph garage-init + CI variable-first key)

C5 (#985)

feat: encryption-capable seeder (keyless→plaintext until C7) + seeder-written lineage INSERT + ssn_hmac + D7 keyless guard + semantic encrypted-render tests

Done (2026-07-12) — !968

C6 (#986)

test: verify-seed round-trip + exhaustive raw-DB/API assertions (pre-push + main backstop)

Done (2026-07-13) — !969

C7 (#987)

chore: activate (mount shared key, both required, full-volume reseed) + craig-cases boot verify-only

Done (2026-07-13) — #987; guard KCV marker makes the activation reseed automatic

C8 (#988)

docs: ADR-020 cross-ref to ADR-048 + docs

Done (2026-07-14) — ADR-020 as-built amendment + configuration-reference + local-dev live-key section + known-issues

Epic: &66
Issues: #969 (C1), #982, #984–#988 (C2, C4–C8); C3 = sub-epic &67 (#983 + #1015–#1021)
Branch: feature/devstack-field-encryption (this plan filing); each child on its own feature/… branch

Context

Field-level PII encryption (craig_crypto::FieldEncryptor, AES-256-GCM-SIV) is a core security control for child-welfare PII (ADR-020). It is implemented only in craig-cases (persons ssn_last_four + ssn_hmac blind index; referrals reporter_first_name/reporter_last_name/reporter_phone; reports reporter_*/narrative/JSONB envelopes). The key comes from CRAIG_FIELD_ENCRYPTION_KEY (services/craig-cases/src/main.rs:141); it is never runtime-generated.

It has never run end-to-end. The devstack sets optional mode with no key (docker-compose.yml:182), and CI brings the stack up with bare docker compose up (also keyless), so encryption is off in both — its consumers, search, key lifecycle, and seed have never been exercised. Turning it on (which production’s required mode already does) exposes latent breakage in the keyed path. This was surfaced while fixing #969, whose encrypted-path regression test is a plaintext passthrough today.

This plan makes encryption genuinely work, then activates it — it is not a one-line key flip.

Scope

In scope: the eight child issues (C1–C8) below.

Out of scope (filed as related follow-ups, not epic children):

  • Field encryption for craig-exchange / craig-security (they encrypt nothing today — unaddressed, not decided-against).

  • A production secrets-management program (the deferred SOPS decision) — its own Plan::NEEDED epic; would later migrate the CI-variable/gitignored dev secrets under one scheme.

Design — standing corrections (apply across children)

Authoritative design: ADR-048 (D1–D7). The devstack/CI key lifecycle (storage, seed idempotency, key↔data lineage, activation, secret delivery, verification) is decided there and governs the C2–C8 scope; the re-decomposition table in ADR-048 §Consequences is authoritative for child boundaries. The corrections below predate that ADR and are subsumed by it — where they differ, ADR-048 wins (notably: reseed is skip-if-seeded + full-volume wipe, not in-place TRUNCATE; the seeder writes the lineage row as a plain INSERT and craig-cases verifies it at boot; the seeder is encryption-capable in C5 and only configured required at C7).

These came out of an independent deep review and apply to every child:

  • Key provisioning: the dev key is generated into a gitignored dedicated file by tooling + supplied to CI via a CI/CD variable — never committed (committing a PII key conflicts with the non-overridable security-baseline; devstack-actor-keys.env is overwritten by gen-actor-keys and mounted into ~13 services). The dedicated file is mounted only into craig-cases + craig-seed.

  • Seeder mode: the seeder gets an explicit required-mode (fail if the key is absent when required) — never an ambient-inferred None (which would silently produce plaintext while cases boots required).

  • Non-destructive encryption: the seed encrypts via a projection / write-model of SeedData (idempotent, retry-safe), not an in-place mutate.

  • Covered columns: encrypt exactly referral reporter_first_name / reporter_last_name / reporter_phone (NOT reporter_relationservices/craig-cases/src/api/encryption/referral.rs:9) and persons ssn_last_four (+ ssn_hmac derived from the plaintext SSN first). Use precise "covered columns" language, not "all PII".

  • Determinism: encrypt() uses a random nonce, so encryption runs as a pass between manifest-render (plaintext) and SQL-render (ciphertext). The committed manifest + the sql_byte_identity guard (rendered keyless) stay byte-deterministic; only the gitignored .seed-sql ciphertext varies, and nothing pins it.

  • Tests assert reality: raw stored ciphertext by deterministic seeded ID (the stored string is base64 of the CGEF envelope, not literal CGEF); the binary key-load treats only VarError::NotPresent as optional (all other CryptoError fatal).

  • Framing: a project security/control decision, not a specific compliance/FIPS mandate.

Steps (child issues, dependency-ordered)

C1 (#969) — idempotent-convert decrypt + stale contract

Already-authored fix routes all three convert return paths through decrypted_referral_response. Add: update the stale OpenAPI/doc 409 → 200 (conversion.rs:247), and cover the fresh, sequential-replay, and concurrent AlreadyExists racer arms. Its encrypted-at-rest proof completes at C7. Deps: none.

C2 (#982) — report-person consumers encryption-aware

Both handlers thread Option<Extension<FieldEncryptor>> + Extension<EncryptionMode> and call decrypt_report_pii after the authz check, before consuming children/adults. get_person_suggestions already ordered existence → authz → content, so it only gained the decrypt; create_person_link was re-ordered — the report fetch + authz move into the handler and the bounds-check (hoisted to a testable check_jsonb_index) runs post-authz on the decrypted array, so no report content is inspected pre-authz.

As-built testing decision: rather than the keyless-passthrough pattern (referrals_encryption.rs, whose encrypted teeth wait for C7), C2 proves the fix with genuine HTTP-level encrypted teeth now. That required making craig-cases a lib+bin crate (the craig-intake-keyring model — api::routes
RulesEngineClient + ScreeningPolicyClient exposed) so a test can assemble the real router in-process with a test-controlled key. tests/api/report_persons_encryption.rs drives it via tower::oneshot with an allow/deny AuthzEngine double + injected caseworker Claims: it asserts encrypted-at-rest (raw-DB {"v":..} / is_encrypted), the decrypt round-trip (suggestions source, manual-link valid index), and the authz-before-content ordering (deny → 403, not a content 400). No-devstack unit tests pin the decode/bounds contract. The reusable in-process harness is a candidate to lift into craig-test-lib under C6 (#986).

Deps: none; must precede C7. Devstack encryption stays off until C7.

C3 (&67) — report search under encryption (ADR-first)

Substring search on narrative + reporter names (store/reports.rs:121) is impossible on randomized ciphertext. Resolved (2026-07-13) as a generic capability-based search abstraction rather than a per-field fix: search capability = f(encryption_scheme, logical_type), resolved through one FieldId registry every boundary consults; reports search repoints to plaintext admin_unit + reporter_type. Decision + threat model + crate rejection in ADR-049; decomposed into sub-epic &67 (MR0 #983 + MR1–MR7 #1015–#1021) governed by Plan: Capability-Based Encrypted Search (C3). Deps: none for the framework; MR2 (the C7 gate) needs C6 merged. Must precede C7. Complete (2026-07-13): all eight MRs merged (!970–!977); the C7 dependency on C3 is satisfied. As a rider, MR4 migrated the SSN blind index to a per-field HKDF domain, so C7’s activation reseed derives ssn_hmac under the new domain automatically.

C4 (#984) — key-lifecycle infra (per ADR-048 D1/D2/D3/D5)

Independent review of the single-MR floor found it too large and resting on unverified guard/lifecycle assumptions, so C4 is re-decomposed into focused MRs, with a determinism prerequisite landing first. Fork resolutions (2026-07-11): the seed identity is the full ADR §D2 recipe (seed
families + active-bundle content/version + generator digest + FORMAT_VERSION_V1 + non-SQL input fingerprints); markers are per-phase (rules/SQL/attachments, so a non-fatal non-SQL failure re-runs rather than being skipped forever); the completion gate is the compose service_completed_successfully on craig-web (xtask verify_seed checks a possibly-still-running one-shot, and three CI jobs use raw docker compose up with no seed check).

  • Prereq — seed determinism (#1010, merged 1dfc8a83) — replaced the two seeder Utc::now() sites with a fixed seed_fixture_epoch() (also emitting data_quality_issues.detected_at so no row is resolved-before-detected) and pinned all 6 byte-identity hashes. The content-fingerprint identity is now stable.

  • Guard correctness — considered and dropped (2026-07-11). An earlier review suggested wiring SeedAction::Reseed into decide_action so dev start reseeds on a seed-source change. Dropped: it is redundant with the per-phase marker (which fails loud on a stale fixture when craig-seed re-runs on the rebuild any seed-affecting change triggers) and hazardous (an auto-down -v on dev start deletes .devstack/snapshots/ DB backups and wipes every volume, destructive-before-build, and runs unlocked from validate). decide_action deliberately stays seed-agnostic; per ADR §D3 the key-change reseed rides VolumeAction::Wipe (which decide_action already honors), and the per-phase marker is the seed-freshness enforcement.

  • C4a — DONE (merged 3ca146fb). craig-crypto lineage-protocol surface (kcv() + versioned consts: pub FORMAT_VERSION_V1, KCV_SCHEME_V1, FIELD_CIPHER_ALGO, CANARY_PLAINTEXT) + the crypto_key_lineage migration + a schema constraint test. Additive + dormant.

  • C4-floor — DONE (#1011, local-dev scope). D1 named volume postgres-data:/var/lib/postgresql (correct PG18 path) + a documented one-time dev clean --confirm transition (a bare dev start re-migrates
    re-seeds onto the fresh volume and is functionally correct — dev clean just drops the orphaned anonymous volume; the old "markerless ColdStart won’t re-migrate" note was wrong); the per-phase _seed_marker protocol (content-fingerprint identity + marker-protocol version, all public.-qualified) with ON_ERROR_STOP on every psql and shell pre-check skip / fail-loud-on-stale — no advisory lock (deterministic PKs + ON_ERROR_STOP make a race safe by failing; see ADR §Status amendment); attachment idempotency (LIST-then-skip, object_status='present'); the guard-fixtures staleness fix (tests/fixtures now in SEED_INPUT_DIRS); and verify_seed in auto_refresh so dev refresh fails loud too. Deps: the two prereqs, C4a.

  • C4-gate — DONE (2026-07-14, #1012 + the #984 CI-variable AC; was deferred while CI was the only consumer). As built: craig-web gates on craig-seed: service_completed_successfully, the seed gates on the new in-graph garage-init compose one-shot (devstack/garage-init/ — alpine + curl/jq against the Garage admin API on garage:3903, endpoints verified against the live v2.2.0 node), and the three raw-compose CI jobs switch to docker compose up -d --wait --wait-timeout 900
    devstack/ci/write-field-key.sh (materializes field-key.env from the masked+protected CRAIG_FIELD_ENCRYPTION_KEY project variable; unset = hard error). Retired: xtask init_garage (both call sites), devstack/ci/garage-init.sh, devstack/ci/wait-healthy.sh. Proven live: a deliberately-broken seed fails a raw up -d --wait with craig-web never started (AC1); a fresh dev start seeds attachments on the FIRST run (AC2 — the bucket race the gate exists to kill); the idempotent --wait re-up over completed one-shots exits 0 (the CI shape). Deps: C4-floor.

  • C4d — DONE (2026-07-12, !967). D5 provisioning tooling (local-dev): xtask generates a gitignored, dormant devstack/field-key.env (craig_crypto::generate_key) — atomic temp
    persist_noclobber (no-clobber, race-safe against a lockless validate→`ensure_ready`); 0600
    parent-fsync on cfg(unix), best-effort-write + loud warn on non-unix (ADR was Windows-silent; user-chosen 2026-07-12); an existing file is validated + reused (never re-keyed), loose perms repaired, a symlink/non-regular target rejected. Provisioned before every Compose op (ensure_ready / bring_up / auto_refresh). Excluded from the infra hash via a path-scoped walk_files skip, so key rotation never forces a rebuild. Two items deferred out of C4d (were listed here): the CI variable-first delivery → C4-gate/C7 (CI never runs the tooling — --skip-devstack + raw docker compose up — so a CI branch would be dead code + a second CI signal); the verify-seed subcommand → C6 (correct exposure needs the exited-success fix + lifecycle lock C6’s harness owns; the existing verify_seed reads only {{.ExitCode}}). Deps: C4a, C4-floor (C4-gate is independent CI hardening, not a prerequisite).

Not in C4-floor: the lineage INSERT → C5 (needs the key + craig-crypto in the seeder); the KCV-staleness wipe + cases boot/write-path verifier → C7 (recording the KCV while the key is inactive can’t detect the plaintext→encrypted transition). The auto_refresh marker-before-reseed ordering (non-bounced path) is #1005; CRAIG_FAMILIES 12-vs-9 is folded into the identity (an explicit input); the rules-API non-fatal branch is #1008 (C4-floor takes the attachment half). Deps: prerequisite for C5, C7.

C5 (#985) — encryption-capable seeder (per ADR-048 D2/D3/D7)

As-built. craig-crypto = { workspace = true } (proptest already dev-dep; Cargo.lock committed). Person.ssn_hmac: Option<String> (constructed None) + a persons SqlRow column. A non-destructive encrypt_seed_data projection encrypts the covered columns (persons ssn_last_four; referral reporter_first_name/last_name/phonenot reporter_relation) and derives ssn_hmac = enc.hmac(ssn_last_four) from the plaintext first. The cases txn emits a plain generated INSERT INTO crypto_key_lineage (CryptoLineage; real KCV + encrypt_str(CANARY_PLAINTEXT) when keyed, deterministic kcv:none/canary:none sentinels keyless; matches the as-built migration incl. kcv_version).

D2 identity (the crux): write_output builds all artifacts in memory then writes; the _seed_identity content digest is over the plaintext renders (cases with a sentinel lineage) and compute_identity folds in the real enc.kcv() when keyed (KCV_SENTINEL keyless) — so the identity is stable for a fixed (data, key) yet key-bound. Randomized ciphertext + the real lineage go to the cases output file only (never the identity). compute_identity gained a kcv: &str param (14 callers); it now binds craig_crypto::FORMAT_VERSION_V1 directly (dropped the local dup).

Fail-closed load (main.rs): CRAIG_SEED__ENCRYPTION_MODE accepts only absent (⇒optional), optional, required — any other value is fatal; a malformed key is fatal in both modes (never silent plaintext); only an absent key is tolerated (optional). Keyless devstack until C7.

D7: re-blessed CASES_SHA256 only (ssn_hmac column + sentinel lineage; other 5 byte-identical)
ledger line; a round-trip proptest (in-src); a semantic encrypted-render test that decrypts every covered value from the real write_output render + asserts lineage/ordering; a leak test (covered plaintext absent, reporter_relation present); a real-binary key/mode matrix (absent / valid / required-without-key / malformed / invalid-mode, inherited env stripped). Deps: C4a (crypto surface); keyless→plaintext until C7, so it needs neither C4d’s key file nor the deferred C4-gate.

C6 (#986) — verification harness + exhaustive assertions (per ADR-048 D6)

As-built. The gate is cargo xtask verify-seed --expect {keyless,keyed} (a required value-enum, not a --expect-keyless flag). Asserting the intended mode is the crux: an auto-detecting gate (read the stored KCV, assert whatever it implies) is circular — a silent C7 key-mount failure emits plaintext + the sentinel KCV, which an auto gate passes vacuously. So C6 runs --expect keyless; C7 flips both call sites to --expect keyed, and a plaintext seed then fails the keyed assertions.

Command shape. run (CLI) takes the devstack lifecycle lock, resolves the cases DSN (explicit CRAIG_CASES__DATABASE_URL override — used by CI — else reconcile ports + the live Postgres port, no 5432 fallback), and calls the lock-free verify_inner; validate’s devstack phase calls `verify_inner directly (it already holds no lock). verify_inner = the completion poll + fetch covered rows + the expected-mode assertion. The pure assert_keyless / assert_keyed take fetched rows and are unit-tested with synthetic fixtures both ways (keyed rows fail keyless and vice-versa; wrong key fails; a tampered ssn_hmac fails; all-NULL / empty can’t pass vacuously) — so the keyed branch is exercised now, not left dormant until C7.

Completion poll (bug fix). devstack::verify_seed read only {{.ExitCode}} via a status-ignoring compose helper — a running one-shot reports exit 0, so a still-running seed read as a clean success, and a dead daemon collapsed to Ok(""). Now a checked compose capture polls {{.State}}|{{.ExitCode}} with a timeout through a pure classify_seed (running → keep polling; exited-0 → ok; exited-nonzero / unexpected-state → fail; empty → missing → fail). Both callers (bring_up, auto_refresh) inherit the fix.

Assertions. Keyless: every present covered value is plaintext, ssn_hmac NULL, lineage KCV = the sentinel, rows non-empty + at least one covered value present. Keyed: every present covered value is a CGEF envelope that decrypts, ssn_hmac == hmac(decrypt(ssn_last_four)), lineage KCV = the loaded key’s KCV. The keyed branch loads the key via a hardened xtask::field_key::load_field_key (rejects symlink/non-regular/loose-perms, requires exactly one CRAIG_FIELD_ENCRYPTION_KEY= assignment, and is TOCTOU-safe via a stat→open→fstat inode (dev, ino) re-check — the O_NOFOLLOW intent without a libc/rustix dep; reused by provisioning’s validate_existing_key_file).

Coverage beyond the seed. A throwaway-per-test craig_cases DB (created + migrated + dropped on Drop, WITH (FORCE)) hosts an in-process keyed router that returns its FieldEncryptor — so the C2 consumer tests (rewired onto it) and a new API-created-report at-rest test (4 TEXT: narrative
reporter_{first,last}_name + reporter_phone; 3 JSONB {"v":..}: children/adults/raw_submission) run under a real key without polluting the shared DB. A decrypt-through-service round-trip test reads seeded persons/referrals back through the live cases API and asserts the covered fields equal the deterministic craig_seed::generate(seed=42, families=12, georgia) oracle (mode-independent: green keyless now, and the decrypt-through proof at C7). KCV_SENTINEL moved to its canonical home in craig-crypto (value unchanged ⇒ byte-identity + golden vector stable).

from_env boundary. Absent / valid / malformed-base64 are already covered by C5’s real-binary matrix; C6 adds the two genuine gaps — valid-base64-wrong-length and non-Unicode — to that subprocess matrix (both fatal in each mode).

Wiring. validate’s devstack phase runs `verify_inner(Keyless, dsn) after ensure_ready
reconcile, before the mutating integration tests; the pentest CI job (allow_failure, main/schedule) runs verify-seed --expect keyless as a non-blocking backstop. The encryption-asserting cases light up with C7.

Deferred → C7 (#987): the negative restart-cases-under-wrong-key ⇒ refuse-boot test — it needs C7’s boot verifier to exist, so it can neither land nor pass here (its #986 AC moved to #987 during the AC-refine). Deps: C2, C5.

C7 (#987) — activation + boot verifier (per ADR-048 D4)

Mount the shared key into cases + seed (coordinated recreate), set both required, full-volume down -v reseed; add the craig-cases boot verify-only (recompute KCV + decrypt canary, refuse on mismatch). Run the full pre-push battery incl. E2E + performance; prove #969/#413 on encrypted data. Activation correctness is the D6 round-trip (cases boots empty-first, then reads the seeded data under the shared key). Deps: C2, C3, C4a + C4-floor + C4d (crypto surface + volume floor + key file), C5, C6 — not the deferred C4-gate (CI hardening is independent of activation).

As-built (2026-07-13). Compose mounts devstack/field-key.env into cases + seed with both required; the D3 host-KCV staleness deferred here from C4 landed as a new .devstack/field-key.kcv marker whose absence/mismatch escalates to VolumeAction::Wipe (the migration-change mechanism) — so the first post-C7 dev start IS the coordinated activation reseed, no manual step. The boot verifier is craig_cases::boot_verify (verify_key_lineage, run before the authz engine/workers): scheme metadata → KCV → canary, plus the lineage-absent branch’s registry-driven ciphertext scan (a craig_crypto::ENVELOPE_B64_PREFIX SQL prefilter confirmed by exact is_encrypted). The #986-inherited negative landed as the boot_verify test matrix over the C6 throwaway-DB harness ("restart under key B" ≡ key-A lineage + key-B encryptor, which a fresh stack cannot exercise). Both verify-seed sites flipped to --expect keyed; the CI backstop stays red-but-allow_failure until C4-gate’s variable-first delivery (compose now fail-closes on the missing env_file in CI by design).

C8 (#988) — docs

ADR-048 carries the as-built key lifecycle; C8 adds a cross-reference from ADR-020 to it (and the C3 search decision to its own ADR), then updates CHANGELOG.adoc, Configuration Reference, Local Development / Known Issues & Lessons Learned (devstack now encrypted; D1 one-time dev clean; key-change⇒reseed), and .claude/CLAUDE.md status. Deps: with C7.

As-built (2026-07-14). ADR-020 gained the epic-&66 amendment (key lifecycle → ADR-048; boot verify-only pre-empting the read-time misconfiguration 500s; the seed-determinism render pass; the ADR-049 search decision; keyless dev loop ended). Configuration Reference gained the craig-cases ENCRYPTION_MODE + CRAIG_FIELD_ENCRYPTION_KEY rows (the key is deliberately un-prefixed — shared with craig-seed) and the craig-seed section was un-staled. Local Development's key section flipped dormant → live, with the field-key KCV row added to the staleness table. Known Issues & Lessons Learned records the markers-absent boot-refusal edge (cold-start path skips the staleness check; remedy cargo xtask dev reseed) and the red-but-allow_failure CI devstack jobs pending C4-gate. The C7 note that local-dev’s verify-seed passage and quality-gates were already corrected in C7’s review sweep held — C8 found the section-level dormant framing still stale and fixed that.

Sequencing

  • Wave A (parallel): C1, C4.

  • Wave B: C2, C3 (independent); C5 (after C4a).

  • Wave C: C6 (after C2, C5).

  • Wave D: C7 then/with C8 (after all).

Verification (epic-level, at C7)

  1. cargo nextest run -p craig-seed (re-blessed byte-identity + lib proptest + orchestration test) + -p craig-cases --test api --run-ignored all (C2 consumers, seeded SSN search, #969 racers) green.

  2. cargo clippy --workspace --all-targets — -D warnings + cargo fmt --check --all clean; cargo xtask quality-budgets reports no regression vs the locked baseline; Cargo.lock committed.

  3. cargo xtask dev reseed boots cases required; the post-seed invariant passes; raw-DB spot-check by seeded ID shows base64-CGEF envelopes + non-NULL hmacs across all covered columns.

  4. Full cargo xtask validate battery green, including E2E + performance scenarios.

Documentation Updates

Edit this page · latest