ADR-048: Field-Encryption Key Lifecycle — Devstack/CI Storage, Seeding, Lineage & Activation

On this page

Status

Accepted (2026-07-10). Resolves the design forks blocking epic &66 (Make craig-cases Field Encryption Work End-to-End). Builds on ADR-020 (fail-closed field-helper semantics + the CGEF self-describing ciphertext envelope) — this ADR adds the operational key lifecycle around that primitive and does not change ADR-020’s runtime field-helper semantics. The C8 doc child (#988) amends ADR-020 to cross-reference this ADR rather than duplicating it.

Amendment (2026-07-12, C4-floor as-built, #1011). The C4 decision text below is unchanged in intent; these are the as-built refinements discovered during implementation:

  • D2 concurrency — no advisory lock. The per-DB _seed_marker uses a shell pre-check (seed.sh)
    ON_ERROR_STOP on every psql + deterministic-PK fail-loud, not the "advisory lock + post-lock recheck" §D2 sketched. All 34 seeded tables carry an explicit deterministic id UUID PRIMARY KEY and each per-DB file is one atomic BEGIN…COMMIT, so a concurrent/second seeder hits a duplicate-key that ON_ERROR_STOP turns into a loud non-zero exit rolling back the whole transaction — safe by failing, never a silent partial commit. The lock only bought graceful-skip-under-race at the cost of a server-side ERROR logged in all six DBs on every re-run + fragile stderr-sentinel parsing.

  • D2 marker placement — wrapped, not embedded. seed_marker::wrap_sql_phase injects the marker into the already-rendered transaction (in write_output), so the byte-identity guard (which calls render_*_sql directly) is untouched — no re-bless. The identity’s content_digest is over the plaintext (pre- encryption) renders and stays so under C5; the key enters only via the KCV component.

  • D1 recreation test — read-only. Automated as a SHOW data_directory assertion (the PG18 path only holds if the named volume is mounted at the parent); the complementary "no stray anonymous parent" docker inspect check is a one-time transition step in Local Development rather than a force-recreating test (which would disrupt a concurrent devstack).

  • Completion gate split out (CI deferred). The D6/§Consequences "`ON_ERROR_STOP` floor + completion gate ship together" pairing is delivered for local dev by verify_seed on every xtask bring path plus a new verify_seed in auto_refresh (dev refresh). The declarative compose service_completed_successfully gate + the garage-init compose one-shot + CI seed-exit fail-loud are deferred to a follow-up C4-gate (CI is currently unreliable and out of scope). The C4 table’s other items (lineage INSERT, kcv()/canary wiring, D5 provisioning, the verify-seed skeleton) are NOT in C4-floor — they remain in C5/C4d/C7 per the decomposition.

Amendment (2026-07-12, C4d as-built, #984). D5 provisioning tooling landed for local dev, with two scope refinements from adversarial review:

  • Windows/non-unix strategy (D5 was silent). On cfg(unix) the generated devstack/field-key.env is hardened to 0600 with a parent-dir fsync; on other platforms it is a best-effort atomic write plus a loud "permissions not hardened — use WSL2, or supply the key yourself" warning (user-chosen 2026-07-12). Publication is a temp file + persist_noclobber (no-clobber rename), which is symlink-safe on write and race-safe against a lockless validate→`ensure_ready`; a literal O_NOFOLLOW open is moot because C4d only writes (the read path is C7). An existing file is validated + reused (never re-keyed); loose perms are repaired; a symlink/non-regular target is refused.

  • CI variable-first + verify-seed deferred out of C4d. CI never runs the local generate-once tooling (--skip-devstack + raw docker compose up), so the D5 CI variable-first delivery would be dead code + a second CI signal — it rides with the deferred C4-gate. The verify-seed subcommand is deferred to C6: the existing verify_seed reads only {{.ExitCode}}, so a still-running one-shot false-succeeds; a correct exposure (exited-success / poll-with-timeout + the lifecycle lock) belongs with C6’s verification harness rather than a placeholder over a known-broken primitive.

The generated key stays dormant (no compose mount, nothing reads it) until C7 activates encryption.

Amendment (2026-07-12, C5 as-built, #985). The encryption-capable seeder landed, keyless→plaintext until C7:

  • D2 identity, realized. write_output’s `_seed_identity content digest is over the plaintext renders (cases with a sentinel lineage row) and compute_identity folds in the real FieldEncryptor::kcv() when keyed (KCV_SENTINEL keyless). The randomized ciphertext (random nonce) + the real crypto_key_lineage row are written to the cases output file only — never the identity — so the identity is stable for a fixed (data, key) yet key-bound (a key change flips it → reseed). The local seed_marker::FORMAT_VERSION dup was dropped in favour of craig_crypto::FORMAT_VERSION_V1.

  • Lineage matches the as-built migration. The seeder’s INSERT INTO crypto_key_lineage supplies (kcv, kcv_version, canary, envelope_version, algo) — including kcv_version, which the §D3 prose above omits; the migration + its constraint tests are authoritative.

  • Fail-closed load (D4). CRAIG_SEED__ENCRYPTION_MODE accepts only absent (⇒ optional), optional, required; any other value is a hard error. A malformed/wrong-length key is fatal in both modes (only an absent key is tolerated, and only in optional mode) — deliberately unlike cases' load_encryptor, which swallows a malformed key under optional.

  • D7. CASES_SHA256 re-blessed once (the ssn_hmac column + the sentinel lineage row shifted the keyless cases bytes; the other five renders are byte-identical); proven by a round-trip proptest, a semantic encrypted-render test (decrypts every covered value from the real render), a plaintext-leak test, and a real-binary key/mode matrix.

Amendment (2026-07-13, C6 as-built, #986). The D6 verification harness landed (keyless devstack; the encryption-asserting cases light up at C7):

  • Expected-mode oracle, not auto-detect. cargo xtask verify-seed --expect {keyless,keyed} asserts the intended mode. The §D6 text below implies reading the stored state and checking it; the as-built makes the caller declare the mode, because an auto-detecting gate is circular — a silent C7 key-mount failure emits plaintext + the sentinel KCV, which an auto gate passes vacuously. C6 wires --expect keyless (validate devstack phase + the non-blocking pentest CI backstop); C7 flips both to --expect keyed.

  • Completion poll fixed (the C4d-deferred concern, realized). devstack::verify_seed now polls {{.State}}|{{.ExitCode}} through a checked compose capture + a pure classify_seed, replacing the {{.ExitCode}}-only read that misclassified a still-running one-shot (exit 0) as success and a dead daemon as Ok(""). Both callers (bring_up, auto_refresh) inherit it. Since #1420 an absent container (the exited one-shot pruned by routine docker container prune) is no longer a hard error: the poll falls back to the durable _seed_marker sql-phase row (§D2, "marker present ⟺ committed") and refuses only when container AND marker are both absent — a genuinely unseeded stack.

  • At-rest coverage via a throwaway DB, not a lift to craig-test-lib. The in-process keyed router runs each test against a per-test craig_cases_enctest_<uuid> database (created + migrated + dropped on Drop), returning its FieldEncryptor — isolating the random key from the shared DB (an isolation improvement over C2’s shared-DB + row-cleanup). Lifting the harness to craig-test-lib was rejected (it would drag five service crates into the shared test lib). An API-created-report test proves the 4 TEXT
    3 JSONB report columns encrypt at rest (the seed emits no reports); a decrypt-through-service round-trip checks seeded persons/referrals through the live API against the craig_seed::generate oracle.

  • Hardened key loader. xtask::field_key::load_field_key (used by the keyed branch; reused by provisioning) rejects symlink/non-regular/loose-perms, requires exactly one key assignment, and is TOCTOU-safe via a stat→open→fstat (dev, ino) inode re-check — the O_NOFOLLOW intent (D5) achieved without a libc/rustix dependency, and now on the read path C4d deferred.

  • from_env boundary + sentinel home. Two gaps (valid-base64-wrong-length, non-Unicode) added to C5’s real-binary key/mode matrix (absent/valid/malformed already covered). KCV_SENTINEL moved to its canonical home in craig-crypto (value unchanged ⇒ byte-identity stable).

  • Negative test → C7. The restart-under-wrong-key ⇒ refuse-boot negative test moved to #987 (its boot verifier must exist first); the #986 AC moved with it, so Closes #986 strands nothing.

Amendment (2026-07-13, C3 as-built, #1021). The C3 sub-epic (&67, ADR-049) landed; three references below are superseded as-built:

  • Field-helper anchors. api/encryption/field.rs (cited in §Context, and in §D6’s pass-through rationale) is deleted: the fail-closed read/write matrix now lives in the registry-driven craig_search::row walk, uniformly typed as SearchError (stored ciphertext with no key → CiphertextWithoutKey, still a hard 500), per the ADR-020 amendment of the same date. §D6’s point survives unchanged — the walk still passes legacy plaintext through on a keyed decrypt (pinned by a craig-search row test), so a decrypt-through GET remains non-proof and the exhaustive raw-DB assertions stay necessary.

  • §Covered columns is now registry-derived. The covered set is declared once in the craig-cases-fields registries and consumed via craig_search::encrypted_fields() by the service write/decrypt paths, the seeder, and verify-seed — the §Covered-columns prose below stays accurate as a snapshot, but the registries (not this prose) are normative. encrypt_field/ encrypt_jsonb_value are retired; JSONB fields keep the same {"v": "<ct>"} envelope, now emitted by the walk.

  • The SSN blind index is per-field domain-separated. ssn_hmac moved off the single global BLIND_INDEX_INFO_V1 domain onto hmac_domain("ssn", 1, …) (BLIND_INDEX_SCHEME_V1), derived through the one shared FieldSpec::blind_index at every site (service write, planner query, seeder, verify-seed). Pre-1.0 breaking for keyed data: a keyed deployment reseeds (devstack down -v); C7’s activation reseed subsumes this, and D3’s KCV domain (craig-crypto/kcv/v1) remains disjoint.

Amendment (2026-07-13, C7 as-built, #987). Activation landed; the devstack is keyed end-to-end. Three as-built refinements against the D3/D4 prose above:

  • The D3 boot matrix, realized in craig_cases::boot_verify with two refinements. (1) Scheme metadata (kcv_version/envelope_version/algo) is checked BEFORE the KCV, so a format/scheme migration refuses with its own error rather than masquerading as a wrong key (KCVs across schemes are incomparable). (2) The "no CGEF-bearing rows" test in the lineage-absent branch is a registry-driven scan (the same craig-cases-fields registries every boundary consults, all five entities): a SQL LIKE prefilter on the new craig_crypto::ENVELOPE_B64_PREFIX (the base64 encoding of the magic’s first three bytes — a necessary condition, pinned to ENVELOPE_MAGIC by test) bounds the fetch, and each candidate is confirmed with the exact structural FieldEncryptor::is_encrypted, so plaintext can never refuse boot. The verifier runs after migrations and before the authz engine/workers/listener. A sentinel lineage row under a keyed boot refuses (as the seeder’s canary-sentinel doc anticipated); a real-KCV row under a keyless boot refuses (hoisting ADR-020’s request-time 500s to a startup refusal).

  • The D3 host-KCV→wipe guard is a dedicated marker, not a file hash. .devstack/field-key.kcv records the provisioned key’s KCV (never raw key material; the file stays excluded from infra.sha256 per §D5/C4d); absence or mismatch escalates to VolumeAction::Wipe + reseed — the same mechanism a modified migration uses, riding auto_refresh’s bounced (wipe) path, which stays on the safe side of the #1005 marker-ordering bug. Absence-⇒-wipe is what makes the first post-C7 `dev start self-activating (§D4’s coordinated recreation with no manual step).

  • §D6 keyed flip + the CI half. Both verify-seed call sites now assert --expect keyed. The CI backstop (and every raw-compose CI devstack job) fail-closes on the missing env_file until the deferred C4-gate delivers the §D5 CI variable-first key — red-but-allow_failure, named here rather than hidden. The #986-deferred restart-under-wrong-key negative landed as the boot_verify integration matrix over the C6 throwaway-DB harness (key-A lineage + key-B encryptor ≡ the restart, which a fresh empty stack cannot exercise).

Amendment (2026-07-14, C4-gate as-built, #1012 + #984 close-out). The deferred declarative/CI half landed; the epic’s last child. As built:

  • Completion gate. craig-web gates on craig-seed: service_completed_successfully, and the seed gates on a 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 instance). Any docker compose up — dev or the raw CI jobs — now fails loud on a bad seed ("didn’t complete successfully"), and the seed’s attachment upload can never race an uninitialized bucket: proven live, the attachments marker lands on the FIRST fresh start (pre-gate it needed a second run). The duplicated bootstrap wrappers are retired: xtask’s init_garage (bring_up
    auto_refresh) and devstack/ci/garage-init.sh; devstack/ci/wait-healthy.sh is superseded by docker compose up -d --wait --wait-timeout 900 in the three raw-compose CI jobs (the success-direction --wait over completed one-shots verified live).

  • §D5 CI variable-first key. The three CI jobs materialize devstack/field-key.env from the masked + protected CRAIG_FIELD_ENCRYPTION_KEY project variable via devstack/ci/write-field-key.sh — unset ⇒ hard error naming the fix (never a keyless CI devstack). Protected means a manual perf-stress run on an unprotected ref hard-errors by design. The variable itself is maintainer-provisioned (openssl rand -base64 32; rotate freely — every CI devstack seeds from scratch).

  • Deliberate-break proof (AC1). A broken seed (non-numeric CRAIG_FAMILIES failing the seeder’s arg parse) makes a raw docker compose up -d --wait exit non-zero with craig-web never started — the gate, exercised for real.

Amendment (2026-08-10, ADR-064 U7 as-built, #1385 — the CI half switches to the encrypted store). §D5’s CI half no longer materializes devstack/field-key.env from a per-secret CI/CD variable: devstack/ci/write-field-key.sh now decrypts the repo-committed store (secrets/dev.yaml, ADR-064) with the CI age identity CRAIG_CI_AGE_KEY inside the digest-pinned devtools container (--network none, read-only, cap-drop, host-UID; the identity reaches sops only as a read-only file mount), validates the output (single line, base64 for exactly 32 bytes) BEFORE touching the target, and publishes atomically with the prior file retained on any failure — so a store rotation reaches CI on the next run with no variable edits. Pre-1.0 switchover with NO dual path: the script never reads the old CRAIG_FIELD_ENCRYPTION_KEY variable, which stays inert as a rollback window until gate G-C (#1031) deletes it. Test seams (CRAIG_SECRETS_FILE / CRAIG_SOPS_CONFIG / CRAIG_FIELD_KEY_OUT / CRAIG_DEVTOOLS_CONTEXT) isolate the in-battery docker proof from real files. Scheduled scan jobs keep their protected-ref posture structurally (schedules target maintainer-configured refs; a manual run on an unprotected branch hard-errors in the script by design, unchanged doctrine).

Amendment (2026-07-25, #1118 — the rules phase gets its own convergent identity). §D2’s single identity ("any fixture-affecting change flips it → reseed") was too coarse for one phase: rule sets are loaded via the craig-rules API into a DB the API can also update, so a ruleset edit never needed a volume wipe — yet the identity fold forced one, and the phase’s silent-stale failure modes (a version bump answered 409 "already exists"; the SQL fallback’s ON CONFLICT (name) DO NOTHING; the fallback masking a charset defect that 422’d every underscore-named authz ruleset, leaving the rules marker never-written on live devstacks) hid behind it. As built:

  • Identity split. --extra-fingerprint now folds only tests/fixtures/ + seed.sh; the rules phase carries its own RULES_FP (sha256 over rulesets/) in the craig_rules rules marker. A ruleset edit flips only the rules marker — the SQL-phase identity (and its wipe posture) is untouched, so a version bump lands on dev refresh/dev start with no data wipe.

  • Convergent, version-aware load. Per fixture file: GET by-name → absent ⇒ POST create; present at the fixture version ⇒ skip; any other version ⇒ PUT update (JDM recompiled, engine cache swapped, ruleset.changed notified — the SQL fallback did none of these; it is deleted, and craig-seed’s compose gate on a healthy craig-rules means an unreachable API is a real failure). A fixture-vs-DB (name, version) receipt runs after the load; the marker is written only on full convergence, and any failure is fatal (the C4 gate then refuses the stack) — an incomplete authz surface must never present as debuggable-only-via-403s.

  • Root cause. is_valid_rule_set_name (810) banned , rejecting the system’s own derived authz names ({jurisdiction}-authz-{resource_type}); the charset now admits interior (no AMQP topic semantics — ./*/ stay banned), so every ruleset loads through the validated API path.

Context

Field-level PII encryption (craig_crypto::FieldEncryptor, AES-256-GCM-SIV) is implemented only in craig-cases and is fail-closed per ADR-020: with a key loaded, decrypt_field reads mixed plaintext/ciphertext rows via the per-value CGEF magic header, and "ciphertext present, no key" is a hard 500 (services/craig-cases/src/api/encryption/field.rs:71-73; JSONB mirror report.rs:88-90). It has never run end-to-end: the devstack sets optional mode with no key (docker-compose.yml:186) and CI brings the stack up keyless, so the keyed path, the seed, key provisioning, and verification have never been exercised. Turning it on — which production’s required mode already implies — is not a one-line flip; it exposes a cluster of entangled infrastructure decisions that three independent design reviews showed had to be resolved together, because each answer constrains the others: postgres storage, seed idempotency, key↔data lineage, activation atomicity, secret delivery, and verification.

Each was researched independently (≥3 alternatives, current state-of-the-art, code-grounded) and the draft was hardened through two adversarial review rounds. The decisions below are mutually consistent and implementable on the current architecture; the "Interactions" notes make the couplings explicit.

Scope

In scope: the devstack/CI key lifecycle — the six decisions D1–D7 below.

Out of scope (owned by the deferred secrets-management program): production key delivery, key rotation (re-encrypting data to a new key), recovery beyond "supply the correct key", revocation, and destruction. Pre-1.0 production has no data, so none of these bite yet.

Shared-binary note (honest scoping). D3 changes the shared craig-cases binary (a migration + a boot check), not a devstack-only artifact. Its fail-closed boot is correct for production: an operator supplies the right key, or — if the key is genuinely lost — the data is unrecoverable by design, which is what encryption means. Only key rotation is deferred.

Threat model

This ADR assumes a passive database-disclosure threat model (at-rest confidentiality of PII). The cipher uses no storage-context AAD, so a valid ciphertext is replayable between rows/columns under the same key — an active-tamper / cell-swap threat that is out of scope here and recorded as a named follow-up (adding AAD is a ciphertext-format change for the threat-model/secrets program).

Covered columns

craig-cases encrypts: persons ssn_last_four (TEXT/CGEF) + ssn_hmac (a deterministic blind index from the plaintext SSN, non-CGEF); referrals reporter_first_name/reporter_last_name/reporter_phone (TEXT/CGEF, not reporter_relation); and on reports the TEXT fields reporter_* + narrative (via encrypt_field) plus the JSONB-envelope {"v":"<ct>"} fields children/adults/raw_submission (via encrypt_jsonb_value, report.rs:113-118). The seeder emits only persons.ssn_last_four and referrals.reporter_* — there is no reports table in tools/craig-seed — so report-level encryption is exercised by API-level integration tests, not the seed.

Decision

# Question Decision

D1

Postgres storage

A named volume postgres-data:/var/lib/postgresql (the path PG18 actually declares).

D2

Seed idempotency

Skip-if-seeded + full-volume wipe — a per-database _seed_marker written inside each per-DB transaction; a reseed is a full down -v clean slate. No in-place TRUNCATE.

D3

Key↔data lineage

The seeder writes an in-DB crypto_key_lineage row (KCV + encrypted canary) as a plain generated INSERT; craig-cases verifies it at boot (fail closed). A host-side key fingerprint drives an automatic full-volume reseed on key change.

D4

Activation atomicity

One shared key for seeder + cases, cases required; coordinated recreation; activation correctness proven by the D6 round-trip (not a phantom re-probe). Key goes live only at C7.

D5

Secret delivery

A gitignored devstack/field-key.env (xtask generate-once, hardened) mounted into only cases + seed; in CI a required masked variable the xtask step overwrites the file from. from_env retained; production secrets:/from_file deferred.

D6

Verification

A verify-seed round-trip + exhaustive raw-DB assertions in the pre-push battery (CRAIG’s functional gate) + a main/schedule backstop; a structural floor of ON_ERROR_STOP + a completion gate.

D7

Byte-identity guard

The reproducibility guard renders keyless (pins plaintext SQL); plus semantic encrypted-render tests that decrypt every generated field (ciphertext is unpinnable — random nonce).

D1 — Postgres storage: a named volume at the correct PG18 path

Today the postgres service (docker-compose.yml:54-66) declares no volumes: and rides the postgres:18-alpine image’s anonymous VOLUME. Adopt a named volume — but at the path PG18 actually uses: postgres:18-alpine declares VOLUME /var/lib/postgresql with PGDATA=/var/lib/postgresql/18/docker (verified via docker image inspect). Mounting the pre-18 path /var/lib/postgresql/data would leave the real cluster in the anonymous parent volume and the named one empty — so the mount is postgres-data:/var/lib/postgresql. Wipe semantics are unchanged (tear_down passes down -v, xtask/src/devstack.rs:418, which removes named and anonymous volumes alike), while the volume becomes discoverable (docker volume lscraig_postgres-data) and — decisive for D3 — a stable, addressable wipe target.

A container-recreation test guards the path: recreate postgres and assert SHOW data_directory = /var/lib/postgresql/18/docker and docker inspect shows postgres-data mounted at /var/lib/postgresql with no stray anonymous parent. Bind mount / tmpfs rejected as before (root-owned-file friction; a dev persistence regression). Pre-1.0 devstack break: existing developers run a one-time cargo xtask dev clean (documented in C8).

D2 — Seed idempotency: skip-if-seeded + full-volume wipe

The seeder emits plain INSERT for 32 of 34 tables (tools/craig-seed/src/sql_row.rs:41-43; only admin_unit_registry + detection_rules use ON CONFLICT, sql.rs:80-92). Because postgres data persists across a CachedRebuild up (xtask/src/devstack.rs:165-173 ups without down), the one-shot craig-seed container re-runs against a populated DB. Today a duplicate-key error aborts the wrapping BEGIN/COMMIT (sql.rs:74,94) and rolls back silentlyseed.sh runs psql without ON_ERROR_STOP (devstack/seed/seed.sh:116), so it exits 0. (D6’s floor turns that into a real failure; D2 makes the re-run correct.)

Decision: the seed runs only against a fresh/empty DB, and a reseed is a full-volume down -v clean slate — no in-place TRUNCATE, so the unbounded CASCADE closure (runtime-owned audit/outbox/attachment tables, plus Garage objects and API-loaded rules a DB transaction can’t reach) never arises.

  • A per-database _seed_marker(identity text, created_at) is written as the last statement inside each per-DB transaction (the generated .sql is already one BEGIN/COMMIT). So marker present ⟺ that DB fully committed — an interrupted psql -f rolls the whole DB back to empty and leaves no marker, so the next run re-seeds cleanly rather than colliding on a half-populated DB.

  • identity = a digest of {CRAIG_SEED, CRAIG_FAMILIES, active-bundle content+version, canonical generator/manifest digest, encryption-format version, key KCV} — broad enough that any fixture-affecting change flips it.

  • seed.sh per DB: CREATE TABLE IF NOT EXISTS _seed_markerSELECT identitymatch → skip (exit 0; no collision on a non-wipe up); absent → seed (the DB is empty — a reseed always arrives via down -v); present + mismatch on a populated DBfail loudly, non-destructively ("run `cargo xtask dev reseed`"). Never wipe in-place.

  • A reseed’s down -v also clears garage-data, so the non-SQL side effects (rules loaded via the Rules API, attachments) are clean-slate too — no stale rules, duplicate attachment blobs, or orphaned objects.

  • This separates the D2/D6 contracts: a fresh/reseed/CI run is always the exact deterministic fixture (D6 verifies it); the "preserve developer mutations" case is simply "an unchanged up skips", and D6 verification runs only after a fresh seed, never after a skip.

Blanket ON CONFLICT is rejected (a hand-maintained per-column list across 34 tables that would keep stale ciphertext after a key change); in-place TRUNCATE is rejected for the destructive-closure reasons above.

D3 — Key↔data lineage: the seeder writes it, craig-cases verifies it

AES-256-GCM-SIV authenticates on decrypt: ciphertext written under key A fails under key B. Nothing today binds the active key to the data. Add a singleton table crypto_key_lineage(id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id), kcv TEXT, canary TEXT, envelope_version INT, algo TEXT, created_at TIMESTAMPTZ) in craig_cases (migration), and a FieldEncryptor::kcv() = base64( HKDF-SHA256(ikm = key, info = "craig-crypto/kcv/v1")[..8] ) — a purpose-separated, key-derived check value (the crate already uses HKDF domain separation for the SSN blind index, crates/craig-crypto/src/lib.rs:341-357; the distinct info keeps a KCV from ever coinciding with a blind-index value). The canary is a fixed known plaintext encrypted under the key. Both are one-way / decrypt-only and safe to expose (Kerckhoffs); the KCV is host-readable (recomputable in Rust from the key without running the AES cipher).

  • Writer = the seeder, as a plain generated INSERT. The seeder links craig-crypto (for the encryption pass), computes the KCV and encrypts the canary in Rust at generation time, and emits the INSERT INTO crypto_key_lineage … — atomic with the ciphertext in the same per-DB BEGIN/COMMIT. It needs no live DB connection and no in-SQL AES (which pgcrypto cannot do): on a down -v reseed the table is empty, so the INSERT simply succeeds. (This is why the writer is the seeder, not a transactional read-verify: the file-emitting, DB-less seeder structurally cannot run SELECT … FOR UPDATE + a canary decrypt.)

  • Verifier = craig-cases (it has the key, craig-crypto, and a live pool). Boot is verify-only: lineage row present → recompute the KCV and decrypt the canary, mismatch ⇒ refuse boot (whether or not ciphertext exists yet); lineage absent + no CGEF-bearing rows ⇒ boot; ciphertext present + lineage absent ⇒ refuse boot (unverifiable ⇒ fail closed). The production first-write "establish-or-verify lineage" on cases' write path (a SELECT … FOR UPDATE cases can run) is a prod-scoped concern and is deferred; in devstack the seeder pre-establishes the row and boot-verify confirms it, so cases writes proceed.

  • Dev auto-reseed uses the path the launcher honors. The host key-KCV fingerprint feeds the guard’s VolumeAction::Wipe (devstack_guard.rs:445) — the same mechanism a migration change uses, which decide_action maps to the StartAction::VolumeWipe executor (devstack.rs:149) for the primary dev start path — not SeedAction (which decide_action never reads, so it would only fire on dev refresh). So a changed devstack/field-key.env forces a down -v reseed before cases could hit refuse-to-boot. (The ordering bug where markers were written before the reseed was closed by #1005 — markers now bless only after every convergence step succeeds.) The seed.sh identity-mismatch fail is the defense-in-depth backstop for a raw docker compose up that bypasses xtask.

A per-row key_id (a registry for a single-key model) and envelope encryption / wrapped-DEK (the named endgame, whose rewrap-not-re-encrypt payoff only cashes out with the deferred KMS program) are both deferred; the lineage table is shaped so a wrapped_dek column is a purely additive later move.

D4 — Activation: coordinated recreation, correctness via the round-trip

env_file injects values at container-create, so the processes do not continuously share one file — the guarantee is a protocol, not a static claim.

  • Coordinated recreation: activation (C7) mounts the shared key into cases + seed and runs one up, then a full-volume reseed. Recreating a single container out-of-band is unsupported and is caught by cases boot-verify + the seed.sh identity-mismatch fail.

  • Activation correctness = the D6 round-trip. On a fresh reseed cases boots first against an empty DB (compose: craig-seed depends_on craig-cases service_healthy, docker-compose.yml:887-905), so boot-verify is trivial there — cases is simply up with the shared key and reads the seeded data correctly, and the D6 round-trip is the activation-time proof. Boot-verify is the guard for a later restart against persisted data. (There is no post-one-shot compose/xtask re-probe primitive, so the ADR does not claim one.)

  • The enforced invariant is K_seed = K_cases = K_lineage (cases boot-verify refuses on mismatch; the D6 round-trip proves it at activation), not merely "a key is present."

  • C5 delivers the seeder’s encryption capability (an explicit mode + key input); in devstack it stays keyless/optional → plaintext until C7 mounts the shared key and sets seeder + cases to required. "Required-mode" is the C7 configuration, not a C5 property. No mega-MR; the fail-open dual-read shim stays rejected (contradicts ADR-020 and the security baseline).

D5 — Secret delivery: CI-variable-first, hardened local file

Deliver the key to craig-cases (runtime) and the seeder (seed time) via a gitignored devstack/field-key.env, env_file: into only those two services (the idiom ~13 services already use; zero new crypto API).

  • CI: the key is a required masked + protected GitLab CI/CD variable, which the xtask step overwrites the file from every run (variable-first, never file-first). An unset/empty variable in a CI environment is a hard error — the generate_key() fallback is local-dev only, so a broken CI provisioning path fails loudly instead of self-consistently seeding + verifying under an ephemeral key.

  • Local dev: generate-once-if-absent (precedent: write_ports_env / .ports.env), written atomically (temp + rename), mode 0600, O_NOFOLLOW; excluded from CI artifacts/caches.

  • A missing field-key.env makes docker compose up error on the env_file: mount (fail-closed), and xtask generates it before up. Compose ${VAR:-} interpolation is rejected (an unset var → empty → silent plaintext). #1003’s `.dockerignore devstack/.env rule already keeps it out of the build context; gitignoring it keeps the PII key out of history (unlike the deliberately-committed *test-only devstack-actor-keys.env).

The textbook production answer — compose secrets: (file at /run/secrets) + a FieldEncryptor::from_file loader — is out of scope (devstack/CI run synthetic data in throwaway stacks); it is the named production target for the secrets-management program. from_env stays for &66.

D6 — Verification: pre-push gate + main backstop

CRAIG gates functional correctness at pre-push (CI runs security/supply-chain/release only, .gitlab-ci.yml:170-173); the existing devstack-booting CI jobs (pentest/perf) are allow_failure, non-blocking, and not per-MR (:503/:563/:609; main/schedule/manual). So the verifier runs in the pre-push battery (which already boots the devstack) as the enforced gate, plus a non-blocking backstop on those main/schedule jobs. A blocking per-MR devstack-in-CI job is a macro CI-policy change (craig-seed pulls up a nine-service graph with no per-MR images) and is out of scope.

Structural floor: ON_ERROR_STOP=1 on the seed.sh psql -f load (hardens the SQL + lineage path) and a service_completed_successfully completion gate on craig-seed to a real downstream. Explicitly, the floor covers the SQL/lineage path — not the attachment / rules-API branches, which are non-fatal by construction (seed.sh || true + WARN, :161-179); that seed-robustness gap is a separate follow-up.

Exhaustive assertions — a decrypt-through GET is not proof, because field.rs:66 ((Some(_), false, _) ⇒ Ok(value)) passes plaintext through when an encryptor is present:

  • raw-DB: every seeded encrypted column on all non-null rows carries a CGEF envelope (persons.ssn_last_four; referrals.reporter_first/last/phone); ssn_hmac non-null and equal to HMAC(a known seeded SSN).

  • round-trip: decrypt-through-service of a deterministic seeded ID equals the manifest plaintext, per covered column.

  • API-created report: POST a report and assert both TEXT (narrative, reporter_*) and JSONB (children/adults/raw_submission) are CGEF at rest and decrypt back (covers the report path the seed omits).

  • negative: restart cases under a wrong key against persisted key-A data ⇒ refuse boot (a restart-based test — a fresh stack boots empty, so the negative must exercise a restart).

A full Playwright e2e as the primary gate is rejected (against CI policy and heavier than the signal requires); a UI assertion on decrypted PII stays part of the existing pre-push Playwright battery.

D7 — Byte-identity guard: keyless render + semantic encrypted-render tests

The reproducibility guard SHA-256-pins the emitted SQL bytes for cases/placement/exchange/financial (tools/craig-seed/tests/sql_byte_identity.rs). Once C5 encrypts, the cases SQL carries random-nonce ciphertext (crates/craig-crypto/src/lib.rs:257-259) and is no longer byte-stable. The guard therefore renders keyless (None encryptor), pinning the plaintext SQL; the _seed_marker and lineage `INSERT`s render with a KCV-absent identity sentinel so the pinned bytes stay deterministic (production renders the real KCV — unpinned, like ciphertext, consistent with this decision). This keeps the guard as the generation-determinism check while acknowledging ciphertext is unpinnable-by-design.

Its correctness is proven separately by semantic encrypted-render tests: render with a key and decrypt every generated encrypted field, asserting the round-trip — this exercises the actual encrypted code path the keyless guard never touches. (No TRUNCATE preamble under D2, so no D2-forced re-bless.) The alternative — hashing the pre-encryption SeedData model — is cleaner in principle but a larger refactor with no additional signal for &66.

Consequences

  • New shared-crate + schema surface: FieldEncryptor::kcv() + a fixed canary constant in craig-crypto (C4); a crypto_key_lineage migration (C4); the seeder-emitted lineage INSERT (C5); the craig-cases boot verifier (C7); a verify-seed xtask subcommand (skeleton C4, exhaustive assertions C6/C7). No change to ADR-020’s runtime field-helper semantics.

  • Encryption-covered seed surface is persons.ssn_last_four (+ a new ssn_hmac) and referrals.reporter_first/last/phone only — the seeder has no reports table, so report TEXT/JSONB encryption is exercised by API-created-report tests (D6), not the seed.

  • Child re-decomposition (the epic plan carries the authoritative table; summary):

Child Scope Deps

C4 (#984)

D1 named volume (correct path) + recreation test; D2 per-DB in-transaction _seed_marker + the ON_ERROR_STOP floor + completion gate (together, so main never breaks); the crypto_key_lineage migration + FieldEncryptor::kcv() + canary; D5 provisioning tooling (hardened) — no live key mount; D3 host-KCV→VolumeAction::Wipe reseed; confirm the completion-gate downstream + Garage-init ordering; verify-seed skeleton. (The auto_refresh marker-ordering fix is #1005, a separate follow-up — see below.)

C5 (#985)

The encryption-capable seeder (keyless→plaintext until C7); the plain generated lineage INSERT (Rust-computed KCV + canary, atomic per-DB txn); persons.ssn_hmac; D7 keyless guard + semantic encrypted-render tests.

C4

C2 (#982)

Report-person read consumers decrypt after authz — report_persons.rs:106-187 (get_person_suggestions) + :224-279 (validate_link_request).

— (precede C7)

C3 (#983)

Report search under encryption — its own ADR-first product decision, independent of this ADR.

— (precede C7)

C6 (#986)

The verify-seed round-trip + exhaustive raw-DB / API-report / negative assertions in the pre-push battery (+ main backstop); the encryption-asserting cases light up with C7.

C2, C5

C7 (#987)

D4 activation: mount the shared key into cases+seed (coordinated recreate), set both required, full-volume reseed; the craig-cases boot verify-only (KCV + canary, refuse on mismatch).

C2, C3, C4, C5, C6

C8 (#988)

ADR-020 cross-reference to this ADR; CHANGELOG; config-reference; local-dev (the D1 one-time dev clean); .claude/CLAUDE.md status.

with C7

  • C4/C5 ordering. The ON_ERROR_STOP floor — which turns today’s silent duplicate-key rollback into a hard failure — ships in the same child (C4) as the skip-if-seeded marker that prevents the collision, so no C4→C5 window breaks main.

  • Pre-1.0 devstack break: first pull after D1 needs a one-time cargo xtask dev clean (documented in C8).

  • Deferred, named-not-hidden: envelope-encryption / wrapped-DEK rotation, the compose-secrets: production loader, and the full production key lifecycle all move to the secrets-management program; the lineage table and delivery path are shaped so those are additive, not rewrites.

  • Follow-ups filed (not epic children): the marker-before-reseed ordering bug (devstack_guard.rs:700:713); the CRAIG_FAMILIES divergence (compose default 12, docker-compose.yml:883; seed.sh default 9, :16; craig-seed default 9, main.rs:75; the families9 byte-identity fixture); the no-AAD cell-swap limitation (threat-model program); and the seed’s non-fatal attachment/rules-API branches (a seed-robustness gap D6’s floor does not cover).

Alternatives rejected

  • Anonymous / bind / tmpfs postgres storage, or the wrong PG18 mount path (D1).

  • Blanket ON CONFLICT or in-place TRUNCATE … CASCADE (D2) — an unmaintainable per-column list that keeps stale ciphertext, or an unbounded destructive closure that also can’t reach Garage/API state.

  • A transactional claim-or-verify in the seeder (D3) — structurally impossible on the DB-less, file-emitting seeder; the verify role belongs on craig-cases.

  • Cases TOFU-writing the lineage row at boot (D3) — would attest cases' own key over data possibly encrypted under another; the seeder (the ciphertext writer) owns the write.

  • A phantom "post-seed cases re-probe" (D4) — no such compose/xtask primitive exists; the round-trip is the activation proof.

  • A blocking per-MR devstack-in-CI gate (D6) — reverses CRAIG’s local-only integration policy and multiplies CI cost (nine-service graph, no per-MR images).

  • Mega-MR / mode-flag dual-read (D4) — merge order is not the atomicity boundary; dual-read is fail-open.

  • Hashing the SeedData model now (D7) — larger refactor, no extra signal for &66.

Open questions

These do not block acceptance; they are resolved in the relevant child’s just-in-time plan.

  1. (C4) Confirm the completion-gate downstream service and the Garage-init ordering so craig-seed cannot report success before the object-store steps could run.

  2. (C4/C7) Pin the exact KCV parameters in code: HKDF info = "craig-crypto/kcv/v1", an 8-byte (64-bit) truncation.

  3. (deferred/prod) Introducing lineage onto an already-encrypted production database (ciphertext present, lineage absent) needs a one-time bootstrap, since D3’s boot matrix refuses that state. Greenfield pre-1.0 prod starts empty, so &66 does not hit it; the secrets-management program owns it.

References

  • ADR-020 — fail-closed field-helper semantics + the CGEF ciphertext envelope (the primitive this ADR builds the lifecycle around).

  • Epic &66 plan — the child decomposition this ADR re-scopes.

  • #1003 — the .dockerignore build-context secret-leak fix that anticipates devstack/field-key.env.

  • #982–#988 (C2–C8) — the epic children re-decomposed in Consequences.

Edit this page · latest