Plan: Multi-Jurisdictional Authorization

On this page
Contents

Status

Step Description Status

1

Plan filing + 5 ADR drafts (ADR-023 multi-jurisdictional authz; ADR-024 zen-engine policy engine + RMQ cache invalidation; ADR-025 identity normalization preferred_username → sub UUID; ADR-026 IdP-neutral identity layer; ADR-027 architectural principles — pluggability, async-first, identity-neutral, configuration-as-data) + GitLab epic + step issues. nav.adoc Active entry. CHANGELOG entry. No code changes.

Done (pre-ADR-030)

2

ADR-025 acceptance + identity normalization. Destructive ALTER on cases.{cases.assigned_worker, cases.supervisor, investigations.assigned_worker}: DROP TEXT columns; ADD UUID columns. Handlers updated to write claims.sub directly. Devstack data regenerated via reseed. No backfill (pre-1.0).

Done (pre-ADR-030)

3

Worker identities table — worker_identities in craig-security; auth middleware lazy-upsert from JWT claims; GET /v1/security/workers?search= for BFF reassignment autocomplete. IdP-neutral; no admin API call.

Done (pre-ADR-030)

4

ADR-023 + ADR-024 + ADR-026 + ADR-027 acceptance + new crates/craig-authz crate. AuthzEngine trait wrapping zen-engine; ResourceRef/Action/ResourceType/ListScope types; in-memory cache; fail-closed. Unit tests cover every grammar shape and scope outcome (test plan L1).

Done (pre-ADR-030)

5

RMQ-driven cache invalidation. craig-rules publishes ruleset.changed on outbox when admin endpoints mutate. New craig.cache_invalidations topic exchange; consuming services bind exclusive queues via subscribe_exclusive. TTL fallback (1h default with jitter, env-overridable). Bulk-fetch endpoint GET /v1/rules/sets?prefix= in craig-rules. Fail-closed bail on boot if bulk-fetch fails.

Done (pre-ADR-030)

6

Default authz rulesets — JDM JSON files in rulesets/{georgia,texas}/{j}-authz-{resource}.json, seeded via existing ruleset-seed mechanism. Georgia: realm-flat supervisor, readonly = auditor. Texas: deliberately divergent (case-scoped supervisor, readonly = PTO-caseworker, custom regional_supervisor role with unit-scoped scope). Curated examples; replace before production deployment.

Done (pre-ADR-030)

7

Cross-service denormalization. ALTER ADD assigned_worker_sub UUID on 11 tables across placement/financial/exchange that reference cases.id. xtask backfill from cases. cases service publishes case.assignment_changed event; consuming services have inbox handler that updates rows.

Done (pre-ADR-030)

8

Cases service adoption: wire engine into ~57 handlers; replace claims.require_caseworker_or_above() with authz.check(…​) (single-row) or authz.auto_scope_list(…​) (LIST). Soft-delete: predicate first. L2 handler smoke + L3 fixture-driven regression tests for cases (Georgia + Texas) generated via macro.

Done (pre-ADR-030)

9

Placement + exchange rollout: ~42 handler sites. Engine wired; default policies cover both services; L2/L3 tests follow Step 8’s pattern.

Done (pre-ADR-030)

10

Financial + reporting rollout: ~28 handler sites.

Done (pre-ADR-030)

11

Security + rules rollout: ~12 handler sites. (Many security handlers admin-only; lighter touch.)

Done (pre-ADR-030)

12

Jurisdiction-readiness Phase A: remove Georgia hardcoded defaults in craig-common::settings, craig-intake::config, craig-web::config (default_jurisdiction, default_theme, default_branding_agency, default_admin_unit_label). Each becomes required env var; service bails at boot if unset. Devstack/CI overrides updated.

Done (pre-ADR-030)

13

Jurisdiction-readiness Phase B: externalize Georgia counties from crates/craig-reference/src/fips.rs::GEORGIA_COUNTIES (159 rows) to admin_unit_registry; new xtask seed-admin-units --jurisdiction <j> --file <csv>; seed Texas counties (closes #225). BFF/intake i18n hardcodes (5 P0 items): "County" label/error → FTL keys; actor_role dropdown → fetch from screening-policy ruleset metadata; "Fulton"/state placeholders → generic. ICPC 84-day deadline → ruleset metadata. Payment-period boundaries → jurisdiction config. BFF cleanup: stop sending ?worker= query params.

Done (pre-ADR-030)

14

Plan completion audit + archive. Spawn audit subagent per delivery-protocol.md; flip Status all-Complete; move plan from nav.adoc Active to archive.adoc Security & Compliance; update .claude/CLAUDE.md Phase Status; CHANGELOG wrap-up entry; close epic.

Done (pre-ADR-030)

Epic: TBD (filed by Step 1)
Issues: TBD (14 step-tracking issues filed by Step 1)
Branch prefix: feat/multi-juris-authz- / fix/multi-juris-authz- / chore/multi-juris-authz-
*Milestone
: TBD

Context

The 2026-04-21 audit flagged record-level authorization (BOLA/IDOR) as the headline P0 federal-compliance event. CRAIG’s caseworker JWTs carry the caseworker role and an opaque sub claim; handlers verify the role but not whether claims.sub is authorized to read THIS PARTICULAR ROW. Platform Stabilization Phase 2 (epic &21, archived 2026-05-07) closed concurrency-correctness but explicitly scoped out application-layer authorization. This plan closes that gap.

Two architectural pivots happened during plan-shaping (2026-05-07 design conversation):

  1. Hardcoding any specific authz policy is wrong for a multi-jurisdictional CCWIS. A jurisdiction-coupling audit found that hardcoding "supervisors are realm-flat" (or any other policy choice) forces every adopting jurisdiction to either fork CRAIG or eat that policy. Each jurisdiction must define its own roles + scoping rules in data, not code. This pivot drives the policy engine + jurisdiction-readiness scope of this plan.

  2. Don’t reinvent the policy DSL. CRAIG already uses zen-engine (gorules' JDM evaluator) for {jurisdiction}-screening-policy, {jurisdiction}-person-match, {jurisdiction}-safety-assessment rulesets. The 2026-05-07 audit identified this as the codebase’s gold-standard pattern. Authz policies join the same pattern: {jurisdiction}-authz-{resource} rulesets evaluated via zen-engine. No new DSL, no new parser, no new evaluator — reuses existing primitives.

A third commitment surfaced during shaping: IdP neutrality (ADR-026). CRAIG must work with any deployer’s identity backend (Keycloak / Auth0 / Okta / AD / etc.) without reaching for IdP-specific admin APIs. Username → sub lookups (for BFF reassignment autocomplete) are served by CRAIG’s own worker_identities table, populated lazily from JWT claims.

Related ADRs (existing): ADR-022 (Event Durability + Idempotency — outbox/inbox foundation), ADR-013 (Web Session Strategy), ADR-014 (Shared reqwest::Client). New ADRs filed by this plan: ADR-023, ADR-024, ADR-025, ADR-026, ADR-027.

Threat model

Trust boundary: every authenticated request entering CRAIG passes through crates/craig-auth/src/jwks.rs::JwksProvider::validate_token (issuer + audience + expiry + JWKS signature check + typ == Bearer). The policy engine sits behind that boundary — it assumes the JWT is genuine and the claims are accurate. Anything upstream of validation is the IdP’s threat surface, not CRAIG’s.

In scope (this plan must mitigate):

  • Authenticated horizontal-privilege escalation (BOLA/IDOR): a caseworker with a valid JWT reads/mutates rows assigned to a different caseworker. Mitigation: record-level predicate evaluated for every row-touching handler (Steps 8-11).

  • Authenticated query-parameter manipulation: caller passes ?worker=other-sub to a LIST endpoint and bypasses scoping. Mitigation: engine determines LIST scope via auto_scope_list; caller-supplied scope params ignored. BFF stops sending the param (Step 13).

  • Authenticated soft-delete-existence-bypass: caller GETs a soft-deleted row by ID to learn it existed. Mitigation: predicate runs before existence check; non-authorized callers get 403 regardless of active flag (§D9).

  • Authenticated cross-jurisdiction read: a user authorized in Georgia reads a Texas row. Mitigation: jurisdiction is per-deployment-instance today (network isolation); engine evaluates (jurisdiction, role, resource_type, action) per request as defense-in-depth.

  • Cache-coherence drift between policy edit and enforcement: admin edits a deny-policy; a replica still has the old (allow) policy cached. Mitigation: RMQ fan-out invalidation + TTL fallback (§D4). Acceptable stale window: ≤ TTL (1h default).

Out of scope (mitigations live elsewhere or are accepted as residual risk):

  • IdP compromise: if the IdP is compromised, attackers can mint JWTs with arbitrary claims. Defense: IdP hardening — a per-deployment concern, not CRAIG’s.

  • Insider supervisor collusion: a supervisor with scope: all for the relevant resource colludes with the requester. Mitigation: audit log + retrospective forensics (§D15); not preventable at the predicate layer.

  • Infrastructure-level cache poisoning: an attacker writes directly into a service’s in-memory cache or RabbitMQ. Defense: RMQ ACLs, DB credentials least-privilege, k8s NetworkPolicy. Not policy-engine concerns.

  • TOCTOU between cache-fetch and predicate evaluation: a policy could change in the millisecond between fetch and eval. Accepted: snapshot taken at request time; effect bounded by single-request scope.

  • Federal compliance read-audit completeness: the engine emits authz.access_denied events but doesn’t capture every successful read decision. Read-audit coverage is a separate plan (audit-trail-completeness.adoc, future).

Attacker capabilities assumed:

  • Has a valid JWT with at least one CRAIG role

  • Knows valid resource IDs (UUIDs are not access-control)

  • Can craft arbitrary HTTP requests (query params, body, headers)

  • Cannot mint or modify JWTs (presumes IdP integrity)

  • Cannot directly write to the authz_policies cache, RMQ, or DB

Scope

In scope:

  • Identity normalization (Step 2): destructive migration of cases.{cases.assigned_worker, cases.supervisor, investigations.assigned_worker} from preferred_username TEXT to sub UUID. No backfill (pre-1.0; no production data; devstack reseed regenerates).

  • Worker identities table (Step 3): new worker_identities in craig-security; auth-middleware lazy-upsert from JWT claims; GET /v1/security/workers?search= endpoint for BFF autocomplete.

  • Policy engine (Step 4): new crates/craig-authz crate wrapping zen-engine; loads JDM rulesets via craig-rules client; in-memory cache; fail-closed on missing policy or cache miss.

  • Cache invalidation (Step 5): RMQ fan-out via subscribe_exclusive + TTL fallback (1-hour default with jitter); bulk-fetch endpoint in craig-rules for boot.

  • Default rulesets (Step 6): JDM JSON for Georgia + Texas, deliberately divergent for L3 regression coverage; seeded via existing mechanism.

  • Cross-service ownership data (Step 7): denormalize assigned_worker_sub onto 11 tables; case.assignment_changed events on outbox/inbox.

  • Per-service engine adoption (Steps 8-11): ~140 handler sites. Action vocabulary { Read, List, Create, Update, Delete, Approve }. Soft-delete: predicate first.

  • Jurisdiction readiness (Steps 12-13): remove Georgia hardcoded defaults; externalize Georgia counties; seed Texas counties (closes #225); BFF/intake i18n hardcodes (5 P0 items); ICPC + payment-period externalization.

Out of scope (deferred):

  • HKDF blind-index, JWS replay defense, intake hardening (Uuid::nil() sentinel, HashMap constant-time, error-detail leakage), strum enum-boundary continuation, pub(crate) discipline, test silent-skip lint, cargo deny RUSTSEC ignore audit → Plan B (pii-and-partner-edge-hardening.adoc)

  • GitOps-style jurisdiction config (gitfs-pulled policies/rulesets/branding/locales) → separate plan (gitops-jurisdiction-config.adoc); Plan A’s file format is gitfs-friendly so future migration is clean

  • Constituent / foster parent / provider portals (Phase 11) — depends on this plan

  • Mobile / offline client (ADR-009) — depends on this plan

  • Tribal language i18n — separate plan once a tribal jurisdiction adopts

  • Sealed/confidential record patterns — no design surface today; punt

  • Supervisor-chain authorization — no supervisor-chain table; punt to a future plan

Design

D1. Identity normalization (destructive migration)

Three columns affected:

Table Column Current type New type

cases.cases

assigned_worker

TEXT NOT NULL (preferred_username)

UUID NOT NULL (sub)

cases.cases

supervisor

TEXT NULL (preferred_username)

UUID NULL (sub)

cases.investigations

assigned_worker

TEXT NOT NULL (preferred_username)

UUID NOT NULL (sub)

Single migration, destructive shape:

ALTER TABLE cases.cases DROP COLUMN assigned_worker;
ALTER TABLE cases.cases ADD COLUMN assigned_worker UUID NOT NULL;
ALTER TABLE cases.cases DROP COLUMN supervisor;
ALTER TABLE cases.cases ADD COLUMN supervisor UUID NULL;
ALTER TABLE cases.investigations DROP COLUMN assigned_worker;
ALTER TABLE cases.investigations ADD COLUMN assigned_worker UUID NOT NULL;
CREATE INDEX idx_cases_assigned_worker ON cases.cases (assigned_worker);
CREATE INDEX idx_cases_supervisor ON cases.cases (supervisor) WHERE supervisor IS NOT NULL;
CREATE INDEX idx_investigations_assigned_worker ON cases.investigations (assigned_worker);

Pre-1.0 + no production data justifies destructive shape. Devstack/test data regenerated via cargo xtask dev reseed. First-production deployments use per-jurisdiction data-import tools (out of scope here).

Existing seed-generator + integration-test fixtures (in tools/craig-seed/src/datagen.rs and per-service tests/api/) currently emit display-name strings ("bob.smith"); these become claims.sub UUIDs ("00000000-0000-0000-0000-000000000002"). Test fixtures iterate accordingly.

D2. Worker identities table (IdP-neutral)

CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE TABLE worker_identities (
    sub                  UUID PRIMARY KEY,
    preferred_username   TEXT NOT NULL,
    display_name         TEXT NOT NULL,
    email                TEXT,
    attrs                JSONB NOT NULL DEFAULT '{}',
    first_seen_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_seen_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE(preferred_username)
);
CREATE INDEX idx_worker_identities_username_trgm ON worker_identities USING gin (preferred_username gin_trgm_ops);
CREATE INDEX idx_worker_identities_display_name_trgm ON worker_identities USING gin (display_name gin_trgm_ops);
CREATE INDEX idx_worker_identities_attrs ON worker_identities USING gin (attrs jsonb_path_ops);

(Trigram indexes for fuzzy autocomplete search; jsonb_path_ops for attrs filtering. pg_trgm is a standard Postgres extension; pg_trgm and jsonb_ops ship with every Postgres install.)

The attrs column captures non-standard JWT claims for per-jurisdiction policy expressions — see §D16 for details.

Lives in craig-security. Auth middleware in crates/craig-auth/src/middleware.rs::JwtMiddleware::on_request upserts on every authenticated request:

async fn upsert_worker_identity(
    db: &PgPool,
    claims: &Claims,
) -> Result<(), sqlx::Error> {
    sqlx::query!(
        r#"INSERT INTO worker_identities (sub, preferred_username, display_name, email, last_seen_at)
           VALUES ($1, $2, $3, $4, now())
           ON CONFLICT (sub) DO UPDATE SET
               preferred_username = EXCLUDED.preferred_username,
               display_name = EXCLUDED.display_name,
               email = EXCLUDED.email,
               last_seen_at = now()"#,
        claims.sub_uuid()?,
        claims.preferred_username,
        claims.display_name(),  // helper that falls back to preferred_username if name claim absent
        claims.email,
    )
    .execute(db)
    .await?;
    Ok(())
}

Performance: this runs per-request; expect 1-2ms median. Indexed on PK. ON CONFLICT path is the common case after warmup.

New endpoint: GET /v1/security/workers?search=<q>&limit=N (default limit 25, max 100). Case-insensitive fuzzy search on preferred_username + display_name. Returns Vec<WorkerIdentitySummary { sub, preferred_username, display_name }>. Auth: require_caseworker_or_above (anyone making decisions about assignment needs to find users).

D3. Policy engine on zen-engine

crates/craig-authz (new). Public surface in src/lib.rs:

use std::collections::HashMap;
use std::sync::Arc;

use anyhow::Result;
use async_trait::async_trait;
use craig_auth::Claims;
use craig_common::error::ApiError;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use strum::{Display, EnumString};
use uuid::Uuid;

#[derive(Clone, Debug)]
pub struct ResourceRef<'a> {
    pub resource_type: ResourceType,
    pub resource_id: Uuid,
    pub assigned_worker_sub: Option<Uuid>,
    pub supervisor_sub: Option<Uuid>,
    pub jurisdiction: &'a str,
    /// Extra fields available to policy expressions (icwa_flag, admin_unit, status, etc.)
    pub attrs: HashMap<&'a str, Value>,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, EnumString, Display, Serialize, Deserialize)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum Action { Read, List, Create, Update, Delete, Approve }

#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, EnumString, Display, Serialize, Deserialize)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum ResourceType {
    Case, Investigation, Referral, Report, Contact, ContactAttachment,
    CourtOrder, CasePlan, CasePlanTask, Person, ReportPerson,
    ReportAttachment, ScreeningDecision, DispositionFollowUp, HouseholdMember,
    Placement, FosterHome, EducationRecord, HealthRecord, KinshipOption, HomeDocument,
    ExchangeAgreement, ExchangePartner, ExchangeTransaction,
    IcpcRequest, IcpcHomeStudy, IcpcAttachment,
    Payment, PaymentAdjustment, Claim, RateTable,
    AfcarsSubmission, NcandsSubmission, QualityReview, DataQualityIssue,
    AuditEvent, SecurityAlert, SecurityArchive, SecurityReview,
    DetectionRule, NistControl, MajorChange, AdminUnit, WorkerIdentity,
    Partner, PartnerApiKey, PartnerSignerKey,
    RuleSet, RuleEvaluation,
}

#[derive(Clone, Debug)]
pub enum ListScope {
    All,
    AssignedWorker(Uuid),         // SQL: WHERE assigned_worker_sub = $1
    AssignedSupervisor(Uuid),     // SQL: WHERE supervisor_sub = $1
    Custom(Value),                // for richer scopes the JDM ruleset returns
    Denied,
}

#[async_trait]
pub trait AuthzEngine: Send + Sync {
    async fn check(
        &self,
        claims: &Claims,
        resource: ResourceRef<'_>,
        action: Action,
    ) -> Result<(), ApiError>;

    async fn auto_scope_list(
        &self,
        claims: &Claims,
        resource_type: ResourceType,
        action: Action,
        jurisdiction: &str,
    ) -> Result<ListScope, ApiError>;
}

pub struct ZenAuthzEngine {
    cache: Arc<tokio::sync::RwLock<RulesetCache>>,
    rules_client: Arc<craig_rules_client::RulesClient>,
}

Cache module src/cache.rs:

use std::collections::HashMap;
use std::time::Instant;
use zen_engine::DecisionEngine;
use zen_engine::loader::MemoryLoader;
use zen_engine::model::DecisionContent;

pub struct RulesetCache {
    by_name: HashMap<String, CachedRuleset>,
}

pub struct CachedRuleset {
    pub name: String,
    pub version: String,
    pub fetched_at: Instant,
    pub engine: DecisionEngine<MemoryLoader>,
}

Engine evaluation flow

For check(claims, resource, action):

  1. Compute ruleset name: format!("{}-authz-{}", resource.jurisdiction, resource.resource_type) (e.g., "georgia-authz-case").

  2. Lookup in cache. Miss → emit authz.cache_miss event; return ApiError::Forbidden::with_detail("policy missing") (fail-closed).

  3. Build zen-engine input as serde_json::Value:

    {
      "claims": {
        "sub": "00000000-...",
        "preferred_username": "bob.smith",
        "roles": ["caseworker"]
      },
      "resource": {
        "id": "...",
        "assigned_worker_sub": "...",
        "supervisor_sub": "...",
        "attrs": { "icwa_flag": false, "admin_unit": "Fulton" }
      },
      "action": "read"
    }
  4. Run engine.evaluate(input).await?. zen-engine returns EvaluationResponse { result: Value, performance: …​ }.

  5. Parse result for { "allow": bool, "reason": Option<String> }.

  6. If allow == false → emit authz.access_denied event with caller, resource, evaluated-policy-name, reason; return ApiError::Forbidden.

  7. If allow == true → Ok(()).

For auto_scope_list(claims, resource_type, action, jurisdiction):

  1. Same ruleset lookup with action = "list".

  2. JDM decision returns { "scope": "all" | "assigned_worker" | "assigned_supervisor" | "denied" | { "custom": {…​} } }.

  3. Engine maps to ListScope enum. Handler translates to SQL filter.

D4. RMQ-driven cache invalidation

Publisher (in craig-rules' admin endpoints handling rule_set CRUD):

// services/craig-rules/src/api/sets.rs::update_rule_set
let mut tx = pool.begin().await?;
sqlx::query!("UPDATE rule_sets SET ... WHERE id = $1", id).execute(&mut *tx).await?;
let envelope = EventEnvelope::new(
    "ruleset.changed",
    json!({ "name": name, "version": new_version }),
);
publisher.publish_in_tx(&mut tx, &envelope, "ruleset.changed").await?;
tx.commit().await?;

Outbox worker (already shipped in platform-stab-2 §D2) drains and publishes to RabbitMQ.

Exchange definition (declared by craig-mq bootstrap):

  • Name: craig.cache_invalidations

  • Type: topic

  • Durable: yes

Consumer (in each consuming service’s bootstrap, e.g. services/craig-cases/src/main.rs):

let invalidation_queue = subscribe_exclusive(
    &channel,
    "craig.cache_invalidations",
    "ruleset.changed.#",
).await?;
let cache_handle = authz_engine.cache_handle();
tokio::spawn(handle_cache_invalidations(invalidation_queue, cache_handle));

Failure modes:

  • Missed event (RabbitMQ blip) → TTL fallback (1h default) catches within window.

  • Bulk-fetch fails at boot → service bails fail-closed (analogous to encryption-mode-required boot guard).

  • RabbitMQ partition → existing cached rulesets continue serving; new policy changes don’t propagate until partition heals.

  • craig-rules outage during normal operation → cached rulesets continue serving.

TTL: Duration::from_secs(env::var("CRAIG_<SERVICE>__AUTHZ_POLICY_TTL_SECONDS").unwrap_or("3600").parse()?). Per-replica jitter 0-300s on the timer to avoid thundering herd at TTL boundary.

Bulk-fetch endpoint in craig-rules:

// GET /v1/rules/sets?prefix=georgia-authz-
pub async fn list_rule_sets(
    Query(q): Query<ListRuleSetsQuery>,
) -> Result<Json<Vec<RuleSet>>, ApiError> {
    if let Some(prefix) = q.prefix {
        store::sets::list_by_prefix(&pool, &prefix).await
    } else {
        store::sets::list_all(&pool).await
    }
}

D5. Default rulesets (JDM JSON files)

File layout:

rulesets/georgia/
  georgia-authz-case.json
  georgia-authz-investigation.json
  georgia-authz-referral.json
  ... (per ResourceType)
rulesets/texas/
  texas-authz-case.json
  ...

Example rulesets/georgia/georgia-authz-case.json:

{
  "metadata": {
    "name": "georgia-authz-case",
    "version": "1.0.0",
    "description": "Curated CRAIG example. Replace before production deployment.",
    "jurisdiction": "georgia",
    "resource_type": "case"
  },
  "nodes": [
    {
      "id": "input",
      "type": "inputNode",
      "name": "Authz Request"
    },
    {
      "id": "decision",
      "type": "decisionTableNode",
      "name": "Case authz decision",
      "content": {
        "hitPolicy": "first",
        "inputs": [
          { "id": "in1", "name": "claims_roles", "field": "claims.roles" },
          { "id": "in2", "name": "is_assigned", "field": "$.resource.assigned_worker_sub == $.claims.sub" },
          { "id": "in3", "name": "action", "field": "action" }
        ],
        "outputs": [
          { "id": "out1", "name": "allow", "field": "allow" },
          { "id": "out2", "name": "scope", "field": "scope" }
        ],
        "rules": [
          { "in1": "contains 'admin'", "in2": "*", "in3": "*", "out1": "true", "out2": "\"all\"" },
          { "in1": "contains 'supervisor'", "in2": "*", "in3": "*", "out1": "true", "out2": "\"all\"" },
          { "in1": "contains 'caseworker'", "in2": "true", "in3": "*", "out1": "true", "out2": "\"assigned_worker\"" },
          { "in1": "contains 'caseworker'", "in2": "*", "in3": "'list'", "out1": "true", "out2": "\"assigned_worker\"" },
          { "in1": "contains 'readonly'", "in2": "*", "in3": "'read' or 'list'", "out1": "true", "out2": "\"all\"" },
          { "in1": "*", "in2": "*", "in3": "*", "out1": "false", "out2": "\"denied\"" }
        ]
      }
    },
    { "id": "output", "type": "outputNode", "name": "Authz Outcome" }
  ],
  "edges": [
    { "id": "e1", "sourceId": "input", "targetId": "decision" },
    { "id": "e2", "sourceId": "decision", "targetId": "output" }
  ]
}

Texas fixture deliberately divergent in three dimensions:

  1. Supervisor scope: assigned_supervisor (case-scoped) instead of all (realm-flat).

  2. Readonly scope: assigned_worker (PTO-caseworker) instead of all (auditor).

  3. Custom role: introduce regional_supervisor with rule claims.roles contains 'regional_supervisor' and resource.attrs.admin_unit in claims.attrs.assigned_units.

Both files prefaced Curated CRAIG example. Replace before production deployment. Acts as L3 regression fixture for the test plan.

Seeded via existing ruleset-seed mechanism (xtask seed-rulesets). New jurisdictions ship their own rulesets.

D6. Cross-service denormalization

11 tables across placement/financial/exchange need an assigned_worker_sub UUID column to enable local authz evaluation:

Service Tables

craig-placement

placements, education_records, health_records, kinship_options, home_documents

craig-financial

payments, payment_adjustments, claims

craig-exchange

icpc_requests, icpc_home_studies, icpc_attachments

Migration shape per table:

  1. ALTER TABLE <t> ADD COLUMN assigned_worker_sub UUID NULL

  2. CREATE INDEX idx_<t>_assigned_worker_sub ON <t> (assigned_worker_sub) (for LIST scope filter)

  3. xtask cargo xtask backfill-cross-service-assignment reads from cases service via existing reconciliation walker pattern (xtask/src/cmd/reconcile.rs precedent), populates each row’s assigned_worker_sub from the linked cases.cases.assigned_worker.

  4. After backfill verified: ALTER TABLE <t> ALTER COLUMN assigned_worker_sub SET NOT NULL (only for tables where parent case is non-NULL).

Replication shape:

  • cases service publishes case.assignment_changed { case_id, old_sub, new_sub, changed_at } event when cases.assigned_worker mutates (covers create + update).

  • Each consuming service has inbox handler bound to case.# topic with routing key case.assignment_changed. Handler executes UPDATE across its local tables that reference the case_id.

  • Failure semantics inherit from inbox three-state retry (platform-stab-2 §D3).

Eventually-consistent window (~5s typical) fails closed: stale reads see OLD sub, deny NEW worker until propagation completes.

D7. Handler integration pattern

GET-by-id pattern (see Step 8 for cases-service rollout):

pub async fn get_case(
    Extension(claims): Extension<Claims>,
    Extension(authz): Extension<Arc<dyn AuthzEngine>>,
    Extension(jurisdiction): Extension<Jurisdiction>,
    State(app): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<store::models::Case>, ApiError> {
    let case = store::cases::get(app.db.inner(), id).await?
        .ok_or_else(|| ApiError::not_found("case", id))?;
    authz.check(
        &claims,
        ResourceRef {
            resource_type: ResourceType::Case,
            resource_id: case.id,
            assigned_worker_sub: Some(case.assigned_worker),
            supervisor_sub: case.supervisor,
            jurisdiction: &jurisdiction.0,
            attrs: hashmap! {
                "icwa_flag" => case.icwa_flag.into(),
                "admin_unit" => case.admin_unit.clone().into(),
                "status" => case.status.clone().into(),
            },
        },
        Action::Read,
    ).await?;
    Ok(Json(case))
}

LIST pattern:

pub async fn list_cases(
    Extension(claims): Extension<Claims>,
    Extension(authz): Extension<Arc<dyn AuthzEngine>>,
    Extension(jurisdiction): Extension<Jurisdiction>,
    State(app): State<AppState>,
    Query(query): Query<ListCasesQuery>,
) -> Result<Json<PageResponse<store::models::Case>>, ApiError> {
    let scope = authz.auto_scope_list(
        &claims,
        ResourceType::Case,
        Action::List,
        &jurisdiction.0,
    ).await?;
    let cases = store::cases::list_with_scope(app.db.inner(), scope, query).await?;
    Ok(Json(cases))
}

Store fn list_with_scope:

pub async fn list_with_scope<'e, E: PgExecutor<'e>>(
    executor: E,
    scope: ListScope,
    query: ListCasesQuery,
) -> Result<PageResponse<Case>> {
    let mut sql = QueryBuilder::new("SELECT * FROM cases WHERE 1=1");
    match scope {
        ListScope::All => {},
        ListScope::AssignedWorker(sub) => {
            sql.push(" AND assigned_worker = ").push_bind(sub);
        }
        ListScope::AssignedSupervisor(sub) => {
            sql.push(" AND supervisor = ").push_bind(sub);
        }
        ListScope::Denied => {
            return Err(ApiError::forbidden("LIST denied by policy"));
        }
        ListScope::Custom(_) => {
            return Err(ApiError::internal("custom scope not yet implemented"));
        }
    }
    // ... existing query.search/sort/page filters, then execute
}

D8. HTTP verb to Action mapping

HTTP Path shape Action

GET

/v1/<svc>/<resource>?<query>

List

POST

/v1/<svc>/<resource>

Create

GET

/v1/<svc>/<resource>/{id}

Read

PUT

/v1/<svc>/<resource>/{id}

Update

PATCH

/v1/<svc>/<resource>/{id}

Update

DELETE

/v1/<svc>/<resource>/{id}

Delete

POST

/v1/<svc>/<resource>/{id}/approve

Approve

POST

/v1/<svc>/<resource>/{id}/<workflow>

<Workflow> (string-keyed)

Sub-resources target their own type, not the parent:

  • GET /v1/cases/cases/{id}/householdList on HouseholdMember (filtered by case_id)

  • POST /v1/cases/contacts/{id}/attachmentsCreate on ContactAttachment

Multi-resource handlers call engine.check() per affected resource.

D9. Soft-delete behavior

Predicate runs first; soft-delete check is irrelevant to authz. GET-by-id returns the row to authorized callers regardless of active flag (audit-trail use case). LIST continues to filter active=true (separate from authz; semantically distinct).

Existence-leak via 403-vs-404 distinction is bounded: caller is already authenticated + role-checked before reaching the predicate.

D10. Test coverage (L1 + L2 + L3)

  • L1 (engine unit tests, ~50-100 tests): full coverage of craig-authz crate. JDM expression shapes, scope outcomes, fail-closed branches, cache-miss behavior, RMQ invalidation handler. Self-contained; cargo nextest run -p craig-authz.

  • L2 (handler smoke tests, ~140 tests): one per handler verifying engine wiring. Generated via #[authz_smoke_test(handler = "get_case", resource = "Case", action = "Read")] macro. Asserts that an unauthorized JWT gets 403 and an authorized JWT gets 200, using a minimal hand-crafted policy.

  • L3 (fixture-driven regression tests, ~200 tests): per (resource_type, jurisdiction), representative scenarios from default fixtures. Generated via proc macro reading rulesets/{jurisdiction}/*.json. Catches "CRAIG change broke a fixture’s behavior" regressions.

L1 covers engine correctness; L2 covers wiring; L3 covers contract regression. Total ~290-340 tests.

Out of scope for CRAIG: validating that any specific jurisdiction’s policies match their actual federal/state requirements. That’s the adopter’s responsibility.

D11. Jurisdiction readiness

D11.1 Hardcoded defaults removal

  • crates/craig-common/src/settings.rs:124 default_jurisdiction() deleted. Field becomes required in deserializer (no #[serde(default = …​)]).

  • services/craig-intake/src/config.rs:270 same.

  • services/craig-web/src/config.rs:61, 65, 67 default_theme(), default_branding_agency(), default_admin_unit_label() deleted.

Service bails at boot with a clear log message if any required env var is unset:

ERROR craig_cases::main: required env var CRAIG_CASES__JURISDICTION not set; refusing to start. See deployment-guide.adoc.

Devstack .env.example and docker-compose.yml updated to set all required vars explicitly. CI overrides updated.

D11.2 Admin-unit registry externalization

crates/craig-reference/src/fips.rs:1408-1414::admin_units_for_state() deprecated with #[deprecated] attribute. New API:

pub async fn admin_units_for_jurisdiction(
    pool: &PgPool,
    jurisdiction: &str,
) -> Result<Vec<AdminUnit>>;

Reads from admin_unit_registry (existing schema; populated via xtask).

GEORGIA_COUNTIES constant array (159 entries at crates/craig-reference/src/fips.rs:1226-1407) exported to default_admin_units/georgia.csv. New xtask:

cargo xtask seed-admin-units --jurisdiction georgia --file default_admin_units/georgia.csv

INSERTs ON CONFLICT DO NOTHING into admin_unit_registry. Same shape for texas.csv (closes #225).

Migration auto-seeds for known jurisdictions on first install.

D11.3 BFF/intake i18n hardcodes

  1. services/craig-intake/static/report.html:82 "County" label → FTL key report-form-admin-unit-label

  2. services/craig-intake/static/report.html:211 "County is required." → FTL key report-error-admin-unit-required

  3. services/craig-web/templates/intake/report_detail.html:253 actor_role dropdown — current static <option value="county_supervisor">…</option> → replaced with template loop {% for role in screening_policy.actor_roles %}<option value="{{ role.id }}">{{ role.label }}</option>{% endfor %} where screening_policy is loaded at template render via the existing {jurisdiction}-screening-policy ruleset metadata.

  4. services/craig-web/locales/en/web.ftl:82, 207, 471 "Fulton" placeholders → e.g. <county> (jurisdiction-config-driven sample)

  5. services/craig-web/locales/en/public.ftl:63, 111 state/county placeholders → generic

D11.4 Operational constants

  • ICPC 84-day deadline (services/craig-exchange/src/api/icpc.rs:264) → moved to ruleset metadata in a new {jurisdiction}-icpc-policy ruleset (mirrors screening-policy pattern). Default Georgia ruleset ships with { "icpc_deadline_days": 84 }.

  • Payment-period boundaries (services/craig-financial/src/main.rs:148, 173) → craig-financial::settings::PaymentPeriodConfig env-driven struct: period_start_day, period_boundary_kind: calendar_month | fiscal_month | iso_week. Default calendar_month / day=1.

D11.5 BFF cleanup

services/craig-web/src/routes/cases/list.rs and similar list routes currently pass ?worker= query param to the backend. After Step 8’s LIST auto-scoping ships, the backend ignores caller-supplied ?worker=. Remove the param-passing logic; backend determines scope.

D12. ADRs

  1. ADR-023: Multi-Jurisdictional Authorization Architecture — data-driven policy engine; jurisdiction first-class; no hardcoded policy in code.

  2. ADR-024: Policy Engine on zen-engine + RMQ Cache Invalidation — {jurisdiction}-authz-{resource} rulesets; in-memory cache with subscribe_exclusive invalidation + TTL fallback; fail-closed on cache miss / boot-load failure.

  3. ADR-025: Identity Normalization — preferred_username TEXT → sub UUID; destructive migration justified by pre-1.0 status; first-production deployments use per-jurisdiction data-import tools.

  4. ADR-026: IdP-Neutral Identity Layer — JWT/JWKS only; no IdP admin API client; worker_identities table populated lazily from JWT claims; lookups served from CRAIG’s own table.

  5. ADR-027: Architectural Principles — pluggability everywhere, async-first, identity-neutral, configuration-as-data, don’t reinvent (OIDC, AMQP, JDM). Forward-references gitfs-jurisdiction-config follow-up plan.

D13. Service-to-service authorization

Some CRAIG calls cross service boundaries with a JWT minted by a service account, not by an end user — e.g., services/craig-intake/src/api/service_token.rs mints a service-account JWT to call craig-cases when partner-submitted reports are converted to cases (per services/craig-cases/src/api/reports.rs:95-98). The current pattern is the service-account JWT carries the caseworker role (or admin), and handlers' role-only check accepts it.

Under the policy engine, service-account JWTs need an explicit policy treatment so jurisdictions can choose whether internal-service calls bypass record-level authz (typical) or are scoped (defense-in-depth).

Recommended pattern:

  • Service-account JWTs carry a service-account claim (a custom claim, not a CRAIG role) AND the operational role(s) needed (e.g., caseworker).

  • Default policies include explicit entries for the service-account role:

    # Default service-account policy in georgia-authz-{resource}.json
    { "in1": "contains 'service-account'", "in2": "*", "in3": "*", "out1": "true", "out2": "\"all\"" }
  • Documentation in crates/craig-authz/README.md and ADR-023 explicitly notes: "service-account claims are deployer-issued and must be deliberately granted; the default Georgia policy is permissive for internal-service flows but jurisdictions can scope tighter."

Existing service-account flow inventory (call sites that should grow service-account claim issuance):

  • services/craig-intake/src/api/service_token.rs (intake → cases for report conversion)

  • services/craig-security/src/api/partners.rs:352 (admin endpoint accepting service-account hits)

  • Any future service-to-service call patterns

This is not a new IdP feature — service-account claims are a deployer-configurable claim mapping in Keycloak / Auth0 / etc. CRAIG’s policy engine treats service-account as a role-equivalent entry. Documented as part of jurisdiction-onboarding playbook (Step 13).

D14. Audit logging schema

The existing audit_log table (services/craig-security/migrations/20260305100000_create_security_tables.sql:2-14) is the destination for authz events. Schema unchanged; events use the existing details JSONB field for authz-specific structure.

Event types emitted by the engine:

action value details JSONB shape

authz.access_denied

{ "resource_type": "case", "resource_id": "…​", "jurisdiction": "georgia", "policy_action": "read", "ruleset_name": "georgia-authz-case", "ruleset_version": "1.0.0", "denied_reason": "no rule matched", "request_id": "…​" }

authz.cache_miss

{ "ruleset_name": "georgia-authz-case", "lookup_jurisdiction": "georgia", "lookup_resource_type": "case", "request_id": "…​" }

authz.cache_refreshed

{ "ruleset_name": "georgia-authz-case", "trigger": "rmq_event" | "ttl_refresh" | "boot", "old_version": "…​", "new_version": "…​" }

authz.policy.changed

{ "ruleset_name": "…​", "old_version": "…​", "new_version": "…​", "changed_by_sub": "…​", "diff_summary": "…​" }

audit_log columns populated:

  • service: the service emitting (e.g., craig-cases)

  • user_id: claims.sub for user-driven events; service-account sub for service-driven events

  • user_role: comma-joined claims.roles

  • action: per the table above

  • resource_type: matches ResourceType::to_string() (snake_case)

  • resource_id: the affected row’s UUID, or NULL for cache events

  • details: JSONB per shape above

  • success: false for access_denied, true for cache events (informational)

Emission path: craig-authz does NOT write directly to the audit_log DB. Instead it publishes events on the existing outbox to craig.events with routing key authz.<event_type>. craig-security’s existing wildcard audit subscriber (per CLAUDE.md Phase 8: "wildcard audit subscriber") consumes them and writes to its own audit_log table. This preserves the established audit pattern; no new subscriber, no new table.

Verification of audit emission lives in L2 smoke tests: every "authorized fail-closed" scenario asserts an audit_log row appears with the matching shape.

D15. Bulk operation authz

Bulk endpoints (POST /v1/cases/batch-lookup at services/craig-cases/src/api/cases.rs:464, and any future bulk endpoints) need a per-row authz check with explicit "partial result" semantics. Without this, bulk endpoints become BOLA bypasses (caller submits N IDs and gets all N back regardless of authz).

Pattern:

pub async fn batch_lookup(
    State(app): State<AppState>,
    Extension(claims): Extension<Claims>,
    Extension(authz): Extension<Arc<dyn AuthzEngine>>,
    Extension(jurisdiction): Extension<Jurisdiction>,
    Json(body): Json<BatchLookupRequest>,
) -> Result<Json<BatchLookupResponse>, ApiError> {
    if body.case_ids.len() > MAX_BATCH_LOOKUP {
        return Err(ApiError::bad_request(format!("maximum {MAX_BATCH_LOOKUP} IDs")));
    }
    let raw_cases = store::cases::batch_lookup_cases(app.db.inner(), &body.case_ids).await?;
    let mut filtered = HashMap::new();
    let mut unauthorized_count = 0;
    for (id, case) in raw_cases {
        match authz.check(
            &claims,
            ResourceRef {
                resource_type: ResourceType::Case,
                resource_id: id,
                assigned_worker_sub: Some(case.assigned_worker),
                supervisor_sub: case.supervisor,
                jurisdiction: &jurisdiction.0,
                attrs: HashMap::new(),
            },
            Action::Read,
        ).await {
            Ok(()) => { filtered.insert(id, case); }
            Err(ApiError::Forbidden { .. }) => { unauthorized_count += 1; }
            Err(e) => return Err(e),
        }
    }
    Ok(Json(BatchLookupResponse {
        cases: filtered,
        unauthorized_count,  // NEW field
        partial_auth: unauthorized_count > 0,  // NEW field
    }))
}

DTO change in crates/craig-cases-contracts/src/cases.rs::BatchLookupResponse:

pub struct BatchLookupResponse {
    pub cases: HashMap<Uuid, CaseSummary>,
    pub persons: HashMap<Uuid, PersonSummary>,
    /// Number of requested IDs the caller was not authorized to see.
    /// Audit log captures who the caller was; response just signals the count.
    #[serde(default)]
    pub unauthorized_count: usize,
    /// True iff `unauthorized_count > 0` for either cases or persons.
    #[serde(default)]
    pub partial_auth: bool,
}

Performance: per-row engine.check() at most N=MAX_BATCH_LOOKUP (currently 100). At <100µs per check (zen-engine cached eval), total overhead <10ms. Acceptable.

Audit: each authz.access_denied event for a per-row deny is logged individually. Bulk endpoints can produce N audit events per request — bounded by MAX_BATCH_LOOKUP cap.

This pattern applies to: batch_lookup in cases (the only existing bulk endpoint); any future bulk operations follow the same shape.

D16. Custom claims source for JDM evaluation context

The default JDM rulesets use claims.roles, claims.sub, resource.assigned_worker_sub, resource.supervisor_sub, resource.attrs.<field>. Texas’s example fixture (D5) references claims.attrs.assigned_units — a custom claim. Where do those come from?

Source: worker_identities table extension. Add an attrs JSONB NOT NULL DEFAULT '{}' column to the schema in §D2:

ALTER TABLE worker_identities ADD COLUMN attrs JSONB NOT NULL DEFAULT '{}';

Population: at JWT-claim-upsert time (§D2 middleware hook), copy any non-standard JWT claims into attrs. CRAIG-recognized standard claims (sub, preferred_username, email, name, roles) populate the dedicated columns; everything else lands in attrs as a flat JSONB object.

Per-deployment claim mapping: deployers configure their IdP to emit the claims their policies reference. Examples:

  • Keycloak: realm role attributes + custom mapper that emits attrs.assigned_units from the user’s group memberships

  • Auth0: app metadata mapped to a custom claim

  • Okta: profile attributes mapped via OAuth claim mapper

Documentation: jurisdiction-onboarding playbook (Step 13) includes a "configuring custom claims for your IdP" section with examples for the major IdPs. CRAIG itself remains agnostic — it just reads what the JWT carries.

Engine evaluation: when building zen-engine input, craig-authz’s engine includes `claims.attrs from the cached worker_identities.attrs (looked up by claims.sub at request time). Single in-memory lookup; no per-request DB call after the upsert hook.

D17. CI authz coverage gate

To prevent regression where a future contributor adds a handler that bypasses engine.check(), ship a CI gate that walks every #[utoipa::path]-annotated handler function and asserts each calls into the engine.

New xtask command validate-authz-coverage (modeled on existing xtask/src/cmd/coverage_matrix.rs and xtask/src/cmd/check_docs.rs):

  • Walks services//src/api/*/*.rs files

  • Parses each via syn (workspace dep already used by macros)

  • For every function annotated with #[utoipa::path(…​)], checks the function body for at least one of:

    • authz.check(

    • authz.auto_scope_list(

    • An allowlist comment immediately above the #[utoipa::path]: // authz: skip — <reason>

  • Reports any handler that bypasses + reports any allowlist entry without a reason

Output (markdown report):

=== authz coverage report ===
Total handlers (utoipa::path): 214
Engine-wired:                  207
Allowlisted:                   7  (health, livez, readyz, openapi, etc.)
Bypassed (FAIL):               0

Allowlist legitimate exceptions:

  • /livez, /readyz, /healthz — k8s probes; pre-auth

  • /openapi.json — public schema doc

  • Any future public-by-design endpoint

Integration: append to cargo xtask validate (called from pre-push hook + CI). Failing the check (any "Bypassed" handler) bails the gate.

Spec lives in xtask/src/cmd/validate_authz_coverage.rs. Test fixture in xtask/tests/authz_coverage_test.rs exercises a handler-with-engine, handler-with-allowlist, handler-without-engine — asserts the third case fails the check.

D18. Missing-policy detection at boot

When a new ResourceType is added in CRAIG code post-Plan A, default policies might not cover it. The engine fail-closes correctly (cache miss → deny), but operators don’t know proactively.

At boot, after bulk-fetch loads cached rulesets, the engine enumerates the cartesian product ResourceType × Action and checks coverage:

async fn validate_default_policy_coverage(
    cache: &RulesetCache,
    jurisdiction: &str,
) -> Result<(), CoverageWarning> {
    let mut missing = Vec::new();
    for resource_type in ResourceType::iter() {
        let ruleset_name = format!("{}-authz-{}", jurisdiction, resource_type);
        if !cache.has(&ruleset_name) {
            missing.push(ruleset_name);
        }
    }
    if !missing.is_empty() {
        tracing::warn!(
            jurisdiction = jurisdiction,
            missing_count = missing.len(),
            "default policies missing for {} ResourceType(s); engine will fail-closed for affected requests. Add rulesets in `rulesets/{}/`",
            missing.len(),
            jurisdiction,
        );
        return Err(CoverageWarning::Missing(missing));
    }
    Ok(())
}

CoverageWarning::Missing is logged but does NOT bail boot — fail-closed at request time is the correct behavior (some (jurisdiction, ResourceType) combinations might be intentionally absent for that jurisdiction). Optionally promotable to bail via env var CRAIG_<SERVICE>__AUTHZ_REQUIRE_FULL_COVERAGE=true for strict deployments.

Per-Action granularity is delegated to the JDM ruleset itself (each ruleset handles all actions for its resource_type via the decision-table rows).

Steps

Step 1: Plan filing + 5 ADR drafts + GitLab issue tree

Files:

  • docs/modules/ROOT/pages/plans/multi-jurisdictional-authz.adoc — new (this content)

  • docs/modules/ROOT/pages/adrs/adr-024-multi-jurisdictional-authz.adoc — new (Proposed)

  • docs/modules/ROOT/pages/adrs/adr-025-policy-engine-design.adoc — new (Proposed)

  • docs/modules/ROOT/pages/adrs/adr-026-identity-normalization.adoc — new (Proposed)

  • docs/modules/ROOT/pages/adrs/adr-027-idp-neutral-identity.adoc — new (Proposed)

  • docs/modules/ROOT/pages/adrs/adr-028-architectural-principles.adoc — new (Proposed)

  • docs/modules/ROOT/nav.adoc — under * Plans / ** Active, add this plan; under ADRs, add 5 entries

  • CHANGELOG.adoc — entry under == Unreleased

GitLab artifacts created:

  • 1 epic at group level: epic: multi-jurisdictional authorization (BOLA + jurisdiction-readiness)

  • 14 step-tracking issues, linked to the epic, labeled per character (chore for 1/14; feat for 2-13), milestone TBD

Branch: chore/multi-juris-authz-step1-plan-and-adrs

MR title: chore(plans): file multi-jurisdictional-authz plan + ADR-023..028 drafts + GitLab issue tree [Step 1]

Verification:

  1. cargo xtask check-docs — Tier 1 docs untouched; new ADR + plan AsciiDoc-correct

  2. cargo xtask validate --skip-docker — pre-push gate green (no code changes)

  3. After push: glab mr view <id> shows pipeline pass; glab issue list --milestone "…​" shows new epic + 14 issues

CHANGELOG draft:

=== Multi-Jurisdictional Authorization plan + ADR-023..028 drafts [Step 1] (DATE)

Plan filed: docs/modules/ROOT/pages/plans/multi-jurisdictional-authz.adoc.
5 ADRs drafted (Proposed status): ADR-023 multi-jurisdictional authz architecture,
ADR-024 zen-engine policy engine + RMQ cache invalidation, ADR-025 identity
normalization, ADR-026 IdP-neutral identity layer, ADR-027 architectural
principles. Epic + 14 step issues filed at GitLab. No code changes; doc-only MR.

Step 2: ADR-025 acceptance + identity normalization

Files:

  • docs/modules/ROOT/pages/adrs/adr-026-identity-normalization.adoc — flip ProposedAccepted

  • services/craig-cases/migrations/<TS>_assigned_worker_uuid.sql — new (destructive ALTER per §D1)

  • services/craig-cases/src/store/models.rs — change assigned_worker: Stringassigned_worker: Uuid; same for supervisor, investigations.assigned_worker

  • services/craig-cases/src/store/cases.rs — update INSERT/UPDATE bindings to UUID

  • services/craig-cases/src/store/investigations.rs — same

  • services/craig-cases/src/api/cases.rs — handler writes claims.sub_uuid() into assigned_worker; request body’s assigned_worker_sub field if present

  • services/craig-cases/src/api/investigations.rs — same pattern

  • crates/craig-cases-contracts/src/cases.rs — DTOs changed: CreateCaseRequest.assigned_worker: Uuid, supervisor: Option<Uuid> (was String)

  • crates/craig-cases-contracts/src/investigations.rsCreateInvestigationRequest.assigned_worker: Uuid

  • All cases-service integration tests in services/craig-cases/tests/api/ referencing "bob.smith" etc. as assigned_worker strings — updated to use the seeded UUID 00000000-0000-0000-0000-000000000002. Use the existing craig_test_lib::SeedSubs constants if available; otherwise add them.

  • tools/craig-seed/src/datagen.rsassigned_worker values become UUIDs from a seeded user pool

  • services/craig-cli/tests/cli/referral.rs:22 and other assigned_worker: "bob.smith".into() test sites — convert to UUID

Branch: feat/multi-juris-authz-step2-identity-normalization

MR title: feat(craig-cases, craig-cases-contracts): identity normalization — assigned_worker TEXT → UUID [Step 2 of multi-juris-authz]

Verification:

  1. cargo xtask dev reseed — devstack regenerates with new shape

  2. cargo nextest run -p craig-cases --tests — all integration tests pass with UUID-typed identity

  3. cargo nextest run --workspace — workspace-wide tests pass (test-lib + cli updates included)

  4. cargo xtask validate --skip-docker — pre-push green

CHANGELOG draft:

=== Identity normalization (Step 2 of multi-juris-authz) (DATE)

ADR-025 accepted. cases.cases.{assigned_worker, supervisor} and
cases.investigations.assigned_worker columns migrated from TEXT
(preferred_username) to UUID (claims.sub). Destructive migration
justified by pre-1.0 status + no production data; first-production
deployments use per-jurisdiction data-import tools, not this migration.

Devstack data regenerated via reseed. Test fixtures updated to use seeded
sub UUIDs instead of display-name strings. Workspace tests: 1696/1696.

Step 3: Worker identities table + IdP-neutral autocomplete

Files:

  • services/craig-security/migrations/<TS>_worker_identities.sql — new table per §D2 (includes attrs JSONB NOT NULL DEFAULT '{}' column per §D16) + pg_trgm extension enable

  • services/craig-security/src/store/worker_identities.rs — new module (upsert + search fns; upsert merges attrs from JWT custom claims)

  • services/craig-security/src/store/models.rs — add WorkerIdentity struct (with attrs: Value field)

  • crates/craig-auth/src/middleware.rs::JwtMiddleware::on_request — call upsert_worker_identity after Claims validation; pass non-standard JWT claims into the attrs JSONB

  • crates/craig-auth/src/middleware.rs — add db: Arc<PgPool> field on JwtMiddleware (security-service connection only; other services use a scoped helper that no-ops if db absent)

  • crates/craig-auth/src/claims.rs — extend Claims deserializer to capture extra: HashMap<String, Value> for non-standard claims via #[serde(flatten)]

  • services/craig-security/src/api/workers.rs — new module with GET /v1/security/workers?search=&limit=; rate-limited to 60 req/min/sub via existing rate-limit middleware; minimum 2-character search query

  • services/craig-security/src/api/mod.rs — register route

  • crates/craig-cases-contracts/src/workers.rs — new (DTOs: WorkerIdentitySummary, ListWorkersQuery)

  • services/craig-security/tests/api/workers.rs — new (8 tests: lazy upsert via JWT, search by username, search by display name, search with diacritics, search below minimum length returns 400, prefix collision returns multiple, rate-limit triggers 429, custom claim populates attrs column)

  • .claude/docs/services.md — document new endpoint + table + attrs column

Branch: feat/multi-juris-authz-step3-worker-identities

MR title: feat(craig-security, craig-auth): worker_identities table + lazy JWT-claim population + autocomplete endpoint [Step 3 of multi-juris-authz]

Verification:

  1. cargo xtask dev reseed

  2. cargo nextest run -p craig-security --test api workers — 3 new tests pass

  3. Manual: log in as jane.doe via craig auth login (CLI); verify worker_identities row appears with sub + preferred_username

  4. cargo xtask validate --skip-docker — pre-push green

CHANGELOG draft:

=== Worker identities table + IdP-neutral autocomplete (Step 3 of multi-juris-authz) (DATE)

ADR-026 implementation: new worker_identities table in craig-security
populated lazily from JWT claims by craig-auth's middleware. Includes
attrs JSONB column for custom claims (per-deployment IdP mapping; e.g.,
unit-membership, region, etc.) used by JDM policy expressions in later
steps. No IdP admin API call ever; identity backend remains pluggable
across Keycloak/Auth0/Okta/AD/etc. via OIDC + JWKS only.

New endpoint GET /v1/security/workers?search= for BFF reassignment
autocomplete. Trigram indexes for fuzzy match. Rate-limited (60/min)
and minimum 2-char query length to bound enumeration. Workspace tests:
1696 → 1704 (+8).

Step 4: ADR-023/024/026/027 acceptance + craig-authz crate skeleton

Files:

  • docs/modules/ROOT/pages/adrs/adr-{023,024,026,027}.adoc — flip Proposed → Accepted (ADR-025 was already Accepted in Step 2)

  • crates/craig-authz/Cargo.toml — new (deps: zen-engine workspace, craig-auth, craig-common, craig-rules-client, async-trait, anyhow, serde, serde_json, tokio, tracing, strum, uuid, syn for the coverage-gate impl)

  • crates/craig-authz/src/lib.rs — public surface per §D3 (trait, types, re-exports)

  • crates/craig-authz/src/engine.rsZenAuthzEngine impl + validate_default_policy_coverage per §D18

  • crates/craig-authz/src/cache.rsRulesetCache per §D3

  • crates/craig-authz/src/types.rsResourceRef, Action, ResourceType, ListScope, CoverageWarning

  • crates/craig-authz/src/error.rs — Authz-specific error mapping to ApiError

  • crates/craig-authz/src/audit.rsemit_audit_event helper publishing authz.access_denied / authz.cache_miss / authz.cache_refreshed envelopes per §D14 schema

  • crates/craig-authz/tests/engine_unit.rs — L1 unit tests (~50-100 tests covering grammar shapes, scopes, fail-closed, cache miss, audit emission, missing-policy detection)

  • crates/craig-authz/tests/fixtures/ — minimal hand-crafted JDM ruleset fixtures for unit tests

  • xtask/src/cmd/validate_authz_coverage.rs — new xtask per §D17 (AST walker via syn; allowlist comments; markdown report)

  • xtask/src/cmd/mod.rs — add pub mod validate_authz_coverage;

  • xtask/src/cmd/validate.rs — call into validate_authz_coverage::run from existing validate command

  • xtask/tests/authz_coverage_test.rs — fixture cases (with-engine, with-allowlist, bypassed)

  • crates/craig-common/src/settings.rs — add authz_require_full_coverage: bool (default false) per §D18

  • Cargo.toml (workspace) — add craig-authz to members

  • .claude/docs/shared-crates.md — document craig-authz public API

Branch: feat/multi-juris-authz-step4-craig-authz-crate

MR title: feat(craig-authz): policy engine skeleton + CI authz-coverage gate + missing-policy detection [Step 4 of multi-juris-authz]

Verification:

  1. cargo build -p craig-authz — clean build

  2. cargo nextest run -p craig-authz — all L1 tests pass (~80 covering grammar/scope/fail-closed/audit-emission/missing-policy detection)

  3. cargo clippy -p craig-authz --all-targets --all-features — -D warnings — clean

  4. cargo xtask validate-authz-coverage — runs against current handlers; expect ~140 handlers reported, all marked "no engine wired yet (will be addressed in Steps 8-11)" — gate is non-failing on this MR but baseline established

  5. cargo xtask validate --skip-docker — pre-push green

CHANGELOG draft:

=== craig-authz crate skeleton (Step 4 of multi-juris-authz) (DATE)

ADRs 023/024/026/027 accepted. New crates/craig-authz crate ships
AuthzEngine trait, ZenAuthzEngine implementation wrapping zen-engine,
ResourceRef/Action/ResourceType/ListScope types, and ~80 L1 unit tests
covering JDM grammar shapes, scope outcomes, fail-closed branches, and
cache-miss behavior. No service integration yet — Steps 8-11 wire
handlers. Workspace tests: 1699 → ~1779 (+~80).

Step 5: RMQ cache invalidation + bulk-fetch endpoint

Files:

  • crates/craig-mq/src/lib.rs — define CACHE_INVALIDATIONS_EXCHANGE: &str = "craig.cache_invalidations"; declare in bootstrap

  • crates/craig-mq/src/subscriber.rssubscribe_exclusive already exists; verify or extend for cache-invalidation routing

  • services/craig-rules/src/api/sets.rs — admin endpoints (POST/PUT/DELETE) publish ruleset.changed { name, version } via publish_in_tx; ALSO publish authz.policy.changed per §D14 schema (when the changed ruleset name matches -authz-)

  • services/craig-rules/src/api/sets.rs — new GET /v1/rules/sets?prefix=&limit= endpoint (bulk-fetch)

  • crates/craig-cases-contracts/src/rules_sets.rs (or matching DTO crate) — ListRuleSetsQuery { prefix: Option<String>, limit: Option<u32> }

  • crates/craig-authz/src/cache.rs — wire RMQ inbox handler that invalidates entries on ruleset.changed.<name>; emits authz.cache_refreshed event per §D14; TTL refresh task with jitter

  • crates/craig-rules-client/src/lib.rs (new or extended) — list_by_prefix + get_by_name HTTP clients used by ZenAuthzEngine boot-load

  • crates/craig-authz/tests/cache_invalidation.rs — integration test: spawn in-memory mock craig-rules, push a ruleset.changed event, verify cache entry invalidated and authz.cache_refreshed event emitted

  • crates/craig-authz/tests/audit_emission.rs — integration test: trigger access_denied + cache_miss paths; verify audit_log row appears in craig-security’s table with correct envelope shape per §D14

  • crates/craig-common/src/settings.rs — add authz_policy_ttl_seconds: u64 field with default 3600

  • All 6 consuming services' src/main.rs — register cache-invalidation subscriber via subscribe_exclusive

Branch: feat/multi-juris-authz-step5-cache-invalidation

MR title: feat(craig-mq, craig-rules, craig-authz): RMQ cache invalidation + ruleset bulk-fetch endpoint [Step 5 of multi-juris-authz]

Verification:

  1. cargo xtask dev reseed

  2. cargo nextest run -p craig-authz — cache-invalidation tests pass

  3. cargo nextest run -p craig-rules --test api — bulk-fetch endpoint test passes

  4. Manual: edit a ruleset via craig-rules admin endpoint; verify all replicas of cases service refresh their cache within 1s (check authz.cache_refreshed log line)

  5. cargo xtask validate --skip-docker — pre-push green

CHANGELOG draft:

=== RMQ-driven authz cache invalidation (Step 5 of multi-juris-authz) (DATE)

craig-rules publishes ruleset.changed events on its outbox when admin
endpoints mutate rule sets. New craig.cache_invalidations topic exchange
fan-outs to all consuming-service replicas via subscribe_exclusive.
Local in-memory caches refresh on event; TTL fallback (1h default,
env-overridable) catches missed events. New bulk-fetch endpoint
GET /v1/rules/sets?prefix= for boot-time cache loading. Fail-closed
boot guard if bulk-fetch fails. Workspace tests: ~1779 → ~1789 (+~10).

Step 6: Default authz rulesets (Georgia + Texas)

Files:

  • rulesets/georgia/georgia-authz-case.json — new (per §D5 example)

  • rulesets/georgia/georgia-authz-{investigation,referral,report,placement,foster_home,payment,…​}.json — ~30 files for the resource-type set

  • rulesets/texas/texas-authz-*.json — same set, deliberately divergent per §D5

  • rulesets/georgia/georgia-icpc-policy.json — new (carries icpc_deadline_days: 84)

  • rulesets/texas/texas-icpc-policy.json — placeholder (Texas ICPC config TBD by Texas team; carry Georgia’s 84 as starter)

  • tools/craig-seed/src/cmd/seed_rulesets.rs (or equivalent existing path) — extend to seed the new authz rulesets if not already covered by the wildcard pattern

  • rulesets/README.md — document naming convention {jurisdiction}-authz-{resource} + the "curated example, replace before production" caveat

  • services/craig-rules/tests/api/authz_rulesets.rs — verify the seeded rulesets exist after cargo xtask dev reseed

Branch: feat/multi-juris-authz-step6-default-rulesets

MR title: feat(rulesets): default authz rulesets for Georgia + Texas [Step 6 of multi-juris-authz]

Verification:

  1. cargo xtask dev reseed

  2. glab api 'projects/…​/v1/rules/sets?prefix=georgia-authz-' — returns ~30 rulesets

  3. cargo nextest run -p craig-rules --test api authz_rulesets — passes

  4. cargo xtask validate --skip-docker — pre-push green

CHANGELOG draft:

=== Default authz rulesets — Georgia + Texas (Step 6 of multi-juris-authz) (DATE)

~60 JDM authz rulesets shipped under rulesets/{georgia,texas}/. Georgia
fixture: realm-flat supervisor, readonly = auditor. Texas fixture
deliberately divergent: case-scoped supervisor, readonly = PTO-caseworker,
custom regional_supervisor role with admin_unit-scoped scope. Both
prefaced "Curated CRAIG example. Replace before production deployment."
Acts as L3 regression fixture for upcoming engine-adoption steps.

Step 7: Cross-service denormalization

Files:

  • services/craig-placement/migrations/<TS>_assigned_worker_sub.sql — ADD column on placements/education_records/health_records/kinship_options/home_documents

  • services/craig-financial/migrations/<TS>_assigned_worker_sub.sql — ADD column on payments/payment_adjustments/claims

  • services/craig-exchange/migrations/<TS>_assigned_worker_sub.sql — ADD column on icpc_requests/icpc_home_studies/icpc_attachments

  • services/craig-{placement,financial,exchange}/src/store/models.rs — add assigned_worker_sub: Option<Uuid> field

  • xtask/src/cmd/backfill_cross_service_assignment.rs — new xtask following xtask/src/cmd/reconcile.rs precedent

  • services/craig-cases/src/events.rs — add publish_assignment_changed fn, called from create_case/update_case + create_investigation/update_investigation handlers (inside the transaction via publish_in_tx)

  • services/craig-{placement,financial,exchange}/src/main.rs — register inbox handler bound to case.assignment_changed (subscribe, not subscribe_exclusive — competing-consumer for at-least-once update semantics)

  • services/craig-{placement,financial,exchange}/src/inbox/case_assignment.rs — handler that UPDATEs all referencing rows

  • Per-service integration tests in services/craig-{placement,financial,exchange}/tests/api/cross_service_assignment.rs — verify denormalized column updates on event

Branch: feat/multi-juris-authz-step7-cross-service-denorm

MR title: feat(craig-placement, craig-financial, craig-exchange): denormalize assigned_worker_sub via case.assignment_changed events [Step 7 of multi-juris-authz]

Deploy ordering (within this MR, applied in sequence by the standard rolling deploy):

  1. ALTER ADD column migrations on all 11 tables (additive; safe to run before consumers exist).

  2. xtask backfill from cases service (idempotent; can run before or after consumers — backfill reads cases via reconciliation walker, no event dependency).

  3. Deploy consuming services (craig-placement, craig-financial, craig-exchange) first with inbox handlers registered. Inbox queues bind to case.# topic with routing key case.assignment_changed; messages start queueing if any are published, but won’t deadletter — at-least-once semantics + idempotent UPDATE statements absorb duplicates.

  4. Deploy craig-cases LAST with the new event publisher. From this point, every assigned_worker mutation publishes an event that consumers process within outbox-poll-cadence (~5s).

  5. Backfill is idempotent against any drift from events that fired during the deploy window.

If consumers are deployed AFTER the cases publisher, no data is lost (RabbitMQ holds events in queue), but the propagation is delayed — backfill reconciles. The ordering above is the minimum-lag shape.

Rollback: if Step 7 needs revert, reverse-deploy: take cases publisher down first; let consumer queues drain; revert consumers; revert migrations (DROP column). Backfill xtask is re-runnable.

Verification:

  1. cargo xtask dev reseed

  2. cargo xtask backfill-cross-service-assignment — populates new columns from cases service

  3. cargo nextest run --workspace — cross-service assignment tests pass

  4. Manual: change a case’s assigned_worker via PUT /v1/cases/cases/{id}; verify referenced placements/payments/icpc_requests update their assigned_worker_sub within ~5s (poll DB; check inbox-handler log lines for case.assignment_changed processed for case_id=…​)

  5. cargo xtask validate --skip-docker — pre-push green

CHANGELOG draft:

=== Cross-service denormalization for record-level authz (Step 7 of multi-juris-authz) (DATE)

11 tables across placement/financial/exchange gain assigned_worker_sub
UUID column for local authz evaluation without cross-service joins.
xtask backfill from cases service. cases service publishes
case.assignment_changed events on outbox; consuming services have inbox
handlers updating rows. Eventually-consistent window (~5s typical) fails
closed in conservative direction. Workspace tests: ~1789 → ~1799 (+~10).

Step 8: Cases service engine adoption

Files:

  • services/craig-cases/src/api/{cases,investigations,referrals,reports,contacts,court_orders,case_plans,case_plan_tasks,persons,report_persons,report_attachments,contact_attachments,screening_decisions,disposition_follow_ups}.rs — replace role checks with authz.check(…​) (single-row) or authz.auto_scope_list(…​) (LIST). ~57 sites.

  • services/craig-cases/src/api/cases.rs::batch_lookup — adopt bulk-operation pattern per §D15: per-row engine.check() with drop-on-deny; BatchLookupResponse gains unauthorized_count + partial_auth fields

  • crates/craig-cases-contracts/src/cases.rs::BatchLookupResponse — DTO change per §D15

  • services/craig-cases/src/main.rs — register craig_authz::ZenAuthzEngine in axum extensions

  • services/craig-cases/src/store/cases.rs — new list_with_scope fn per §D7

  • Same pattern for investigations.rs, referrals.rs, etc. — ~14 store fns gain _with_scope variants

  • services/craig-cases/tests/api/authz_smoke.rs — generated tests via #[authz_smoke_test] macro (L2)

  • services/craig-cases/tests/api/authz_fixture_regression.rs — generated tests via macro reading rulesets/{georgia,texas}/georgia-authz-*.json (L3)

  • services/craig-cases/tests/api/batch_lookup_authz.rs — new integration test: POST batch with mixed authorized/unauthorized IDs; assert partial_auth: true + unauthorized_count > 0 + filtered cases dict

  • crates/craig-authz/src/test_macros.rs — proc macros for L2/L3 generation

  • .claude/docs/services.md — document new authz integration on cases (incl. bulk-lookup behavior)

Branch: feat/multi-juris-authz-step8-cases-adoption

MR title: feat(craig-cases): wire AuthzEngine into ~57 handlers + L2/L3 test coverage [Step 8 of multi-juris-authz]

Verification:

  1. cargo xtask dev reseed

  2. cargo nextest run -p craig-cases --tests — all existing + new tests pass

  3. Manual: log in as bob.smith (caseworker, sub 00000000-…​-000000000002); GET /v1/cases/cases/{id} where id is a case assigned to jane.doe — expect 403. GET on a case assigned to bob — expect 200.

  4. Manual: log in as admin; GET any case — expect 200.

  5. cargo xtask validate --skip-docker — pre-push green

CHANGELOG draft:

=== Cases service authz adoption (Step 8 of multi-juris-authz) (DATE)

~57 handlers in craig-cases now use authz.check() / auto_scope_list()
instead of role-only checks. Record-level authorization enforced for
GET-by-id (predicate) + LIST (auto-scope SQL filter). Soft-delete:
predicate first; authorized callers see deleted rows. ~80 new tests
across L2 (handler smoke, generated via macro) + L3 (fixture-driven
regression for Georgia + Texas). Workspace tests: ~1799 → ~1879 (+~80).

Step 9: Placement + exchange engine adoption

Files:

  • services/craig-placement/src/api/*.rs — ~20 handler sites

  • services/craig-exchange/src/api/*.rs — ~22 handler sites (incl. ICPC)

  • services/craig-{placement,exchange}/src/main.rs — register engine

  • services/craig-{placement,exchange}/src/store/*.rs_with_scope store fns

  • services/craig-{placement,exchange}/tests/api/authz_{smoke,fixture}.rs — generated tests

Branch: feat/multi-juris-authz-step9-placement-exchange-adoption

MR title: feat(craig-placement, craig-exchange): wire AuthzEngine into ~42 handlers [Step 9 of multi-juris-authz]

Verification:

  1. cargo xtask dev reseed

  2. cargo nextest run -p craig-placement -p craig-exchange --tests

  3. cargo xtask validate --skip-docker

CHANGELOG draft:

=== Placement + exchange authz adoption (Step 9 of multi-juris-authz) (DATE)

~42 handlers in craig-placement + craig-exchange now use the AuthzEngine.
ICPC handlers (8) use the icpc_coordinator role; transactions (4) use
supervisor; agreements/partners (12) admin-only — all routed through
the engine for jurisdiction-aware policy lookup. Workspace tests:
~1879 → ~1939 (+~60).

Step 10: Financial + reporting engine adoption

Files:

  • services/craig-financial/src/api/*.rs — ~13 handler sites

  • services/craig-reporting/src/api/*.rs — ~15 handler sites

  • services/craig-{financial,reporting}/src/main.rs — register engine

  • services/craig-{financial,reporting}/src/store/*.rs_with_scope store fns

  • services/craig-{financial,reporting}/tests/api/authz_{smoke,fixture}.rs — generated tests

Branch: feat/multi-juris-authz-step10-financial-reporting-adoption

MR title: feat(craig-financial, craig-reporting): wire AuthzEngine into ~28 handlers [Step 10 of multi-juris-authz]

Verification:

  1. cargo xtask dev reseed

  2. cargo nextest run -p craig-financial -p craig-reporting --tests

  3. cargo xtask validate --skip-docker

CHANGELOG draft:

=== Financial + reporting authz adoption (Step 10 of multi-juris-authz) (DATE)

~28 handlers in craig-financial + craig-reporting now use the AuthzEngine.
Payment approval + adjustment approval workflows route through the engine's
Approve action. AFCARS/NCANDS submissions scoped per jurisdiction. Workspace
tests: ~1939 → ~1979 (+~40).

Step 11: Security + rules engine adoption

Files:

  • services/craig-security/src/api/*.rs — ~10 handler sites (most admin-only)

  • services/craig-rules/src/api/*.rs — ~2 handler sites

  • services/craig-{security,rules}/src/main.rs — register engine

  • services/craig-{security,rules}/tests/api/authz_{smoke,fixture}.rs — generated tests

  • xtask/src/cmd/validate_authz_coverage.rs — flip from soft-warn to hard-fail mode (handlers that lack engine wiring outside the allowlist now fail the gate)

  • xtask/src/cmd/validate.rs — make validate-authz-coverage a required step (no longer skippable)

Branch: feat/multi-juris-authz-step11-security-rules-adoption

MR title: feat(craig-security, craig-rules): wire AuthzEngine into ~12 handlers + flip authz-coverage gate to hard-fail [Step 11 of multi-juris-authz]

Verification:

  1. cargo xtask dev reseed

  2. cargo nextest run -p craig-security -p craig-rules --tests

  3. cargo xtask validate-authz-coverage — now hard-fails if any handler lacks engine wiring; expect zero failures across all 7 stateful services

  4. cargo xtask validate --skip-docker — pre-push green (now includes hard-fail gate)

CHANGELOG draft:

=== Security + rules authz adoption (Step 11 of multi-juris-authz) (DATE)

~12 handlers in craig-security + craig-rules now use the AuthzEngine.
Most security handlers are admin-only and route via a uniform admin
policy. Closes the BOLA/IDOR surface across all 7 stateful services
(~140 sites total across Steps 8-11). Workspace tests: ~1979 → ~1999 (+~20).

Step 12: Jurisdiction-readiness Phase A — defaults removal

Files:

  • crates/craig-common/src/settings.rs:124 — delete default_jurisdiction(); field becomes required

  • services/craig-intake/src/config.rs:270 — same

  • services/craig-web/src/config.rs:61, 65, 67 — delete default_theme(), default_branding_agency(), default_admin_unit_label()

  • services/craig-{cases,placement,exchange,financial,reporting,security,rules}/src/main.rs — boot-guard messages clearer if env vars missing

  • docker-compose.yml — set all required env vars explicitly

  • .env.example — same

  • docs/modules/ROOT/pages/deployment-guide.adoc — document required env vars per service

  • .gitlab-ci.yml — CI overrides updated

Branch: feat/multi-juris-authz-step12-defaults-removal

MR title: feat(craig-common, craig-intake, craig-web): remove Georgia hardcoded defaults; required env vars [Step 12 of multi-juris-authz]

Verification:

  1. Manual: unset CRAIG_CASES__JURISDICTION in a test container; verify boot fails with clear message

  2. cargo xtask dev reseed — devstack still works (env vars set)

  3. cargo nextest run --workspace

  4. cargo xtask validate --skip-docker

CHANGELOG draft:

=== Jurisdiction-readiness Phase A: defaults removal (Step 12 of multi-juris-authz) (DATE)

BREAKING (devstack only): default_jurisdiction(), default_theme(),
default_branding_agency(), default_admin_unit_label() removed. Each
becomes a required env var; service bails at boot if unset. Devstack
.env.example and docker-compose.yml updated; production deployments
must set all required vars explicitly. Removes Georgia as a silent
fallback for non-Georgia adopters.

Step 13: Jurisdiction-readiness Phase B — admin units + i18n + operational constants

Files:

  • crates/craig-reference/src/fips.rs — deprecate admin_units_for_state; replace with admin_units_for_jurisdiction(pool, jurisdiction) reading from DB

  • default_admin_units/georgia.csv — new (159 rows extracted from GEORGIA_COUNTIES)

  • default_admin_units/texas.csv — new (Texas counties; closes #225)

  • xtask/src/cmd/seed_admin_units.rs — new

  • services/craig-security/migrations/<TS>_seed_default_admin_units.sql — auto-seed for known jurisdictions on first install

  • services/craig-intake/static/report.html:82i18n key="report-form-admin-unit-label"

  • services/craig-intake/static/report.html:211i18n key="report-error-admin-unit-required"

  • services/craig-intake/locales/en/public.ftl — add new keys

  • services/craig-web/templates/intake/report_detail.html:253 — replace static dropdown with template loop fetching from screening-policy ruleset metadata

  • services/craig-web/locales/en/web.ftl:82, 207, 471 — "Fulton" placeholders → generic

  • services/craig-web/locales/en/public.ftl:63, 111 — generic

  • services/craig-exchange/src/api/icpc.rs:264 — read icpc_deadline_days from {jurisdiction}-icpc-policy ruleset metadata

  • rulesets/{georgia,texas}/{j}-icpc-policy.json — already shipped in Step 6; verify metadata field present

  • services/craig-financial/src/main.rs:148, 173 — replace calendar-month logic with PaymentPeriodConfig from settings

  • crates/craig-common/src/settings.rs — add payment_period: PaymentPeriodConfig struct

  • services/craig-web/src/routes/cases/list.rs and similar — stop sending ?worker= query param to backend (engine determines scope)

Branch: feat/multi-juris-authz-step13-admin-units-and-i18n

MR title: feat(craig-reference, craig-web, craig-intake, craig-exchange, craig-financial): externalize Georgia counties + BFF i18n hardcodes + operational constants [Step 13 of multi-juris-authz]

Verification:

  1. cargo xtask dev reseed — Georgia + Texas counties seeded

  2. cargo nextest run --workspace

  3. Manual: visit /intake/report in BFF; verify "County" label is FTL-keyed; verify actor_role dropdown reflects screening-policy metadata

  4. cargo xtask e2e — full E2E suite passes

  5. cargo xtask validate --skip-docker

CHANGELOG draft:

=== Jurisdiction-readiness Phase B: admin units + i18n + operational constants (Step 13 of multi-juris-authz) (DATE)

GEORGIA_COUNTIES constant (159 rows) externalized from craig-reference
to admin_unit_registry table. New xtask seed-admin-units. Texas
counties seeded (closes #225). BFF/intake i18n hardcodes (5 P0 items
from 2026-05-07 audit) extracted to FTL keys; actor_role dropdown
fetches from screening-policy ruleset metadata at render time.
ICPC 84-day deadline → ruleset metadata. Payment-period boundaries →
jurisdiction-config-driven (calendar/fiscal/iso-week). BFF stops
sending ?worker= query param (engine determines scope post-Step 8).

Step 14: Plan completion audit + archive

Files:

  • docs/modules/ROOT/pages/plans/multi-jurisdictional-authz.adoc — Status table → all Complete

  • docs/modules/ROOT/nav.adoc — Active → next plan or "(none)"

  • docs/modules/ROOT/pages/plans/archive.adoc — new row under Security & Compliance with all step MR numbers

  • .claude/CLAUDE.md — Phase Status final stats; new row "Multi-Jurisdictional Authorization"

  • CHANGELOG.adoc — wrap-up entry

Branch: chore/multi-juris-authz-step14-archive

MR title: chore: Multi-Jurisdictional Authorization plan completion + archive [Step 14 of multi-juris-authz]

Verification:

  1. Spawn plan-completion-audit subagent per delivery-protocol.md

  2. Verify all 13 prior steps complete via MR list

  3. glab issue list --milestone "…​" shows zero multi-juris-authz issues open

  4. glab epic view <N> state == closed

  5. cargo xtask check-docs — Tier 1 docs clean

CHANGELOG draft:

=== Multi-Jurisdictional Authorization plan completion + archive [Step 14 of multi-juris-authz] (DATE)

Plan completion. ~140 handler sites now enforce record-level authz via
zen-engine policy DSL; 11 cross-service tables denormalized; identity
model normalized to claims.sub UUID; IdP-neutral worker_identities
table populated lazily from JWT claims; Georgia hardcoded defaults
removed; admin units externalized; BFF/intake i18n hardcoded items
fixed. CRAIG is now genuinely multi-jurisdictional: states/tribes/
counties onboard via rulesets + CSVs + env vars, no code changes.
Plan B (pii-and-partner-edge-hardening) safe to execute. Closes epic.

Files Touched (aggregate)

File Change

docs/modules/ROOT/pages/plans/multi-jurisdictional-authz.adoc

Step 1 — new plan file

docs/modules/ROOT/pages/adrs/adr-{024..028}.adoc

Step 1 — 5 new ADRs

services/craig-cases/migrations/<TS>_assigned_worker_uuid.sql

Step 2 — destructive ALTER

services/craig-cases/src/{api,store}/{cases,investigations}.rs

Step 2 — handler/store updates

crates/craig-cases-contracts/src/{cases,investigations}.rs

Step 2 — DTO type changes

services/craig-security/migrations/<TS>_worker_identities.sql

Step 3 — new table

crates/craig-auth/src/middleware.rs

Step 3 — JWT-claim upsert hook

services/craig-security/src/api/workers.rs

Step 3 — autocomplete endpoint

crates/craig-authz/{Cargo.toml,src/*}

Step 4 — new crate (engine, cache, audit emission, missing-policy detection)

xtask/src/cmd/validate_authz_coverage.rs

Step 4 — new CI gate (soft-warn); Step 11 — flip to hard-fail

crates/craig-mq/src/{lib,subscriber}.rs

Step 5 — cache_invalidations exchange

services/craig-rules/src/api/sets.rs

Step 5 — bulk-fetch endpoint + outbox publishing

rulesets/{georgia,texas}/{j}-authz-*.json

Step 6 — ~60 default rulesets

services/{placement,financial,exchange}/migrations/<TS>_assigned_worker_sub.sql

Step 7 — denormalize column

services/craig-cases/src/events.rs

Step 7 — assignment_changed publisher

Per-service src/api/*.rs across 7 stateful services

Steps 8-11 — engine wiring at ~140 sites

crates/craig-common/src/settings.rs, services/craig-{intake,web}/src/config.rs

Step 12 — defaults removal

crates/craig-reference/src/fips.rs, default_admin_units/{georgia,texas}.csv, xtask/src/cmd/seed_admin_units.rs

Step 13 — admin-unit externalization

services/craig-{intake,web}/static/, services/craig-{intake,web}/locales/en/.ftl

Step 13 — i18n hardcodes

services/craig-exchange/src/api/icpc.rs, services/craig-financial/src/{main,settings}.rs

Step 13 — operational constants

Verification (overall)

After every step:

  1. cargo xtask validate --skip-docker — pre-push gate green

  2. cargo xtask dev reseed — required after schema changes (Steps 2, 3, 7, 12)

  3. cargo nextest run --workspace — workspace tests green

  4. cargo xtask check-docs — Tier 1 docs clean

  5. cargo deny check — no new advisory regressions

After Step 13 (plan-wide):

  1. Authorization integration matrix: per-handler test that for each (jurisdiction, role, expected outcome) the engine enforces correctly. Generated via test-harness macro (Step 8’s L2/L3 macros applied across services).

  2. Cross-jurisdiction isolation: spawn two craig-cases instances with JURISDICTION=georgia and JURISDICTION=texas; confirm a Georgia caseworker JWT and a Texas caseworker JWT see different scope outcomes per their default policies.

  3. Replication convergence: admin-edit a ruleset via craig-security admin endpoint; verify all replicas refresh within 5s (RMQ event) or within TTL (1h fallback).

  4. Default policy sufficiency: cargo xtask dev reseed followed by full E2E suite — no test fails due to "missing policy" 403s.

  5. BOLA regression: per-handler "non-assigned caseworker is denied" + "supervisor sees scope per policy" + "admin always allowed" tests. ~280-340 across L1/L2/L3.

  6. Manual emergency-revocation walkthrough: revoke a user’s role at the IdP; verify next JWT refresh denies them on subsequent requests (this is IdP behavior, not policy-engine, but worth verifying as part of operational runbook).

Documentation Updates

  • Per-step CHANGELOG entries

  • .claude/docs/security.md — record-level authz model, IdP-neutral identity layer, fail-closed engine semantics (after Step 4 / Step 11)

  • .claude/docs/services.mdworker_identities table; new admin endpoints; denormalized assigned_worker_sub on 11 cross-service tables; new authz integration per service

  • .claude/docs/shared-crates.md — craig-authz public API surface

  • docs/modules/ROOT/pages/architecture.md — multi-jurisdictional authz section

  • docs/modules/ROOT/pages/deployment-guide.adoc — required env vars after defaults removal; jurisdiction onboarding playbook

  • docs/modules/ROOT/pages/jurisdiction-onboarding.adoc (new) — step-by-step guide for adopting CRAIG as a new jurisdiction (rulesets, default policies, admin-unit CSV, branding, env vars)

  • ADRs 024, 025, 026, 027, 028

  • Step 14 archive entry in archive.adoc

  • .claude/CLAUDE.md Phase Status row added

Errata

Step-by-step deviations from the original plan that landed in their respective MRs. Each entry cites the file:line that changed and the reason — so future readers can trace the decision back to the work that surfaced it.

Step 13b deviations (2026-05-10)

Step 13b ships the i18n + operational-constants tracks deferred from Step 13a.

  • icpc_deadline_days via settings, not ruleset metadata: the plan body called for reading the 84-day deadline from {jurisdiction}-icpc-policy ruleset metadata, but no such ruleset exists (Step 6 only shipped authz rulesets). Added icpc_deadline_days: i64 to ServiceSettings instead — same env-var driven posture as admin_unit_label, single boot-time read, no per-request ruleset evaluation overhead. A future MR can move it to ruleset metadata if jurisdiction-specific authz rules need to read it; right now it’s a single boot constant.

  • PaymentPeriod enum: 3 variants — CalendarMonth (default), FiscalMonth (currently identical to CalendarMonth since federal fiscal-month boundaries align with calendar boundaries), IsoWeek (Monday-anchored 7-day windows). New compute_period(today, payment_period) helper in services/craig-financial/src/main.rs replaces the inline with_day(1) + end_of_month shape.

  • AuthzContext bundle for too-many-args at icpc::update_icpc_request: adding the IcpcDeadlineDays extension pushed the handler past clippy’s 7-arg threshold. Replaced the separate authz + jurisdiction extensions with the existing AuthzContext bundle (same Step 9 pattern). The handler reads authz_ctx.engine + authz_ctx.jurisdiction thereafter.

  • BFF ?worker= cleanup — UI state preserved: dropped the redundant caseworker-default-self path that round-tripped claims.sub to the backend (engine handles it post Step 8). Kept the supervisor-explicit-filter path (?worker=<sub>) since that’s a genuine "view as worker X" feature not covered by engine scope. UI toggle state computed separately so templates render the correct selection.

  • Intake i18n: HTML genericization, not Fluent integration: craig-intake doesn’t host an i18n stack (no FTL files, no fluent-rs dep). Step 13b genericizes the inline strings ("County" → "Administrative Unit", "e.g. fulton" → "e.g. your county or region") rather than retrofitting a full Fluent i18n stack. Full intake-side i18n is a separate plan-level concern; Step 13b removes the Georgia-specific copy.

  • Fluent placeholder strings in craig-web: the 5 P0 i18n items the 2026-05-07 audit flagged were Fulton-specific example placeholders. Step 13b replaces them with jurisdiction-neutral examples in the existing FTL keys; no new keys required.

Step 13a deviations (2026-05-10)

Step 13’s plan body (Phase B) bundles three independent tracks: admin-unit externalization, BFF/intake i18n FTL extraction, and operational-constant externalization (ICPC deadline + payment-period). Step 13a ships the admin-unit track only; tracks two and three split out to a Step 13b follow-up MR (per the same Step-8/8b pattern from earlier in this plan).

  • CSV-as-single-source-of-truth: rather than the plan body’s admin_units_for_jurisdiction(pool, jurisdiction) async function in crates/craig-reference/src/fips.rs, Step 13a keeps the existing static GEORGIA_COUNTIES const for sync callers (validation.rs::validate_admin_unit, translate.rs::admin_unit_to_fips) and adds CSV files (default_admin_units/{georgia,texas}.csv) as the canonical source for both the production xtask seed path AND the devstack seeder. craig-reference stays sqlx-free; the DB-driven path lives in craig-security where it already does (store::admin_units::list_units since Step 6).

  • Texas counties: 254 entries seeded via default_admin_units/texas.csv (closes #225). Not embedded as a static const in fips.rs — keeps the file from ballooning to ~3000 lines and avoids duplicating the CSV.

  • xtask seed-admin-units: idempotent UPSERT keyed on the existing (name, jurisdiction) WHERE active = true partial unique index. RETURNING (xmax = 0) reports per-row inserted/updated/skipped so operators can dry-run-equivalent re-runs.

  • craig-seed via include_str!: instead of leaving the seeder’s existing admin_units_for_state(State::Georgia) path untouched and adding Texas separately, Step 13a replaces it with a CSV-driven loop reading both jurisdictions via include_str!. Single source of truth between devstack reseed and the xtask path. Required Dockerfile change: COPY default_admin_units/ default_admin_units/ so the macro resolves at container-build time.

  • No new craig-security migration: the plan body called for <TS>_seed_default_admin_units.sql to auto-seed at install. Step 13a keeps the seeder + xtask paths instead — both are repeatable and idempotent, and a SQL migration that bakes 413 INSERT statements (159 + 254) is harder to evolve than the CSVs. Production deployments run cargo xtask seed-admin-units once at install.

  • Step 13b deferred scope (split for review tractability): BFF/intake i18n FTL extraction (5 P0 items from 2026-05-07 audit), ICPC 84-day deadline → ruleset metadata, payment-period config struct in crates/craig-common/src/settings.rs + craig-financial wiring, BFF ?worker= query param removal post Step 8 engine-driven scope.

Step 11 deviations (2026-05-10)

Step 11 wires the engine into 60 handlers: craig-security (49 across 9 modules) + craig-rules (11). Plan body estimated ~28; actual is more than 2× because craig-security is broader than the plan section anticipated (admin units 9 + archive 3 + audit 3 + detection 8 + nist 5 + partners 9 + reviews 5 + signer_keys 5 + workers 1) and craig-rules has 11 endpoints (rule sets CRUD + import/export, evaluation, evaluation list).

  • Per-service bootstrap: services/craig-security/src/authz_bootstrap.rs mirrors Step 9/10 (transitional ROPC; Plan E retires).

  • DB-source for craig-rules: instead of HTTP self-loopback (http://craig-rules:8001), craig-rules' authz engine reads rule_sets directly from its own Postgres pool via a new services/craig-rules/src/authz_db_source.rs::DbRulesetSource. HTTP self-loopback raced the still-starting axum listener at boot AND recursed every authz check through the same handler chain (each engine.check for RuleSet triggered an HTTP call to the same service which itself called engine.check). The DB source is bootstrap-correct, latency-free, and architecturally honest about craig-rules being the source of truth for its own data. Removed the now-redundant services/craig-rules/src/authz_bootstrap.rs module.

  • Cold-start hardening in craig-authz: three layers of self-healing close the bootstrap window where rulesets exist in DB but the engine cache hasn’t yet seen them. (1) ZenAuthzEngine::boot retries the bulk-fetch up to 5× with exponential backoff (250ms → 4s, ~7.75s budget). (2) New lazy_load helper in check + auto_scope_list: on cache miss, attempts a single refresh_entry round-trip before failing closed. (3) New spawn_post_boot_warmup task: after axum binds, retries bulk_refresh 20× × 1s waiting for the seeder to populate rulesets. Wired into all 7 authz-using services.

  • depends_on: craig-rules added in compose: craig-cases, craig-placement, craig-exchange, craig-financial, craig-reporting, craig-security all gain depends_on: { craig-rules: { condition: service_healthy } } so their ROPC bootstrap doesn’t connection-refuse out the gate. craig-rules itself can’t depend on itself; the DB-source above sidesteps the question.

  • 26 admin-only rulesets created: Step 6 deliberately excluded the admin-internal ResourceTypes (admin_unit, major_change, audit_event, security_archive, security_alert, detection_rule, nist_control, partner, partner_api_key, partner_signer_key, security_review, rule_set, rule_evaluation) — 13 per jurisdiction × 2. Step 11 ships them, sourced from the Step-10-refined claim template. rule_set allows caseworker read+list (rule sets are referenced in case workflows); rule_evaluation allows caseworker create+read (case workflows trigger person-match etc. evaluations). 1 additional ruleset (worker_identity) sourced from the foster_home template (caseworker list scope=all, supporting the autocomplete-style UI on case assignment).

  • Shared admin-fallback helpers in craig-security: services/craig-security/src/api/mod.rs::authz_check_or_admin_fallback + authz_list_or_admin_fallback collapse the engine.check + admin-role-fallback pattern at every site. Wired across all 9 security modules. craig-rules has the equivalent rules_authz_or_admin_fallback plus the same per-handler admin-role bypass on ListScope::Denied.

  • AuthzContext bundle for too-many-args: update_rule_set + import_rule_set in craig-rules use Extension<AuthzContext> to keep the per-handler arg count under clippy’s 7-arg threshold without #[allow]. Established Step 9 pattern.

  • list_rule_sets / get_rule_set / get_rule_set_by_name now require authz: pre-Step-11 these were authenticated-only (any role). The engine path now requires admin/supervisor (default ruleset) — but rule_set policy explicitly allows caseworker read+list since rule sets are referenced in case workflows.

  • Existing role-gates removed: handlers no longer call require_admin() / require_supervisor_or_above() / require_caseworker_or_above() / require_role(). 60 handlers across 10 modules.

Step 10 deviations (2026-05-10)

Step 10 wires the engine into 34 handlers: craig-financial (18 across payments, adjustments, claims, rates) + craig-reporting (16 across afcars, ncands, quality). Plan body estimated ~28; actual is higher because rate-table CRUD (5) + AFCARS workflow (8 incl. export/download) carry more endpoints than the section anticipated.

  • Per-service bootstrap: services/craig-{financial,reporting}/src/authz_bootstrap.rs mirrors Step 9 (transitional ROPC; Plan E retires).

  • Payment scope filter: Payment carries assigned_worker_sub (Step 7 denorm). ListPaymentsParams + CountPaymentsParams gain assigned_worker_sub: Option<Uuid>; SQL filters on the denorm column. PaymentAdjustment also has the denorm but its list endpoint is small-N so Step 10 maps AssignedWorker scope to "empty page" rather than threading the column through the query (consistent with Step 9 KinshipOption deferral).

  • Ruleset refinements (12 files: 6 admin-only resources × 2 jurisdictions): dropped caseworker rules entirely from Claim, RateTable, AfcarsSubmission, NcandsSubmission, QualityReview, DataQualityIssue. These are admin/supervisor-managed reference data + reporting submissions; caseworker → default deny → 403.

  • Action::Approve mapping: approve_payment, approve_adjustment, approve_afcars, approve_ncands, submit_claim, transmit_afcars, transmit_ncands use Action::Approve. Submit and transmit are state-promoting operations on already-reviewed submissions; semantically Approve.

  • Early authz on resolve_issue: DataQualityIssue policy gates on role + jurisdiction (no per-row attrs); the resolve_issue handler does the engine check with a skeleton ResourceRef before the row read so the existing 404-on-missing semantic is preserved for authorized callers (mirrors Step 9 retry_transaction).

  • Existing role-gates removed: handlers no longer call require_eligibility_worker_or_above() / require_supervisor_or_above() / require_admin() / require_caseworker_or_above() — engine.check() / auto_scope_list() are the entire access decision. 34 handlers across 7 modules.

Step 9 deviations (2026-05-09)

Step 9 wires the engine into 59 handlers across craig-placement (35 handlers in 7 modules) + craig-exchange (24 handlers in 4 modules). Plan body estimated ~42; actual is higher because health.rs is a CCWIS health-records module (6 handlers — not a /health liveness probe) and foster_homes.rs includes 9 training-management handlers in addition to home CRUD.

  • Per-service bootstrap: services/craig-{placement,exchange}/src/authz_bootstrap.rs ships the same transitional ROPC pattern Step 8 introduced for craig-cases. docker-compose.yml adds CRAIG_PLACEMENTAUTHZ_BOOTSTRAP_* + CRAIG_EXCHANGEAUTHZ_BOOTSTRAP_*. Plan E retires all three modules together.

  • ResourceType vocabulary: 7 placement types (Placement, KinshipOption, FosterHome, HomeDocument, EducationRecord, HealthRecord — and matching uses FosterHome::Read) + 4 exchange types (ExchangeTransaction, ExchangePartner, ExchangeAgreement, IcpcRequest — IcpcHomeStudy + IcpcAttachment are children of IcpcRequest). All already covered by the 70-file ruleset corpus from Step 6.

  • Scope filter — Placement only: the Placement row carries assigned_worker_sub (Step 7 denorm), so list_placements translates ListScope::AssignedWorker(sub) into a SQL filter via a new assigned_worker_sub: Option<Uuid> column on ListPlacementsParams + count_placements. Other resource types (FosterHome, HomeDocument, EducationRecord, HealthRecord, ExchangeTransaction, ExchangePartner, ExchangeAgreement, IcpcRequest) lack the denorm column; non-admin scopes map to "empty page" rather than a SQL filter.

  • KinshipOption post-filter: KinshipOption has assigned_worker_sub (Step 7 denorm) but its existing list_kinship_options SQL takes only case_id — Step 9 post-filters in the handler rather than threading another optional param through, since kinship lists are bounded by case (small N).

  • Action::Update for child-resource deletes: delete_kinship_option, delete_home_document use Action::Update on the parent (case for kinship; foster home for home_document) rather than Action::Delete — same pattern as Step 8b’s case-scoped child deletes (deleting a kinship option is a case mutation, not a case deletion).

  • FosterHome / HomeDocument caseworker access: Georgia’s default rulesets allow caseworker read/update on assigned foster homes via claims.sub == resource.assigned_worker_sub. FosterHome has no assigned_worker denorm (Step 7 deviation: foster homes are licensable provider entities, not case-assigned), so the predicate evaluates false and caseworkers fall through to default deny. Practical effect: caseworkers currently cannot view foster homes via the engine path. Documented as a Step 12 (Phase A defaults removal) follow-up — the right fix is to either add a foster-home→assigned-worker linkage table or refine the ruleset so caseworker FosterHome::Read is admin-unit-scoped rather than worker-scoped.

  • EducationRecord / HealthRecord child→case lookup deferred: same root cause as foster_home but with a Step 7-deferred remedy. child_id-keyed records can be in multiple cases simultaneously; the right fix is a child→primary-case lookup at authz-eval time. Step 9 leaves assigned_worker_sub: None so caseworker access goes to default-deny; admin/supervisor/readonly remain functional. Lookup work deferred to a follow-up.

  • ICPC child resources: home_study + attachment authz checks operate on the parent IcpcRequest (their id parameter is the IcpcRequest id). submit_home_study uses Action::Update; get_home_study / list_attachments / download_attachment use Action::Read; upload_attachment uses Action::Update.

  • No new test crate: Step 8 / 8b ship hand-written L2 smoke tests. Step 9 follows the same pattern; Step 11+ revisits proc-macro test generation.

  • Existing role-gates removed: handlers no longer call require_caseworker_or_above() / require_supervisor_or_above() / require_admin() / require_icpc_coordinator_or_above() — engine.check() / auto_scope_list() are the entire access decision. 59 handlers across 11 modules.

Step 8b deviations (2026-05-09)

Step 8b is the continuation MR Step 8’s deviations promised. Wires the Plan A engine pattern into 4 additional services/craig-cases/src/api/* modules covering 26 handlers:

  • investigations.rs (5 handlers — create, list, get, update, submit_safety_assessment) — parent resource with its own assigned_worker. Uses ResourceType::Investigation. List path uses auto_scope_list (returns empty page for AssignedSupervisor — Investigation has no supervisor field; documented in handler).

  • contacts.rs (5 handlers — create, list, get, update, delete) — case-scoped child. All authz routes through parent case_resource_ref.

  • court_orders.rs (7 handlers — create, list, upload_document, download_document, get, update, delete) — case-scoped child. upload_document + download_document authz on parent case.

  • case_plans.rs (9 handlers — create_case_plan, list_case_plans, update_case_plan, approve_case_plan, create_task, update_task, list_tasks, get_task, delete_task) — case-scoped child + tasks scoped via plan→case chain. approve_case_plan uses Action::Approve.

Helper visibility lifted: services/craig-cases/src/api/cases.rs::case_resource_ref raised from private to pub(super) so sibling modules build the parent ResourceRef without duplicating field coercion. Single source of truth for attrs shape across all case-scoped child handlers.

Local Jurisdiction newtype removed: Step 8 left a duplicate pub struct Jurisdiction(pub String) in services/craig-cases/src/api/mod.rs. Step 8b replaces it with pub use craig_authz::Jurisdiction so handlers and engine.check(…​) use the same type. Pre-Step-8b the local type compiled but would have caused engine misses at runtime if any test path mixed the two.

Existing role-gates removed across the 4 modules: handlers no longer call claims.require_caseworker_or_above() / require_supervisor_or_above(). Engine.check() / auto_scope_list() are the entire access decision — role logic lives inside JDM rulesets via 'admin' in claims.roles, etc. Same posture as Step 8.

Step 8c — deferred handler modules (planned)

Step 8b defers 6 modules to a continuation Step 8c MR. Reasoning: each carries domain-specific complexity that warrants per-module review rather than templated wiring:

  • services/craig-cases/src/api/referrals.rs — intake-side (entry point from craig-intake forwarding). Authz semantics differ from case-assigned model: pre-screening posture; no assigned_worker until referral-to-investigation conversion. Needs separate ResourceType::Referral policy design.

  • services/craig-cases/src/api/reports.rs — partner-submitted reports with PII encryption. Authz interacts with intake-source authentication (anonymous public form vs. authenticated partner) + the existing report→person link audit trail (ADR-019).

  • services/craig-cases/src/api/persons.rs — PII-encrypted records (ssn_last_four, etc. — platform-stab-2 §D9). Authz query interacts with the blind-index search path; needs careful design to avoid plaintext cache leaks.

  • services/craig-cases/src/api/report_persons.rs — ADR-019 report-person linking (suggestions / link / unlink). Authz on parent report (when wired in 8c) + secondary check on the linked person (when 8c does persons).

  • services/craig-cases/src/api/report_attachments.rs — report-scoped child. Trivial wiring once reports.rs is wired (mirrors contacts.rscontact_attachments.rs shape).

  • services/craig-cases/src/api/contact_attachments.rs — contact-scoped child. Trivial; deferred only because contacts.rs was wired in 8b but attachments weren’t yet — keeping module-pair churn together.

Step 8c is filed as a follow-up issue with the same single-MR-full-scope shape as Step 8b. Independent of Plan E (no service-to-service dependency); independent of Plan B Step 3 (encryption). Can ship at any time after Step 8b lands.

Step 8 deviations (2026-05-09)

  • Module scope: plan body listed 14 modules with ~57 handlers. Step 8 wires only services/craig-cases/src/api/cases.rs (8 handlers: create_case, list_cases, get_case, update_case, list_household, add_household_member, list_milestones, batch_lookup). The remaining 13 modules (investigations, referrals, reports, contacts, court_orders, case_plans, case_plan_tasks, persons, report_persons, report_attachments, contact_attachments, screening_decisions, disposition_follow_ups) defer to a continuation MR (Step 8b). Reasoning: Step 8 establishes the integration pattern (bootstrap + Jurisdiction extension + ResourceRef helper + ListScope→SQL mapping + invalidation subscriber + TTL refresh + ROPC bootstrap) — Step 8b template-applies the pattern across the remaining modules. Splitting reduces per-MR review surface + lets Step 8’s foundation land before the bigger templating sweep.

  • L2 macro generators + L3 fixture-driven regression tests: plan body called for crates/craig-authz/src/test_macros.rs proc-macros generating #[authz_smoke_test] + L3 fixture tests. Step 8 ships hand-written tests in services/craig-cases/tests/api/authz_smoke.rs instead (3 tests covering admin / supervisor / caseworker scope outcomes against the live engine). Macro-generation is deferred to Step 8b along with the remaining handlers — at that point the macro produces ~80 tests automatically. Hand-written 3 in this MR demonstrates the integration works end-to-end without taking on the macro-generation complexity in the same MR.

  • Pre-Plan-E ROPC bootstrap: craig-cases needs a service-account bearer to fetch authz rulesets from craig-rules at boot. Plan E (service-identity, filed at !235) ships OidcServiceToken + client_credentials to handle this, but Plan E hasn’t started yet. Step 8 ships a transitional services/craig-cases/src/authz_bootstrap.rs that does ROPC against Keycloak using devstack admin credentials to obtain a bearer for the boot bulk-fetch. Same anti-pattern as services/craig-intake/src/api/service_token.rs::KeycloakServiceToken; explicitly marked "REMOVE WHEN PLAN E LANDS" in the file header. Devstack docker-compose.yml sets the four env vars (CRAIG_CASES__KEYCLOAK_TOKEN_URL, …​_USER, …​_PASSWORD, …​_CLIENT_ID); production deployments unset → engine boots with empty cache and fail-closes every request until Plan E lands. Documented as Plan E Step 3’s first concrete consumer.

  • Cache-invalidation subscriber + TTL refresh registration: Step 5 plan errata documented that registering subscribers in service main.rs files was deferred to Steps 8-11. Step 8 wires both for craig-cases here. Routing-key bug fixed in crates/craig-authz/src/invalidation.rs: bind on ruleset.changed. (one full word per RabbitMQ topic semantics) rather than ruleset.changed.<prefix> (which would require dashes-as-separator); jurisdiction-prefix filtering moved into the handler.

  • Ruleset emit shape: scope outputs added: Step 6’s rulesets emitted {allow, reason} for every rule. Step 8 surfaced that auto_scope_list needs {scope: …​} shape. Regenerated all 70 ruleset files to emit {allow, scope, reason} from every rule. Read/Update/Create rules emit o_scope: "assigned_worker" as a placeholder; List rules emit the actual policy scope ("all" / "assigned_worker" / "assigned_supervisor" / "denied"). Engine reads the right field based on action.

  • BatchLookupResponse contract change: per Plan A § D15, added unauthorized_count: usize + partial_auth: bool fields. Pre-1.0 + serde defaults guarantee the addition is a non-breaking wire change for existing clients (older clients ignore the new fields).

  • Existing role-gate removal: handlers no longer call claims.require_caseworker_or_above() — engine.check() / auto_scope_list() are the entire access decision, with role logic now living inside the JDM rulesets via 'admin' in claims.roles etc.

Step 7 deviations (2026-05-09)

  • Table count: plan body called for "11 tables across placement/financial/exchange". Step 7 shipped 7 (placements, kinship_options, payments, payment_adjustments, icpc_requests, icpc_home_studies, icpc_attachments). The 4 deferrals are architecturally justified, not scope-shedding:

    • home_documents (placement) — keyed on home_id/foster_home_id. Foster homes are licensable provider entities; authz here is licensing-staff/admin scope, not case-assigned worker. Adding assigned_worker_sub would be misleading.

    • claiming_records (financial) — quarterly aggregate (CFS-2 submission); no per-case scoping, no case_id column. Authz is admin-only. Skip.

    • education_records (placement) — keyed on child_id + nullable placement_id. A child can be in multiple cases simultaneously (siblings, multiple investigations); there’s no single "assigned_worker per education record." Skip — child_id-keyed records use a child→primary-case lookup at authz-eval time (Step 8 design choice when handlers wire in).

    • health_records (placement) — same as education_records.

      Documented in services/craig-placement/migrations/<TS>_assigned_worker_sub.sql header.

  • Inbox handler subscriber semantics: plan body called for "competing-consumer for at-least-once update semantics" using subscribe. Implemented exactly as spec’d. UPDATE WHERE clauses include assigned_worker_sub IS DISTINCT FROM $1 so duplicate deliveries from competing consumers are no-ops. craig_mq::handle_idempotently wraps the handler for full at-least-once correctness per platform-stab-2 §D3.

  • Backfill xtask shape: ships at xtask/src/cmd/backfill_cross_service_assignment.rs matching plan §D6. Direct DB connection (4 pools: cases + placement + financial + exchange) — no service API churn. cargo xtask backfill-cross-service-assignment [--limit N]. Idempotent re-runs cheap.

  • Placement subscriber added; financial + exchange extended: plan body had placement listed in the per-service main.rs registration. craig-placement had no inbound subscribers prior; this MR adds the first. craig-financial + craig-exchange had subscribers for other events; we extend their routing-key list + dispatch.

  • Manual-verification of backfill: backfill against current devstack populated 17 placements + 7 kinship_options + 45 payments + 1 icpc_request + 1 icpc_home_study from 7 seeded cases. payment_adjustments + icpc_attachments updated 0 rows because the seeder doesn’t currently produce any.

Step 6 deviations (2026-05-09)

  • Resource type count: plan body’s CHANGELOG draft estimated "~30 per jurisdiction"; Step 6 shipped 35 per jurisdiction (70 total). The extra 5 per jurisdiction are the icpc/exchange/rate_table types that fall under caseworker-visible scope. 12 admin-internal types (audit_event, security_alert, security_archive, security_review, detection_rule, nist_control, major_change, admin_unit, worker_identity, partner, partner_api_key, partner_signer_key, rule_set, rule_evaluation) intentionally excluded — they fail-closed via cache miss, which is the right default for admin-only types. Documented in rulesets/README.adoc. Step 11 (security + rules engine adoption) will revisit if explicit policies are needed there.

  • JDM expression syntax: plan §D5 example used "contains 'admin'" for input rule expressions; Step 6 verified that zen-engine 0.54 actually accepts the cleaner 'admin' in claims.roles form as the input field expression (returns bool), with "== true" as the rule value. The §D5 example wire-shape was approximately right but not literal; the shipped rulesets carry the verified syntax.

  • Generator script not committed: a one-shot Bash generator at /tmp/gen-authz-ruleset.sh stamped out the 70 JSON files. Discarded after use — rulesets are version-controlled artifacts, not generated code. Re-running the generator would produce the same output (template is deterministic) but ops should treat the JSON files as the source of truth.

  • README format: rulesets/README.adoc (asciidoc per project convention). Initial draft was markdown; corrected mid-MR after user feedback.

Step 5 deviations (2026-05-09)

  • Cache-invalidation exchange: plan’s Files-list called for crates/craig-mq/src/lib.rs to define CACHE_INVALIDATIONS_EXCHANGE: &str = "craig.cache_invalidations" as a separate topic exchange. Step 5 reuses the existing craig.events topic exchange instead — same fan-out semantics via subscribe_exclusive (auto-delete queues bound on ruleset.changed.<name> routing key) without an extra exchange to declare in bootstrap. The existing Publisher::publish_in_tx path stages the events through the outbox unchanged; consumers bind narrowly via topic-pattern. Documented in crates/craig-authz/src/invalidation.rs module doc.

  • 6-services-main.rs subscriber wiring: plan’s Files-list called for "All 6 consuming services' src/main.rs — register cache-invalidation subscriber via subscribe_exclusive`". Step 5 ships the subscriber primitive (`craig_authz::spawn_invalidation_subscriber) but does NOT register it in the 6 services because no service consumes craig-authz yet — Steps 8-11 wire the engine into individual services. Subscriber registration becomes part of each service-adoption step (Step 8 wires craig-cases + registers; Step 9 wires craig-placement + craig-exchange + registers; etc.) so the subscriber ships alongside its first consumer in each service.

  • authz.policy.changed event: plan’s § D14 schema includes a separate authz.policy.changed event for -authz- ruleset mutations. Step 5 ships the more general ruleset.changed.<name> event (which covers all rule-set CRUD); the authz.policy.changed distinction is a routing-key namespace concern that becomes meaningful only when a separate audit subscriber wants to filter authz-only changes. Deferred to Step 8 (cases adoption) when audit-event subscribers actually exist.

  • TTL task cadence: plan called for "TTL refresh task with jitter". Step 5 ships TTL/10 sweep cadence (floor 30s) with ±25% jitter. Defaults: 1h TTL → 6m sweeps. Override via CRAIG_<SVC>__AUTHZ_POLICY_TTL_SECONDS=<n>.

  • Test count: plan called for "~10 new tests"; Step 5 shipped 10 new (6 invalidation + 4 rules-client). Math reconciles.

Open questions

All resolved during plan-shaping (2026-05-07 design conversation):

  1. Identity normalization shape — destructive migration; pre-1.0 + no production data. Resolved.

  2. Policy DSL — zen-engine/JDM (already in workspace; gold-standard pattern in CRAIG). Resolved.

  3. Cross-service authz path — denormalize via case.assignment_changed events. Resolved.

  4. Soft-delete behavior — predicate first; return deleted row to authorized callers (Option A). Resolved.

  5. Fail-closed when no policy matches. Resolved.

  6. Cache invalidation — RMQ via subscribe_exclusive + TTL fallback (1h default). Resolved.

  7. Test coverage — L1 (engine unit) + L2 (handler smoke) + L3 (fixture-driven regression). Resolved.

  8. Default policies content — Georgia + Texas as deliberately-divergent regression fixtures, not authoritative. Resolved.

  9. BFF route guards — backend-only; BFF is a thin renderer. Resolved.

  10. IdP neutrality — no admin API; lazy worker_identities from JWT claims. Resolved.

  11. Action vocabulary — Read/List/Create/Update/Delete/Approve. Resolved.

Risks

Risk Mitigation

Step 2 destructive migration on a deployment with real production data

Pre-1.0 + no production = safe today; document in ADR-025 that first-production deployments need per-jurisdiction data-import tooling, NOT this destructive migration. ADR-025 includes a runbook section.

Cache miss / boot-load failure produces fail-closed-everything outage

Boot-fail bails clearly with operator-facing log; cache miss at request time is logged + alerted; TTL refresh is the safety net for partial events. Same fail-closed-with-clear-error pattern as platform-stab-2’s encryption-mode-required guard.

Default fixtures don’t match a future jurisdiction’s actual roles

L3 regression layer documents that fixtures are CRAIG-curated; jurisdiction-onboarding playbook (Step 13 doc) instructs adopters to fork-and-customize.

Eventually-consistent denormalization window denies new worker until propagation completes

Acceptable: stale window fails closed in conservative direction (denies new worker, not old). Documented in ADR-023.

Steps 8-11 touch ~140 sites; large coordinated diff risks merge conflicts against in-flight feature work

Per-service rollout (Steps 9/10/11 split by service); each MR is reviewer-tractable. Coordinate with feature-work cadence; target quiet windows.

LIST handlers ignoring ?worker= breaks BFF list views that pass it

Step 13 cleans up BFF callsites in same MR. Backend already auto-scopes correctly.

zen-engine ruleset complexity grows beyond what jurisdictions can author by hand

gorules.io has a visual JDM editor; document in jurisdiction-onboarding playbook.

Auth middleware DB upsert (Step 3) adds 1-2ms latency to every authenticated request

Indexed lookup; ON CONFLICT path is the common case after warmup. If perf becomes a concern, add an in-process LRU cache with TTL.

After this plan lands

  • Record-level authorization enforced at every authenticated handler (~140 sites); BOLA/IDOR surface closed

  • CRAIG is genuinely multi-jurisdictional: states, tribes, counties onboard with rulesets + CSV + env vars, no code changes

  • Identity model normalized: claims.sub UUID is the canonical worker identity; preferred_username is presentation-only

  • IdP-neutral: any OIDC-compatible IdP works; no admin API coupling

  • Plan B (pii-and-partner-edge-hardening.adoc) becomes safe to execute

  • GitOps follow-up plan (gitops-jurisdiction-config.adoc) becomes the natural next step

  • ADR-009 (mobile/offline-capable client) becomes safe to design — predicate is well-defined for offline-sync conflict resolution

  • Foundation laid for Phase 11 portals (constituent, foster parent, provider) — they extend the policy engine with their own role types

CRAIG can credibly claim federal-compliance posture for record-level access control across arbitrary jurisdictions.

Edit this page · latest