Plan: Per-Jurisdiction Search-Scheme Override
On this page
Status
| Unit | Description | Status |
|---|---|---|
U0 |
Plan + epic &80 + child issues + follow-ons (#1399, #1400) |
Done (2026-08-11) — epic &80 + this plan page |
U1 |
|
Done (2026-08-11) — this MR |
U2 |
|
Done (2026-08-11) — this MR (as-built deltas: |
U3a |
craig-cases as the 8th bundle consumer — features, matrix, compose, Dockerfile (#1394) |
Done (2026-08-11) — this MR (as-built deltas: the boot call lands in |
U3b |
|
Done (2026-08-11) — this MR (as-built deltas: the gate hook rides a new generic
|
U4a |
Planner runtime effective-scheme path — presence-gated, fail-closed (#1396) |
Done (2026-08-11) — this MR (as-built deltas: the effective-spec LOOKUP is
presence-INdependent — an incomplete registry fails every plan over the entity, only the
capability re-check is presence-gated; |
U4b |
Resolved registry threaded through every boundary (#1397) |
Done (2026-08-11) — this MR (as-built deltas: the leak-once interning is single-sourced in
the new |
U5 |
Activation — tx-stub override, lifecycle fingerprints, e2e proof, ADR-049 amendment (#1398) |
Done (2026-08-11) — this MR (as-built deltas: the integration proof runs through the
PRODUCTION resolver + materializer against the keyed harness (tx-stub is not compiled into
devstack images — |
Epic: &80
Issues: #1025 (anchor; closed by U5), #1392 #1393 #1394 #1395 #1396 #1397 #1398 (children),
#1399 (U6 follow-on: mid-life migration tooling), #1400 (capabilities ∩ descriptor ops)
Branches: feature/1025-u<N>-*
|
Plan v2 was externally reviewed 2026-08-11 (~40 findings, blocking) and this v3 folds ALL of
them; the reviewer’s "sound direction" is adopted verbatim. Every |
Context
ADR-049 (capability-based encrypted search) made the craig-cases field registry config-ready for
per-jurisdiction encryption schemes, but the override wiring was deferred out of C3 v1 (user
scope decision 2026-07-13). Today the craig-cases-fields registries are compile-time constants:
every deployment encrypts the same fields the same way. Multi-jurisdiction deployments (ADR-032)
need per-deployment field encryption — the reference case: a Texas deployment stores
persons.ssn_last_four as Opaque (unsearchable ciphertext) where Georgia’s default is
BlindIndex (equality-searchable via the HMAC sibling).
The invariant that shapes everything (ADR-049 §Blind-index domain separation): a field’s at-rest
scheme is a property of the stored data. The blind-index HKDF domain is baked into the derived
subkey, so a scheme change is a migration + reseed — never a runtime toggle. An
Opaque→Plaintext flip would reinterpret ciphertext as searchable plaintext (a PII leak). This
plan therefore wires the override fail-closed: fresh deployments work end-to-end; any scheme
change over existing data is refused pre-serving until migration tooling (#1399) exists.
What source verification proved (and the rejected v2 got wrong):
-
Eligibility is an allowlist of ONE. Only
persons.ssn_last_fouris crypto-mediated end-to-end with a safe alternative scheme:BlindIndex ⇄ Opaque{Text}— the at-rest bytes are identical under both, only thessn_hmacsibling toggles, and the sole affected index is partial (idx_persons_ssn_hmac, unused under Opaque). Everything else fails the gate: cases
investigations have zero crypto accessors (api/encryption/rows.rs); the Opaque reporter/narrative/JSONB fields have no safe target (→BlindIndexneeds a sibling_hmaccolumn no migration created;→Plaintextexposes PII); fullssnis load-bearing (dedup/attestation/federal export); the rest carry CHECK / UNIQUE / trigram / scope / lifecycle SQL that assumes plaintext.→Plaintextis ineligible for every allowlisted field. -
The stamping actor is the ADR-063 migrate gate, not the seeder — production has no fixture seeder, and the devstack seed gates on healthy craig-cases (
docker-compose.yml:1246), so seed-time stamping would deadlock fresh boot. -
SearchRuntime::newhas 37 call sites — 5 production (one per entity list handler) + 32 test. -
Keyless +
Optionalstores plaintext under encrypted labels (craig-search/src/row.rs:251-252) — overrides must requireEncryptionMode::Required. -
Neither devstack staleness (
xtask/src/devstack_guard.rs:351-377) nor the_seed_markeridentity (tools/craig-seed/src/seed_marker.rs:74-99) binds the active bundle or a registry fingerprint today — a host-only override flip would silently skip recreate + reseed.
Scope
In scope:
-
The override contract (
StateBundle::search_schemes(), defaulted), the shared-leaf resolver
allowlist, thecrypto_field_lineagemarker + gate reconcile + serving verify, the planner runtime path, full boundary threading, deployment wiring (features/matrix/compose/Dockerfile), lifecycle fingerprint binding, and atomic activation with the tx-stub reference override.
Out of scope (filed as issues, not deferred silently):
-
Mid-life scheme migration over existing data — #1399. Until it lands, the gate refuses the change with the remedy named.
-
Capabilities ∩ declared-descriptor-operations intersection — #1400 (pre-existing over-advertisement, orthogonal to overrides which only narrow).
-
Widening any field’s search capability (e.g.
Opaque→Plaintext): permanently a code change
migration, never a bundle override.
Design
Decisions
| Fork | Decision |
|---|---|
Eligibility |
Hard-coded allowlist: v1 = |
Surface |
DEFAULTED |
Resolver home |
Shared leaf |
Marker |
|
Stamping |
The ADR-063 migrate gate (owner role, pre-serving, parallelism-1) reconciles the marker
insert/equal-only. Serving boot verifies read-only, presence required (the gate is mandatory
per ADR-063 — no absent-arm at serving). Runtime app role: SELECT-only via explicit REVOKE (the
|
Lookup |
Entity-keyed |
Runtime check |
Fires only on a PRESENT filter value (the planner visits every declared predicate; presence
lives in the bind’s inner |
Sorts / summary / scope |
Static invariant tests pin: no allowlisted field appears in any |
Encryption mode |
Overrides declared + |
Activation |
Atomic: the production allowlist constant stays EMPTY until U5; tests exercise the machinery via parameterized allowlists. tx-stub declares its override only in U5 with the last boundary. GA acceptance = behavioral/wire compatibility. |
Bundle name / env |
|
Single-writer, many-readers
The migrate gate is the only writer of scheme truth; everyone else reads the marker or the compiled defaults:
+-- compiled defaults (craig-cases-fields consts)
resolve() ----------+
(gate + serving) +-- bundle.search_schemes() -> leaf SchemeOverride (allowlist-checked)
|
gate: reconcile crypto_field_lineage (insert/equal-only; refuse on conflict)
|
serving boot: verify marker == resolved (read-only; presence required) -> materialize &'static
|
&'static ResolvedRegistry --> Extension (5 HTTP list handlers, wrappers, capabilities endpoint)
--> inbox Deps (report_converted -> matching -> ssn_promotion)
--> boot_verify lineage-absent scan (compiled DEFAULT_REGISTRY --
pre-resolution arm; both schemes share the at-rest envelope)
xtask verify-seed --> reconstruct effective registry FROM marker rows + defaults
(shared leaf codec -- no second resolution to drift)
craig-seed --> bundle-side resolve() (same pure leaf; the seed is a DB-less
SQL generator, so it CANNOT read markers -- divergence from the
gate-stamped truth is caught by the serving verify + verify-seed)
Gate reconcile (5 cases, insert/equal-only)
-
marker present, == resolved → no-op.
-
marker present, != resolved → refuse (scheme change over existing data; remedy: reseed now, #1399 tooling later). Never overwrite.
-
absent + covered columns provably clean (bounded scan, mirrors
services/craig-cases/src/boot_verify.rs:229) → INSERT resolved. (Fresh deploy — devstack and production.) -
absent + data present + resolved == compiled default → INSERT default. Sound, not grace: pre-marker binaries had no override mechanism, so pre-marker data is provably default-written.
-
absent + data present + resolved != default → refuse (unattested override over live data).
Planner path (craig-search; the leaf stays free of state-bundle/cases-fields)
SearchRuntime gains Option<EffectiveRegistry> (&'static [&'static FieldSpec] + table
binding asserted against SearchEntity::table()). In emit_equality / emit_substring /
emit_blind_equality (crates/craig-search/src/plan.rs:212-293): when the bind’s inner Option
is Some, resolve the effective spec (registry present + miss ⇒ invariant error), re-check
capability(effective.scheme, logical_type) ⇒ SearchError::UnsupportedFilter → 400 no-oracle
(error.rs:23-29; craig-common/src/error.rs:691). Absent values keep today’s NULL-guard no-op
and stable bind counts. blind_index derives off the effective spec. The capabilities endpoint
(api/search_capabilities.rs:23) computes from the resolved registry — an Opaque’d
ssn_last_four honestly reports equality: false; since v1 transitions only narrow, nothing new
is over-advertised (the pre-existing capability-vs-descriptor gap is #1400).
Steps
Every unit: own MR on feature/1025-u<N>-*, full battery, Status row update here, CHANGELOG
entry when operator-visible. Every unit ≤ ~500 LOC (splits pre-designed). GA behavior/
wire-compatible at every stage (empty production allowlist until U5).
U1 — Contract (#1392, w1)
Files: crates/craig-state-bundle/src/search_scheme.rs (new), src/lib.rs, src/bundle.rs.
SchemeName {Plaintext, Opaque, BlindIndex} + SearchSchemeSpec { entity, column, scheme }
('static plain data, the UasCodeSpec pattern); defaulted
fn search_schemes(&self) → Vec<SearchSchemeSpec> { Vec::new() } after uas_codes()
(bundle.rs:52). GA/tx-stub inherit the empty default unchanged. Pure additive.
U2 — Leaf resolver (#1393, w3)
Files: crates/craig-cases-fields/src/resolve.rs (new) + lib.rs.
Neutral SchemeOverride; ALLOWLIST const (empty in production until U5); pure owned
resolve(defaults, overrides, allowlist) → Result<ResolvedRegistryData, ResolveError> with
errors NotAllowlisted / UnknownEntity / UnknownColumn / UnsupportedTransition /
DuplicateOverride / RegistryInvalid (post-resolution craig_search::validate_registry — a
resolution can never yield an invalid registry silently). The canonical-spec codec (complete
scheme serialization, shared by the marker writer and every reader) + the marker-row →
effective-registry reconstruction helper. Proptests: resolution total/exact; codec round-trip;
overrides-empty ⇒ resolved == compiled defaults.
U3a — Consumer wiring (#1394, w2)
Files: services/craig-cases/Cargo.toml, src/bundle.rs (new),
xtask/src/cmd/feature_matrix.rs:12-42, docker-compose.yml:370-394, Dockerfile:41-73.
Cases becomes the 8th bundle consumer: state-ga/state-tx-stub features + optional deps
(financial template Cargo.toml:15-17); candidate_bundles() wrapper + compile_error! guard
(financial/src/subsidy/bundle.rs:38-47); fail-closed jurisdiction↔bundle check (mirrors
:19-28). FEATURE_MATRIX_CRATES 7→8 + the "SEVEN"/"7-crate" prose (the fingerprint test
feature_matrix.rs:182-215 forces this). Compose: cases gets *active_state_bundles.
Dockerfile: CRAIG_CASES_FEATURES build arg wired into BOTH cook and build (the
CRAIG_WEB_FEATURES cache-key precedent).
U3b — Marker + gate + boot (#1395, w3)
Files: services/craig-cases/migrations/<ts>_crypto_field_lineage.sql (new),
src/boot_verify.rs, the cases migrate-gate path, src/lib.rs:133.
Migration: the marker table + explicit REVOKE INSERT/UPDATE/DELETE FROM craig_cases_app
(runtime SELECT-only; the gate runs as owner; the devstack seed principal is the craig
superuser and unaffected). Gate: the 5-case reconcile (§Design) appended to cases' ADR-063
migrate path, plus the EncryptionMode::Required requirement when overrides are declared.
Serving boot: verify_field_lineage — read-only, presence required, exact-set — after
verify_key_lineage. Tests: all 5 gate arms; exact-set violations; mode refusal; the case-4
upgrade backfill.
U4a — Planner (#1396, w3)
Files: crates/craig-search/src/plan.rs, src/runtime.rs, tests/planner.rs (14 call sites).
EffectiveRegistry + the SearchRuntime field + new() signature change (pre-1.0, no shim);
presence-gated capability re-check + no-fallback lookup in emit_*; Concat/AnyOf atomic.
Tests: present-value → UnsupportedFilter; absent value → NULL-guard no-op with stable binds;
registry-miss / wrong-entity slice → invariant error; None-registry path byte-identical.
U4b — Service threading (#1397, w3)
Files: services/craig-cases/src/lib.rs (materializer + Extension + Deps), the 5 handlers
(api/persons.rs:109, api/referrals.rs:211, api/cases/crud.rs:344,
api/reports/crud.rs:258, api/investigations/crud.rs:213 — consolidating the 5× inlined
mode-match), 18 service test call sites, api/encryption/{person,referral,report}.rs,
boot_verify.rs scan, api/search_capabilities.rs, tools/craig-seed, xtask verify-seed.
Boot materializer interns resolved → &'static; Extension AND inbox Deps (the non-HTTP leg:
report_converted → matching → ssn_promotion); every encrypt/decrypt/scan/capabilities
boundary repointed at resolved. verify-seed reconstructs from marker rows via the U2 leaf
helper; craig-seed resolves bundle-side through the same pure resolve() — it is a DB-less
SQL generator (verified as-built) and cannot read markers; divergence from the gate-stamped
truth is caught by the serving verify + verify-seed. Static invariant tests: allowlist ∩
SORT_OPTIONS / default sorts / SUMMARY_COLUMNS / scope columns = ∅, plus every
equality/substring predicate field ⊆ its registry slice. HTTP 400 integration test.
U5 — Activation (#1398, w3; Closes #1025)
Added by the U4b review (A6): with the allowlist non-empty, add the executable divergence
test the recorded seed deviation promises — a seed generated under a bundle whose schemes
disagree with the gate-stamped markers must FAIL verify-seed (and the serving verify), so
"divergence is caught downstream" stops being prose.
Files: crates/craig-cases-fields/src/resolve.rs (allowlist),
crates/craig-state-tx-stub/src/lib.rs, tools/craig-seed/src/seed_marker.rs:74-99,
xtask/src/devstack_guard.rs, ADR-049, CHANGELOG.adoc.
The allowlist gains persons.ssn_last_four → {BlindIndex, Opaque}; tx-stub declares the Opaque
override; the registry fingerprint enters _seed_marker compute_identity AND a devstack_guard
staleness input binds CRAIG__ACTIVE_STATE_BUNDLES (the field-key KCV-marker pattern) so a
host-only override flip is never AlreadyCurrent. Integration proof on the test-plane DB: write a
person under the tx-stub-resolved registry → raw SELECT asserts envelope ciphertext
ssn_hmac IS NULL; the ssn filter → UnsupportedFilter/400; gate-refusal arms (2/5).
ADR-049 amended as-built (Consequences bullet + == Amendment — #1025). CHANGELOG; plan Status
→ Done; archive per lifecycle.
Verification
| # | Scenario | Level | Expected |
|---|---|---|---|
1 |
Gate: all 5 reconcile arms (fresh-clean insert; provably-default backfill; override-over-data refuse) |
gate unit/integration |
per-arm; refusals name the remedy |
2 |
Marker mismatch; extra/missing/malformed rows; mode≠Required with overrides |
unit |
refuse, typed errors |
3 |
Serving boot with marker absent |
unit |
refuse ("run the migrate gate") |
4 |
Present ssn filter under Opaque override |
planner unit + HTTP |
|
5 |
Absent filters (equality/substring/Concat/AnyOf) under override |
planner unit |
NULL-guard no-ops, stable bind count |
6 |
Registry present + descriptor field missing / wrong-entity slice (colliding columns) |
planner unit |
invariant error, never silent fallback |
7 |
Sorts / summary / scope untouched |
static tests |
allowlist ∩ each = ∅ |
8 |
|
leaf proptests |
typed errors; never an invalid registry |
9 |
Fresh tx-stub deploy end-to-end |
integration (test-plane DB) |
gate stamps → boot verifies → raw DB shows envelope + |
10 |
Existing GA DB upgrade (data, no marker) |
gate integration |
case-4 backfill, serves — behavior-preserving |
11 |
GA default path, every unit |
regression |
resolved == consts; wire behavior unchanged; full battery green |
12 |
Capabilities under override |
integration |
|
13 |
Devstack host-only override flip |
xtask test |
staleness + seed identity flip → reseed, never AlreadyCurrent |
14 |
Runtime role cannot write the marker |
integration |
INSERT/UPDATE/DELETE as |
References
-
ADR-049 (capability-based encrypted search; §Blind-index domain separation; amended at U5)
-
ADR-063 (migration gate — the marker’s pre-serving writer), ADR-048 §D3 (
boot_verifymodel) -
ADR-032 (state bundles), the
subsidy_policy()/uas_codes()DEFAULTED-method precedent (#1072) -
plans/archive/c3-encrypted-search.adoc§Scope (Out) — the original deferral -
#1025 (anchor) · epic &80 · #1399 (mid-life migration tooling) · #1400 (capabilities ∩ descriptors)