Plan: Make craig-cases Field Encryption Work End-to-End (+ Activate in Devstack)
On this page
- Status
- Context
- Scope
- Design — standing corrections (apply across children)
- Steps (child issues, dependency-ordered)
- C1 (#969) — idempotent-convert decrypt + stale contract
- C2 (#982) — report-person consumers encryption-aware
- C3 (&67) — report search under encryption (ADR-first)
- C4 (#984) — key-lifecycle infra (per ADR-048 D1/D2/D3/D5)
- C5 (#985) — encryption-capable seeder (per ADR-048 D2/D3/D7)
- C6 (#986) — verification harness + exhaustive assertions (per ADR-048 D6)
- C7 (#987) — activation + boot verifier (per ADR-048 D4)
- C8 (#988) — docs
- Sequencing
- Verification (epic-level, at C7)
- Documentation Updates
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) |
|
Done (2026-07-10) — merged |
C2 (#982) |
|
Done (2026-07-12) — !966 (in-process keyed harness; quality-budget buy-down, no lock raise) |
C3 (&67) |
|
Done (2026-07-13) — sub-epic &67 complete: !970–!977 (MR0–MR7); plan archived |
C4 (#984) |
|
Done (2026-07-14) — C4a |
C5 (#985) |
|
Done (2026-07-12) — !968 |
C6 (#986) |
|
Done (2026-07-13) — !969 |
C7 (#987) |
|
Done (2026-07-13) — #987; guard KCV marker makes the activation reseed automatic |
C8 (#988) |
|
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::NEEDEDepic; 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.envis overwritten bygen-actor-keysand 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 bootsrequired). -
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(NOTreporter_relation—services/craig-cases/src/api/encryption/referral.rs:9) and personsssn_last_four(+ssn_hmacderived 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 + thesql_byte_identityguard (rendered keyless) stay byte-deterministic; only the gitignored.seed-sqlciphertext varies, and nothing pins it. -
Tests assert reality: raw stored ciphertext by deterministic seeded ID (the stored string is base64 of the
CGEFenvelope, not literalCGEF); the binary key-load treats onlyVarError::NotPresentas optional (all otherCryptoErrorfatal). -
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 seederUtc::now()sites with a fixedseed_fixture_epoch()(also emittingdata_quality_issues.detected_atso 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::Reseedintodecide_actionsodev startreseeds 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 -vondev startdeletes.devstack/snapshots/DB backups and wipes every volume, destructive-before-build, and runs unlocked fromvalidate).decide_actiondeliberately stays seed-agnostic; per ADR §D3 the key-change reseed ridesVolumeAction::Wipe(whichdecide_actionalready 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) + thecrypto_key_lineagemigration + 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-timedev clean --confirmtransition (a baredev startre-migrates
re-seeds onto the fresh volume and is functionally correct —dev cleanjust drops the orphaned anonymous volume; the old "markerless ColdStart won’t re-migrate" note was wrong); the per-phase_seed_markerprotocol (content-fingerprint identity + marker-protocol version, allpublic.-qualified) withON_ERROR_STOPon everypsqland shell pre-check skip / fail-loud-on-stale — no advisory lock (deterministic PKs +ON_ERROR_STOPmake a race safe by failing; see ADR §Status amendment); attachment idempotency (LIST-then-skip,object_status='present'); the guard-fixtures staleness fix (tests/fixturesnow inSEED_INPUT_DIRS); andverify_seedinauto_refreshsodev refreshfails 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-webgates oncraig-seed: service_completed_successfully, the seed gates on the new in-graphgarage-initcompose one-shot (devstack/garage-init/— alpine + curl/jq against the Garage admin API ongarage:3903, endpoints verified against the live v2.2.0 node), and the three raw-compose CI jobs switch todocker compose up -d --wait --wait-timeout 900
devstack/ci/write-field-key.sh(materializesfield-key.envfrom the masked+protectedCRAIG_FIELD_ENCRYPTION_KEYproject variable; unset = hard error). Retired: xtaskinit_garage(both call sites),devstack/ci/garage-init.sh,devstack/ci/wait-healthy.sh. Proven live: a deliberately-broken seed fails a rawup -d --waitwith craig-web never started (AC1); a freshdev startseeds attachments on the FIRST run (AC2 — the bucket race the gate exists to kill); the idempotent--waitre-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 locklessvalidate→`ensure_ready`);0600
parent-fsync oncfg(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-scopedwalk_filesskip, 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+ rawdocker compose up— so a CI branch would be dead code + a second CI signal); theverify-seedsubcommand → C6 (correct exposure needs the exited-success fix + lifecycle lock C6’s harness owns; the existingverify_seedreads 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/phone — not 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)
-
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. -
cargo clippy --workspace --all-targets — -D warnings+cargo fmt --check --allclean;cargo xtask quality-budgetsreports no regression vs the locked baseline;Cargo.lockcommitted. -
cargo xtask dev reseedboots casesrequired; the post-seed invariant passes; raw-DB spot-check by seeded ID shows base64-CGEFenvelopes + non-NULL hmacs across all covered columns. -
Full
cargo xtask validatebattery green, including E2E + performance scenarios.
Documentation Updates
-
ADR-048 — key-lifecycle design (this plan’s spike)
-
ADR-020 cross-ref to ADR-048 (C8)
-
CHANGELOG.adoc== Unreleased(C8) -
Configuration Reference
CRAIG_FIELD_ENCRYPTION_KEY(C8) -
Local Development / Known Issues & Lessons Learned — devstack now encrypted; key change requires reseed (C8)
-
.claude/CLAUDE.mdstatus tables (final child)