Architecture

On this page

CRAIG follows a modular, service-oriented architecture modeled after OpenStack. Like OpenStack, all functionality is accessible through three equivalent interaction modes, so no capability is exclusive to any one client:

  • REST APIs: each functional domain (case management, eligibility, data exchange, etc.) is exposed as an independent, versioned REST service. The APIs are the authoritative interface and the foundation for all other clients.

  • CLI (craig): a first-class command-line client providing full access to all API functionality, suitable for scripting, automation, and administrative workflows.

  • Web UI: a browser-based interface for caseworkers, constituents, and providers, built entirely on the public REST APIs.

  • Message Bus (RabbitMQ): asynchronous event-driven communication between services, for data exchange, notifications, and audit events.

  • Rules and Policy Engine (GoRules / Zen Engine): jurisdiction-configurable policy rules evaluated at runtime using JSON Decision Models (JDM). Title IV-E eligibility, safety and risk assessment, timeliness monitoring, and other policy logic are defined as portable rule sets that each jurisdiction loads without modifying core code, which directly satisfies the 45 CFR § 1355.53(a)(1) requirement to separate business rules from core programming.

  • Modular Design: components can be independently developed, deployed, replaced, or shared with other states and tribes, per 45 CFR § 1355.53(a)(1).

  • Plain Language Documentation: all automated functions are documented in non-technical language, per 45 CFR § 1355.53(a)(2).

Service Architecture

Infrastructure
CRAIG Core Services
Clients
/report, /report/status
wildcard audit subscriber
OIDC authn for every service
domain events
Identity Provider
Keycloak OIDC
RabbitMQ
Message Bus
Garage
Object Storage
Rules & Policy Engine
GoRules / Zen Engine
Case Management
Service
Placement & Foster Care
Service
Data Exchange
Service
Financial & Claims
Service
Reporting & Data Quality
Service
Security & Compliance
Service
Public Intake
Service
Document Composition
Service
Web UI BFF
Server-rendered HTML
Web UI
Caseworker / Constituent / Provider
craig CLI
Scripting & Administration
Direct API Clients
Integrations & Automation
Public Reporter
Unauthenticated

Three fan-outs are drawn at the subgraph level, or described in prose, to keep the graph legible (#1418); no information is lost:

  • OIDC: the identity provider authenticates every core service (the single AUTH → CORE edge). Keycloak is the reference backend, not a CRAIG service itself; see the IdP integration guide.

  • Message bus: every API service publishes and consumes domain events over RabbitMQ, with two exceptions. craig-web is a pure BFF with no bus connection at all; it reads exclusively through service APIs. craig-intake, the stateless edge, holds no MQ connection either; it forwards over HTTP only (ADR-017). craig-security’s dashed edge is the informative special case: the wildcard audit subscriber (routing key `#) described under Cross-cutting Service Contracts below.

  • Databases: each core service owns its like-named PostgreSQL database (craig_rules, craig_cases, and so on), schema-per-service, with no cross-service DB access. The canonical service-to-port-to-database table is in Services. craig-intake is the stateless exception (ADR-017); craig-web is DB-less.

Cross-cutting Service Contracts

Every CRAIG service except the pure-BFF craig-web honors the cross-cutting contracts below, enforced by shared middleware and helper crates. Since #1186 (ADR-061) that includes worker supervision: every background worker registers on a per-process Supervisor under an expected-worker manifest. A Critical worker’s death fail-fasts the process, with bounded drains and a nonzero exit, and /readyz gates on the worker registry + the RabbitMQ parent connection; a knob-disabled worker shows up as Disabled on /healthz.

Contract Implementation

OIDC authentication

craig_auth::middleware::auth_middleware — validates JWTs against the configured OIDC issuer’s JWKS with auto-refresh. The issuer is resolved at boot via <issuer>/.well-known/openid-configuration (craig_auth::OidcDiscovery) so the jwks_uri, token_endpoint, authorization_endpoint, and end_session_endpoint come from the discovery doc rather than hard-coded Keycloak paths. Any OIDC-compliant issuer that supports the client_credentials grant (Keycloak, authentik, Okta, Azure AD/Entra, ForgeRock, PingFederate, Auth0) works without code changes. Dex 2.45.1 does not support client_credentials and is therefore not a viable backend — see IdP integration guide. Every protected route is nested under the auth layer; public endpoints (craig-intake /public/v1/*, health checks) opt out explicitly.

Record-level authorization

craig_authz::AuthzEngine (zen-engine/JDM) — every protected handler routes its access decision through authz.check(…​) (single row) or authz.auto_scope_list(…​) (LIST). Per-resource policies live in rulesets/{jurisdiction}-authz-{resource}.json; roles, scoping, and approval workflows are inputs to decision tables — no require_caseworker_or_above() hard-codes. Spans all 8 authz-consuming services (incl. craig-composition; the site count drifts — grep authz.check/auto_scope_list for a live figure). Engine boots from craig-rules via OIDC-discovered HTTP for the other 7 services; craig-rules reads its own policies from local Postgres (DbRulesetSource) to avoid HTTP self-loopback. RMQ-driven cache invalidation + post-boot warm-up keep caches consistent. See ADR-023 + ADR-024.

RFC 9457 Problem Details

craig_common::ApiErrorIntoResponse emits application/problem+json with type, title, status, detail. Every handler returns Result<_, ApiError>. Per-variant problem-type URLs live in the craig_common::error::problem_types catalogue (23 constants); the BadRequest and Conflict variants carry an Option<&'static str> type_url field populated by sub-typed constructors (bad_request_typed(url, detail, field) / conflict_typed(entity, detail, url)) when the precise cause is known. Catalogue includes category defaults (NOT_FOUND / BAD_REQUEST / UNAUTHORIZED / FORBIDDEN / CONFLICT / INTERNAL / VALIDATION_FAILED), BadRequest sub-types (INVALID_UUID / INVALID_ENUM_VALUE / INVALID_DATE / REQUIRED_FIELD_MISSING), Conflict sub-types (DUPLICATE_RESOURCE / INVALID_STATE_TRANSITION / FOREIGN_KEY_VIOLATION), middleware-specific (PAYLOAD_TOO_LARGE / IDEMPOTENCY_* / RATE_LIMITED), and domain-specific (MALFORMED_MULTIPART / CAPTCHA_FAILED / BATCH_TOO_LARGE / INVALID_RULESET / STRUCTURAL_MISMATCH). Since ADR-068 the surface also carries the degradation taxonomy: a GatewayTimeout (504) variant (REQUEST_TIMEOUT from the route-class ceiling middleware; DB_TIMEOUT for a server statement cancel), sub-typed ServiceUnavailable URLs (DB_CONNECTION_LOST, DB_OUTCOME_UNKNOWN for a commit whose verdict never arrived) with a variant-specific Retry-After: 5 ONLY on pool-exhaustion refusals, and the manual From<sqlx::Error> classifier (classify_db_error) every ?-propagated DB error rides — enforced by the blocking db-error-construction lint. Discipline: new URLs join the catalogue, not call sites.

Request idempotency (ADR-062 §B)

craig_api::request_claims — convert-class endpoints claim their client-minted client_request_id as the FIRST statement of the domain transaction (claim_first); replays classify against the durable claim (scope + entity kind + effective actor + IntentV1 hash) and re-derive the endpoint’s own success shape. The legacy Idempotency-Key response-cache middleware was DELETED in B2 (#1194, zero senders); each service prunes request_claims on the fleet-wide CRAIGREQUEST_CLAIMSWINDOW_DAYS horizon (default 30 d) via the request-claims-retention worker.

Shared reqwest::Client

craig_common::build_shared_client(name, version) — one pool per service, 30s timeout, 5s connect timeout, 32 idle connections per origin, <service-name>/<version> user-agent. Injected via axum::Extension and cloned into handlers / adapters. Removes per-request reqwest::Client::new() pool churn.

Stateless session cookies (craig-web only)

tower-cookies + AES-GCM encrypted private() cookie carrying the JWT and locale, HttpOnly + SameSite=Strict. CRAIG_WEB__SESSION_SECRET (≥64 bytes) is the master key; see ADR-013. CSRF protection is enforced separately by the verify_csrf middleware (Sec-Fetch-Site / Origin / Referer header verification on state-changing requests, #763), not by a cookie field. Enables horizontal scaling of the web tier without a shared session store.

Wildcard audit subscriber

craig-security binds queue craig-security.events to the craig.events topic exchange with routing key #, writing every domain event to the audit_log table. This is how audit logging gets implemented once and for all — no per-service audit wiring.

Data Exchange Topology

Mandatory
Mandatory
Mandatory
Mandatory if applicable
Mandatory if applicable
Mandatory if applicable
Mandatory if applicable
To the extent practicable
To the extent practicable
To the extent practicable
Optional §1355.54
CRAIG
Data Exchange
Service
Child Welfare
Contributing Agencies
Financial Payments
& Claims
Medicaid
Eligibility
Child Abuse
& Neglect Systems
Title IV-A
TANF Systems
Title IV-D
Child Support
External Data
Collection Systems
Court
Systems
Education
Systems
Health
Agency Systems
Tribal
Entities
All exchanges use a single jurisdiction-defined data exchange standard per 45 CFR § 1355.52(f).

Reference Data Architecture

CRAIG uses a two-tier approach to reference data:

  • Tier 1: Compile-time (craig-reference crate). Domain enums (Gender, Race, PlacementType, etc.), FIPS codes for all US states and administrative units, and AFCARS/NCANDS field translations are defined as Rust enums with strum-derived conversions. These are shared across the workspace and validated at compile time. The crate includes 48 unit tests.

  • Tier 2: Runtime (Admin Unit Registry). Deployment-specific administrative units, such as counties, regions, boroughs, chapters, districts, and municipalities, are managed through CRUD endpoints on craig-security. Default seeds for all 57 US jurisdictions (50 states, DC, 5 inhabited territories, and the Minor Outlying Islands; 3,235 county-equivalents sourced from the Census Bureau’s national_county2020.txt) are compiled directly into craig-reference::counties, with no on-disk CSV layer involved. cargo xtask seed-admin-units [--only <jurisdiction_key>] performs an idempotent UPSERT keyed on (name, jurisdiction). A deployment config endpoint, GET /v1/security/admin/config, returns the admin_unit_label (e.g., "County" for Georgia, "Region" for Texas), so the UI can adapt its labels without code changes. That label itself comes from the required CRAIG_<SVC>__ADMIN_UNIT_LABEL env var; there is no in-code default.

Post-deployment extensions, such as custom non-FIPS units like county consortia or experimental tribal compacts, go through the same craig-security::admin_unit_registry CRUD endpoints. That’s the operator extension mechanism.

Worker Identity

All API handlers identify the acting worker by claims.sub — the OIDC issuer’s stable user UUID. The web UI continues to display preferred_username for human readability. craig-security lazy-populates the worker_identities table from JWT claims on first authenticated request — no IdP admin-API dependency; this works across any OIDC backend. Devstack test users have pinned UUIDs in devstack/keycloak/craig-realm.json so seed data and integration tests can reference them deterministically.

Multi-Jurisdictional Posture

CRAIG ships jurisdiction-neutral by default: onboarding a new state, tribe, or county requires only data, not code.

  • Domain rules: drop JDM JSON files into rulesets/<jurisdiction>/<jurisdiction>-<function>.json (e.g. georgia-ive-eligibility.json). The rules engine evaluates them following the {jurisdiction}-{function} naming convention.

  • Authorization policies: drop JDM JSON files into rulesets/<jurisdiction>/<jurisdiction>-authz-<resource>.json. The authz engine (craig-authz) consumes the same naming convention. Roles, scoping, approval workflows are inputs to the decision tables.

  • Admin units: cargo xtask seed-admin-units --only <jurisdiction_key> upserts the compiled-in dataset, any of the 57 US jurisdictions, into admin_unit_registry. For deployment-specific units, such as county consortia, tribal compacts, or multi-state regional districts, operators use the craig-security::admin_unit_registry CRUD endpoints instead: the same flow, but at runtime, so it doesn’t require redeployment.

  • Env vars: every service requires CRAIG_<SVC>JURISDICTION=<jurisdiction> and CRAIG_<SVC>ADMIN_UNIT_LABEL=<label>; craig-web also requires CRAIG_WEBTHEME and CRAIG_WEBBRANDING_AGENCY. There’s no silent fallback to Georgia.

  • OIDC identity: any compliant issuer works, since OIDC discovery resolves endpoints at boot. Worker identities populate from JWT claims.

See the archived Multi-Jurisdictional Authorization plan for the underlying architectural decisions (14 step MRs, 5 ADRs, May 2026).

Plugin Runtime

Server-side UI plugins extend the craig-web BFF without modifying it (Plan W; ADR-033). A plugin is a crate under plugins/ whose sync-pure render fn is annotated with #[craig_plugin(slug, manifest)] (craig-plugin-macros). That annotation emits a linkme CRAIG_PLUGINS registration carrying the plugin’s Plugin.toml manifest and its render fn. The host owns all I/O; the plugin only renders.

  • Boot: craig-web materializes a validated PluginRegistry (craig-plugin-contracts) from the slice, failing fast on a duplicate slug, an unknown required_role, or a display_name {term.*} key that doesn’t resolve against the materialized terminology.

  • Render: GET /plugins/<slug>, inside protected_routes, host-fetches the manifest-declared [data] endpoint. That fetch is SSRF-guarded by a deny-by-default host allow-list, per the auth mode (none / service_token / user_jwt), and maps the response to a FetchOutcome. The plugin’s render fn is then called and returns a four-state (data / empty / error) CSP-clean HTML fragment; loading is the shell’s own htmx placeholder.

  • Opt-in: plugins compile into craig-web behind per-plugin Cargo features (a #[cfg(feature = "…")] use … as _; linkme force-link), so a deployment composes its own plugin set. Production omits the reference plugin.

  • Source-agnostic seam: the PluginSource trait stays stable across backends. v1 is the compile-time linkme slice; v2 is WASM, with no contract change, since every value crossing the plugin boundary is serde-serializable.

The full design is in Plan W — Plugin Manifest + Render Runtime. The composition engine that arranges plugins into surfaces is Plan X (ADR-035).

Edit this page · latest