Deployment Guide

On this page

Architecture

CRAIG consists of 9 API services, the craig-web BFF, 4 infrastructure services, and an optional CLI client:

Service Port Database Description

craig-rules

8001

craig_rules

Rules engine (JDM evaluation)

craig-cases

8002

craig_cases

Case management

craig-placement

8003

craig_placement

Foster care & placement matching

craig-exchange

8004

craig_exchange

Data exchange & ICPC

craig-financial

8005

craig_financial

Payments, rate tables & claims

craig-reporting

8006

craig_reporting

Reporting & data quality

craig-security

8007

craig_security

Security & compliance

craig-intake

8008

— (stateless edge, ADR-017)

Public intake (anonymous child abuse reporting; no DB/MQ — forwards to craig-cases over HTTP)

craig-composition

8009

craig_composition

Composition layer engine (ADR-035)

craig-web

8080

Web UI (BFF, no database)

Infrastructure:

  • PostgreSQL 18 — one database per service, native uuidv7() for defaults

  • RabbitMQ 4.2 — topic exchange craig.events

  • Keycloak 26.5 — OIDC provider with craig realm

  • Garage v2 (or any S3-compatible store) — file attachments

Environment Variables

All services use the CRAIG_<SERVICE>__* prefix pattern (double underscore separates sections), loaded by the config crate.

Common Variables (All Services)

Variable Default Description

CRAIG_<SVC>__PORT

varies

HTTP listen port

CRAIG_<SVC>__DATABASE_URL

PostgreSQL connection string. Two principals share this variable NAME (#1310): the migration gate’s container carries the migration-owner DSN, the serving container the runtime DSN — see § Database Setup.

CRAIG_<SVC>__RABBITMQ_URL

AMQP connection string

OIDC_ISSUER

Public OIDC issuer URL for JWT iss validation (e.g. https://auth.example.com/realms/craig). Any OIDC-compliant issuer that supports the client_credentials grant works (Keycloak, authentik, Okta, Azure AD/Entra, ForgeRock, PingFederate, Auth0). Dex 2.45.1 does not qualify. CRAIG resolves the actual endpoints via OIDC discovery (<issuer>/.well-known/openid-configuration) at boot per Plan E § Step 2. See IdP integration guide.

OIDC_INTERNAL_URL

Internal URL for split-DNS deployments (Docker / k8s). When set, CRAIG fetches the discovery doc + JWKS via this URL and rewrites the discovery doc’s jwks_uri + token_endpoint host portions to match. Browser-facing endpoints (authorization_endpoint, end_session_endpoint) keep the public OIDC_ISSUER URL since those land in HTTP redirects.

CORS_ORIGINS

*

Comma-separated allowed CORS origins

BODY_LIMIT

2097152

Maximum request body size in bytes

CRAIG_<SVC>__JURISDICTION

— (required)

Jurisdiction identifier used to select the rule set pack (e.g. georgia, texas). Plan A § Step 12: no fallback default; service bails at boot if unset.

CRAIG_<SVC>__ADMIN_UNIT_LABEL

— (required)

Display label for the administrative-unit concept (County, Region, Chapter, …​). Plan A § Step 12: no fallback default; service bails at boot if unset.

craig-rules

Variable Default Description

CRAIG_RULES__JURISDICTION

— (required)

Jurisdiction prefix for rule set names. Plan A § Step 12: no fallback default.

CRAIG_RULES__EVAL_TIMEOUT_MS

5000

Budget for one rules evaluation dispatch, ms (valid 1..=25000; boot-validated). Elapsing returns 503 on evaluate and fails the domain-event handler (#784). Keep well below HTTP callers' shared 30s client budget.

CRAIG_RULES__FUNCTION_TIMEOUT_MS

min(5000, eval budget)

Per-NODE JS interrupt bound for JDM function nodes, ms (#1046). Explicit values must sit in 1..=EVAL_TIMEOUT_MS (boot-validated); the default derives, so lowering only the eval budget needs no coordination. See configuration-reference for the per-node vs per-dispatch semantics.

CRAIG_RULES__DECISION_REFRESH_SECONDS

300

Decision-cache sweep interval, s (valid 10..=86400; boot-validated; #1188 / the ADR-006 #1188 amendment). Each instance periodically reconciles its compiled-decision cache against the DB via the identity-aware (id, revision) CAS, bounding staleness after a LOST rules.cache_invalidated event to ≤ ~2×interval + pass duration under a healthy DB (≈10 min at the default). Replicas de-phase via a deterministic per-instance start offset. Devstack compose pins 15.

Object Storage (craig-cases / craig-placement / craig-exchange / craig-reporting / craig-security / craig-rules)

Every service that touches the object store loads the same CRAIG_STORE* family. CRAIG_STOREBACKEND is explicit-required (#1362): a deployment that omits it refuses to boot with the remedy in the message (craig-rules only loads the family — and so only enforces the requirement — when its retention archiver is enabled) — the backend is never silently defaulted, because local writes blobs to a single container’s disk (dev/test only; they vanish with the container). A deployment that intends S3 can therefore never silently get local.

Variable Default Description

CRAIG_STORE__BACKEND

— (required; boot refuses when unset)

Storage backend (s3 or local; local is single-container disk, dev/test only)

CRAIG_STORE__BUCKET

S3 bucket name

CRAIG_STORE__S3_ENDPOINT

S3 endpoint URL

CRAIG_STORE__S3_ACCESS_KEY

S3 access key

CRAIG_STORE__S3_SECRET_KEY

S3 secret key

CRAIG_STORE__S3_REGION

S3 region

craig-placement

No service-specific environment variables beyond the shared CRAIG_PLACEMENT* settings listed under "Shared settings" (database, MQ, Keycloak, port, logging, CORS). Earlier versions carried a CRAIG_PLACEMENTRULES_ENGINE_URL variable; it was removed in 2026-04-19 when the unused Extension wrapper was deleted (see CHANGELOG under Step 12 #[allow] audit).

craig-financial

Variable Default Description

CRAIG_FINANCIAL__PORT

8005

HTTP listen port

CRAIG_FINANCIAL__DATABASE_URL

PostgreSQL connection string

CRAIG_FINANCIAL__RABBITMQ_URL

AMQP connection string

OIDC_ISSUER

Public Keycloak URL for JWT iss validation

OIDC_INTERNAL_URL

Internal Keycloak URL for JWKS fetching

CRAIG_FINANCIAL__JURISDICTION

— (required)

Jurisdiction prefix for financial rules

CORS_ORIGINS

*

Comma-separated allowed CORS origins

BODY_LIMIT

2097152

Maximum request body size in bytes

craig-reporting

Variable Default Description

CRAIG_REPORTING__PORT

8006

HTTP listen port

CRAIG_REPORTING__DATABASE_URL

PostgreSQL connection string

CRAIG_REPORTING__RABBITMQ_URL

AMQP connection string

OIDC_ISSUER

Public Keycloak URL for JWT iss validation

OIDC_INTERNAL_URL

Internal Keycloak URL for JWKS fetching

CRAIG_REPORTING__JURISDICTION

— (required)

Jurisdiction prefix for reporting rules

CORS_ORIGINS

*

Comma-separated allowed CORS origins

BODY_LIMIT

2097152

Maximum request body size in bytes

craig-security

Variable Default Description

CRAIG_SECURITY__PORT

8007

HTTP listen port

CRAIG_SECURITY__DATABASE_URL

PostgreSQL connection string

CRAIG_SECURITY__RABBITMQ_URL

AMQP connection string

OIDC_ISSUER

Public Keycloak URL for JWT iss validation

OIDC_INTERNAL_URL

Internal Keycloak URL for JWKS fetching

CRAIG_SECURITY__JURISDICTION

— (required)

Jurisdiction prefix for security rules

CORS_ORIGINS

*

Comma-separated allowed CORS origins

BODY_LIMIT

2097152

Maximum request body size in bytes

craig-intake

craig-intake is a stateless edge (ADR-017): it has no database and no message-queue connection — there are no DATABASE_URL/RABBITMQ_URL variables for this service. The full variable set (standalone mode, SHINES backend, signed-path limits, TLS) is in the Configuration Reference.

Variable Default Description

CRAIG_INTAKE__PORT

8008

HTTP listen port

OIDC_ISSUER

Public Keycloak URL for JWT iss validation

OIDC_INTERNAL_URL

Internal Keycloak URL for JWKS fetching

CRAIG_INTAKE__CASES_URL

Base URL of craig-cases (for forwarding authenticated reports)

CRAIG_INTAKE__SECURITY_URL

Required (integrated mode). Base URL of craig-security (partner verification + signer-key lookup)

CRAIG_INTAKECLIENT_ID / CRAIG_INTAKECLIENT_SECRET

Required. OIDC client_credentials principal for intake’s outbound S2S calls

CRAIG_INTAKE__JURISDICTION

— (required)

Jurisdiction identifier (no default)

CRAIG_INTAKE__IP_HASH_SECRET

— (required)

Per-deployment HMAC secret for received_ip_hash — set to a random value per deployment

CRAIG_INTAKE__PUBLIC_RATE_LIMIT

5

Max requests per hour from a single client IP against public (unauthenticated) endpoints. The devstack overrides this to 100000 to keep concurrent E2E submits from hitting the gate; production should leave the conservative default. All intake rate limits (this one and the partner-scoped rate_limit_rpm) are per-instance by design (ADR-017 §Amendment #269): each replica’s bucket fills independently, so the effective cluster-wide limit is the configured value × replica count — size limits accordingly, and front intake with an edge/gateway limiter if a deployment needs precise global quotas.

CRAIG_INTAKE__CORS_ORIGINS

*

Comma-separated allowed CORS origins

craig-web (BFF)

Variable Default Description

CRAIG_WEB__PORT

8080

HTTP listen port

CRAIG_WEB__RULES_URL

craig-rules base URL

CRAIG_WEB__CASES_URL

craig-cases base URL

CRAIG_WEB__PLACEMENT_URL

craig-placement base URL

CRAIG_WEB__EXCHANGE_URL

craig-exchange base URL

CRAIG_WEB__FINANCIAL_URL

craig-financial base URL

CRAIG_WEB__SECURITY_URL

craig-security base URL

CRAIG_WEB__OIDC_INTERNAL_URL

Keycloak realm URL (for OIDC)

CRAIG_WEB__KEYCLOAK_CLIENT_ID

craig-ui

OIDC client ID

WEB_EXTERNAL_URL

Public URL of the web UI (for OIDC redirect)

CRAIG_WEB__SESSION_SECRET

Required. AES-GCM master key for the stateless encrypted session cookie (ADR-013). Must be at least 64 bytes; the service refuses to start otherwise. Generate with openssl rand -hex 32 and store via your orchestrator’s secret store.

CRAIG_WEB__SESSION_MAX_AGE_SECS

1800

Session cookie lifetime in seconds (default: 30 minutes).

SESSION_SECURE

true

Emit the Secure flag on the session cookie. Set to false only for non-HTTPS dev loops.

CRAIG_WEB__THEME

— (required)

Theme directory name — since Plan U Step 8 (ADR-036) used only to resolve the agency logo (static/themes/{name}/logo.svg); the per-jurisdiction color palette now comes from the active state bundle’s /assets/theme.css, not an override.css. Plan A § Step 12: no fallback default.

CRAIG_WEB__BRANDING_AGENCY

— (required)

Agency display name in the BFF chrome (e.g. "GEORGIA DEPARTMENT OF HUMAN SERVICES"). Plan A § Step 12: no fallback default.

CRAIG_WEB__BRANDING_APP_NAME

CRAIG

App display name in the BFF chrome.

CRAIG_WEB__BRANDING_LOGO_URL

Optional logo URL.

CRAIG_WEB__UPLOAD_BODY_LIMIT

11534336 (11 MiB)

Max multipart request-body size (bytes) for the two BFF upload routes (case-contact attachments, placement home documents), applied per-route so all other routes keep axum’s 2 MiB default (#970). Size the storing backends' BODY_LIMIT (CRAIG_CASESBODY_LIMIT, CRAIG_PLACEMENTBODY_LIMIT) and the ingress body-size cap (below) to at least this value, or uploads will 413 at whichever tier is smallest.

Public report routing

The public report portal is served by craig-intake (the edge), not craig-web (epic &60 Phase 5; ADR-043). On a single public hostname, the ingress path-routes the public report paths to craig-intake and everything else to craig-web — there is no HTTP redirect and no compatibility route (constraint L6). craig-intake runs in integrated mode and listens on container port 8008.

Required operator cutover step

This is a breaking change. After upgrading, you MUST add the ingress rule below. Without it, /report returns 404 — craig-web no longer serves it. (The repo ships no in-repo gateway; ingress is operator infrastructure per ADR-017.)

Route /report and /report/* → craig-intake (:8008); all other paths → craig-web (:8080).

nginx
# Public report portal → craig-intake (edge). No redirect (L6).
location /report  { proxy_pass http://craig-intake:8008; }
location /report/ { proxy_pass http://craig-intake:8008; }
# Everything else → craig-web (BFF).
location /        { proxy_pass http://craig-web:8080; }
# Attachment uploads: nginx's default client_max_body_size is 1 MiB, which
# would 413 uploads at the edge before they reach the BFF. Raise it to at
# least the largest upstream limit — craig-intake signed_body_limit (30 MiB)
# ≥ craig-web upload limit (11 MiB default) — see the upload-size tiers below.
client_max_body_size 30m;
Kubernetes Ingress (path-based; longest-prefix wins)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: craig-public
  annotations:
    # ingress-nginx defaults to a 1 MiB body cap, which would 413 attachment
    # uploads at the edge. Raise it to the largest upstream limit — craig-intake
    # signed_body_limit (30 MiB) ≥ craig-web upload limit (11 MiB) — see the
    # upload-size tiers below (#970).
    nginx.ingress.kubernetes.io/proxy-body-size: "30m"
spec:
  rules:
    - host: report.example.gov
      http:
        paths:
          - path: /report
            pathType: Prefix
            backend:
              service:
                name: craig-intake
                port:
                  number: 8008
          - path: /
            pathType: Prefix
            backend:
              service:
                name: craig-web
                port:
                  number: 8080
Attachment upload size — configure every tier

A file upload traverses up to four request-body caps; the smallest one wins, so a large attachment 413s at whichever tier is under-sized:

  1. Ingress / reverse proxy — nginx client_max_body_size (default 1 MiB) or ingress-nginx proxy-body-size (default 1 MiB), shown above.

  2. BFF (craig-web)CRAIG_WEB__UPLOAD_BODY_LIMIT (default 11 MiB), scoped to the upload routes.

  3. Storing backendCRAIG_CASESBODY_LIMIT (contact attachments) / CRAIG_PLACEMENTBODY_LIMIT (home documents); the craig_common default is a conservative 2 MiB, so a deployment accepting large attachments MUST raise these.

  4. Object storeCRAIG_STORE__MAX_UPLOAD_BYTES (default 50 MiB).

Set each tier to at least your intended maximum attachment size. The devstack sets craig-cases and craig-placement to 50 MiB; ingress is operator infrastructure (the repo ships no gateway — ADR-043), so its cap is your responsibility. (Whether attachment-accepting backends should scope a larger limit to only their attachment routes, rather than a global per-service raise, is a tracked follow-up.)

SHINES conversion cutover (#1071 / ADR-057)

One-time (per cohort batch) conversion of the GA closed-cohort subsidy recipients (RCS/ERCS, ERSG/ENRSG) out of SHINES — see ADR-057 for the full contract. The conversion runs as a bounded WINDOW, not a standing capability.

Deploy order (parser-first, then knob-off):

  1. Deploy the substrate release (#1071 MR-A) EVERYWHERE first — it carries the craig-security audit-parser arms for financial.subsidy_agreement_imported / financial.subsidy_import_batch_finalized, and the parser must be live before any producer can emit (no imported event may ever be audited through the unknown-type fallback). Let in-flight events drain.

  2. Deploy the surface release (#1071 MR-B) with CRAIG_FINANCIALSUBSIDY_IMPORTENABLED UNSET/false — it is knob-off inert (every import write refuses with a typed 403 naming the knob; GETs work).

The conversion window:

  1. Enable — set CRAIG_FINANCIALSUBSIDY_IMPORTENABLED=true on craig-financial for the window. This gates ALL import writes including finalize; flipping it is the operator’s recorded consent (configuration reference).

  2. Stage — run cargo xtask import-subsidy-history with the export manifest + JSONL. The tool authenticates as the responsible STATE-OFFICE human via device-code OAuth — there is deliberately NO service-principal mode (the finalize authority is ApprovalAction::ImportHistory at state_office floor; an admin session without the state_office role is refused naming it). Staging runs the full validation battery per record and persists the outcome on the record row (staged / rejected + typed blockers + warnings) — nothing touches the live ledger. A dry run is stage + inspect + abort.

  3. Reconcile — the records ledger IS the reconciliation export: re-list the batch, review the rejection manifest and the warnings. Beyond-grace-overdue ACTIVE heads are BLOCKERS by design (refuse-beyond-grace, ADR-057 D6): complete or suspend the review in SHINES and re-export — CRAIG never auto-suspends at conversion. Expect and accept overdue_renewal_anchor/overdue_paper_anchor (within-grace) and aged_suspension warnings: they are native-parity states; the post-finalize review sweep owns them — an aged suspension will surface on the sweep’s leg-2 backlog, and within-grace overdue anchors will suspend later only under YOUR sweep knobs.

  4. Finalize — re-run the tool’s finalize step: the manifest gate (count + checksum
    zero live rejections, else a 409 naming the four numbers) precedes materialization; per-record transactions make a crash resumable under the SAME batch id. Each agreement is born with its payment_cutover_month, review slots materialized from the imported anchors (the conversion baseline — SHINES keeps pre-cutover review history).

  5. Disable — set the knob back to false and restart. VERIFY the closed posture: an import write (e.g. a batch create) must return 403 naming the knob; batch/record GETs must still work (the post-conversion audit surface). Materialized agreements are unaffected — their lifecycle never consults the knob.

The money boundary: SHINES pays every month strictly BEFORE cutover_month; CRAIG pays every month from it onward. CRAIG enforces its side by machine — the generator’s BeforeCutover rule bounds the scheduled, manual, and reconcile paths, and voids any stray pre-cutover undisbursed row — but the SHINES side is inter-system agreement (⁂ #1073): confirm SHINES payment stop-dates against the batch’s cutover_month before finalizing. Corrections after money moved are a designed 409: use native amendments/transitions (+ #1028 adjustments), never a corrected re-export.

Container Deployment

Docker images are built from the multi-stage Dockerfile in the repository root:

# Build all service images
docker build --target craig-rules -t craig-rules:latest .
docker build --target craig-cases -t craig-cases:latest .
docker build --target craig-placement -t craig-placement:latest .
docker build --target craig-exchange -t craig-exchange:latest .
docker build --target craig-financial -t craig-financial:latest .
docker build --target craig-reporting -t craig-reporting:latest .
docker build --target craig-security -t craig-security:latest .
docker build --target craig-intake -t craig-intake:latest .
docker build --target craig-composition -t craig-composition:latest .
docker build --target craig-web -t craig-web:latest .
# Standalone-SHINES profile only (ADR-042 §D8):
docker build --target craig-intake-keyring -t craig-intake-keyring:latest .

Images use Alpine 3.23 as the runtime base for minimal footprint.

Inbox-conversion deploys are stop-then-start (epic &77, ADR-062)

Each Track A unit (A2–A6) converts one service’s event consumers from the legacy at-least-once inbox path to the ADR-062 single-transaction attempt machine. The two implementations must never run concurrently against one event_inbox table: the legacy path’s autocommitted claim bypasses the row lock the transactional path serializes on, reopening the double-execution window mid-rollout. Deploy those releases with a stop-then-start barrier — compose recreates the container (the devstack’s single-replica shape already does this); on Kubernetes set the workload’s update strategy to Recreate for the conversion release (and back to rolling afterwards if desired). Pre-1.0 the platform ships single-replica, so this is a release-note discipline rather than an orchestration change.

Health Checks

All services expose three health endpoints (no auth required), aligned with the Kubernetes liveness/readiness convention (Step 6 of platform-stabilization):

Endpoint Status Purpose

GET /livez

200 (always, while the process is responsive)

Kubernetes liveness probe. Failure → restart. Never checks downstream deps — restarting on a transient DB blip is the wrong reaction.

GET /readyz

200 when no Critical worker is dead/missing AND DB + MQ report healthy; 503 with Retry-After: 5 and the discriminating body not ready: <check[, check]> (checks named in the fixed order workers, database, rabbitmq) otherwise

Kubernetes readiness probe and load-balancer signal. Failure → remove from service rotation without restart. Compose depends_on: condition: service_healthy also points here. Since #1186 (ADR-061) the gate covers the worker registry and genuinely covers the RabbitMQ parent connection (the pre-#1186 probe was silently DB-only); since #1235 it also covers the publisher CHANNEL (a dead confirm channel on a live connection fails readiness — healthz distinguishes disconnected from publisher channel closed).

GET /healthz

200 with detailed JSON: status + uptime_seconds + per-dep checks incl. a checks.workers array of {name, criticality, state, panicked?} entries (structured state only — raw panic/error text stays in logs)

Human/dashboard view. Always 200; the body’s status field carries the ok/degraded signal (an Observed worker death degrades here without failing /readyz). Use for ops dashboards, not probes.

Kubernetes probe example

livenessProbe:
  httpGet:
    path: /livez
    port: 8002
  initialDelaySeconds: 5
  periodSeconds: 10
readinessProbe:
  httpGet:
    path: /readyz
    port: 8002
  initialDelaySeconds: 2
  periodSeconds: 5
  failureThreshold: 3

Readiness

Services report ready (/readyz → 200) when:

  1. Database migrations have completed

  2. JWKS has been fetched from Keycloak

  3. RabbitMQ connection is established (services with MQ; craig-web omits)

  4. The HTTP listener is bound

  5. No Critical background worker is dead or missing (#1186 / ADR-061 — Observed workers never gate; the MQ arm reflects the parent AMQP connection, not per-channel health)

Shutdown & fail-fast exit (#1186 / ADR-061)

A Critical worker death fail-fasts the process: the registry records the death, the supervisor cancels the process shutdown token, HTTP gets a bounded 20s drain (HTTP_DRAIN_DEADLINE), workers get a bounded 5s drain (WORKER_DRAIN_DEADLINE — 20s + 5s stays inside k8s’s default 30s termination grace), and the process exits NONZERO naming the dead worker so the orchestrator restarts it. SIGTERM/SIGINT takes the same drain path and exits zero. From orchestration’s view the pod leaves rotation by 503-or-refusal-then-exit: after the cancel, new connections may be refused while open ones can still see the 503 — a refused probe fails readiness exactly as a 503 does, so no fresh-network-probe-sees-503 guarantee is claimed. The devstack compose files deliberately keep the default restart: "no" so a crashed service stays down loud instead of silently flapping.

Worker functional-health alerting (#1231 / ADR-061)

Supervision is HANDLE-liveness: a hung worker, a reconnect-forever supervisor, or a persistently warn-and-continue loop stays running in /healthz. The worker_last_success_timestamp_seconds{worker=…​} gauge family (meter craig-worker-health, exported on /metrics like every other instrument) closes that gap: each instrumented loop records the unix time of its last SUCCESSFUL pass — failure paths stay silent, so the gauge stales honestly. Metrics-only by design: nothing here gates /readyz. Like every CRAIG instrument, the family exports through the OTel → Prometheus bridge on /metrics, which initializes only when OTEL_EXPORTER_OTLP_ENDPOINT is set (telemetry::init) — stock devstack does not set it, so the end-to-end export proof lives in craig-common’s Prometheus-bridge unit test (the #1160 precedent).

Since ADR-068 the pool-saturation hysteresis exports its own alerting gauge — db_pool_degraded is 1 after 2 consecutive slow acquire probes (>2.5s, half the 5s cliff) and clears after 4 consecutive healthy ones. It deliberately never gates /readyz (a saturated-but-alive database must not trigger restart flapping); the alert IS the operator surface:

db_pool_degraded > 0

Alert on staleness relative to the worker’s cadence — 3×cadence is the recommended floor (it tolerates one missed tick plus jitter without paging):

time() - worker_last_success_timestamp_seconds{worker="outbox"} > 60
worker label Cadence Suggested alert threshold

outbox (all 8 backend services)

~1 s poll

> 60 s

request-claims-retention / reconcile-sweep (financial) / send-jobs-sweep (exchange)

hourly sweep

> 3 h

jwks-refresh (backends) / jwks-refresh-id / jwks-refresh-access (craig-web)

1 h refresh

> 3 h

oidc-discovery-refresh (craig-web)

configured discovery interval

> 3× the interval

authz-ttl-refresh (all authz-booting services)

policy_ttl/10, floor 30 s, ±25% jitter

> 6× the sweep period

events-subscriber (6 services) / audit-subscriber / dlq-subscriber (security) / cache-invalidation (rules) / composition-invalidation / authz-invalidation (all authz-booting services)

30 s consumer-liveness tick while a consume session is live (#1321; silent during reconnect backoff)

> 120 s

decision-refresh (rules)

configured refresh interval (devstack 15 s; default 300 s)

> 3× the interval

jws-cleanup (cases)

hourly

> 3 h

upload-attempt-reconciler (cases, exchange, placement, reporting — one shared label)

5 min pass; beats only on a clean pass

> 30 min

send-worker (exchange)

~1 s poll

> 60 s

detection-scheduler (security)

configured scan interval (60 s default; absent when Disabled)

> 3× the interval

subsidy-generator / review-sweep (financial)

configured schedule; EXECUTED runs only — a replica behind a peer-held lease stays silent

> 3× the interval, alerting on max() across replicas

retention-archive (rules, security, exchange — #1566)

hourly + jitter; absent when the archive knob is off

> 3 h

ssa-worker (exchange)

~1 s poll (knob-gated; absent when WORKER_POLL_SECONDS=0)

> 60 s

ssa-transport-sweep (exchange, #1566)

hourly; EXECUTED passes only (a lock-skip stays silent); absent when TRANSPORT_RETENTION_DAYS=0

> 3 h

The series is ABSENT until a worker’s first successful pass (an hourly loop exports nothing for its first interval) — pair the staleness rule with an absent_over_time(…​[3×cadence])-style guard rather than treating boot-time absence as failure. The retention archiver’s sibling gauge (archive_last_success_timestamp, ADR-058) predates this family and keeps its own name (0-at-boot, unlabeled — different semantics, deliberately not aliased). The engine-level subsidy_sweep_last_success_timestamp_seconds is the second named exception: it fires for manual AND scheduled runs (lease-winner scoped), a different signal from the review-sweep scheduler heartbeat that now exists alongside it. Since #1321 every registered worker beats: subscribers via the 30 s consumer-liveness tick (beats while a consume session is live — a quiet queue is healthy and keeps beating; reconnect backoff is silent, so broker-loss staleness alerts honestly), and the per-service loops at their genuine-success points (lease-skipped scheduler ticks deliberately do not beat — alert on max() across replicas).

Rules engine counters (#1219)

Beyond the decision-refresh heartbeat above, craig-rules exports (meter craig-rules-engine):

  • rules_decision_refresh_outcomes_total{outcome=refreshed|removed|evicted|failed} — per-pass reconcile outcomes (the #1188 sweep’s four sets; zero-count outcomes export no series). A nonzero evicted rate means stored ruleset content stopped compiling (direct-SQL writes — the API pre-validates); sustained failed means per-name re-probe/fetch/compile failures that never starve the rest of the pass.

  • rules_decision_refresh_probe_failures_total — passes whose bulk probe failed outright (nothing reconciled that tick; pairs with the heartbeat staleness alert).

  • rules_evaluations_total{outcome=completed|timeout|runtime_error} — zen dispatches by terminal outcome, reusing the rule_evaluations disposition words. It counts at the DISPATCH boundary, so it approximately tracks the table: a redelivered inbox attempt re-counts, a post-success audit-commit failure counts with no row, and a converged timeout stays timeout here while its row becomes late_completed. Infra-class errors (Db/staging/compile/not-found) count nothing. Alert on the timeout/runtime_error rate relative to completed.

DLQ alerting recommendations (#1205)

The security.dlq.threshold_exceeded event (ADR-022 §D3) is the per-event_type AUDIT record of a dead-letter burst — it lands in audit_log and pages nobody. The operator-visible mechanism is THIS rule set over the #1199 dlq_* family (meter craig-mq-dlq; the same OTEL_EXPORTER_OTLP_ENDPOINT-gated /metrics export as every other instrument). Scrape scope: the depth gauges and capture/outcome counters export from craig-security only (the fleet’s one DLQ consumer, port 8007) — but dlq_handler_panics_total{path="events"|"inbox"} fires in EVERY craig-mq-consuming service’s handler paths, so the panic rule needs fleet-wide scrape, not just 8007. The choice — shipped rules, not an event consumer routing to a paging channel — is recorded in ADR-022 §Amendment #1205.

# Any quarantined capture requires an operator drain (ADR-059 §D7 — the
# tier has NO consumer by design; every publish also logs at error!).
dlq_queue_depth{queue="craig-security.dlq.quarantine"} > 0

# Parking is self-draining at the 10-minute TTL: a nonzero FLOOR across
# 3x the TTL means the replayer is not returning captures (or they
# re-park every cycle — the ~2 h park cap will quarantine them).
min_over_time(dlq_queue_depth{queue="craig-security.dlq.parking"}[30m]) > 0

# The DLQ itself should drain promptly; a sustained floor means the
# consumer is down or its DB writes are refusing.
min_over_time(dlq_queue_depth{queue="craig-security.dlq"}[15m]) > 0

# The metrics ANALOGUE (approximate, not equivalent) of the ADR-022
# section-D3 threshold event. The counter has no event_type label, so
# this is the AGGREGATE 1-hour rate: usually more sensitive (it sums
# across types), but it CAN stay silent when the event fires — the
# event's per-type window also counts divergent-quarantine rows this
# outcome excludes, and the counter undercounts across a
# craig-security restart or a crash between the DB commit and the
# increment. The event's audit_log row is the authoritative
# per-event_type record; this rule is the pager, not the ledger.
increase(dlq_outcomes_total{outcome="recorded"}[1h]) > 10

# Same-token-different-content collisions: investigate immediately.
increase(dlq_outcomes_total{outcome="divergent_quarantined"}[1h]) > 0

# Any handler panic is a code defect, not an outage (#1203).
increase(dlq_handler_panics_total[1h]) > 0

# Staleness guard — check BEFORE trusting a depth value: a stale sample
# timestamp means the sampler is down and dlq_queue_depth is frozen, not
# current (3x the 30 s default sampling cadence).
time() - dlq_depth_last_sample_timestamp > 90

Pair the staleness rule with an absent_over_time(…​[3m])-style guard, same as the worker-health family above: every series is absent until its first sample, so a never-sampled queue alerts through absence, not staleness. Two outage regimes, two different rules: during a BROKER outage nothing can publish to parking and the sampler itself fails (depth frozen) — the staleness/absence guards are what fire. During a craig-security DB/handler outage longer than the 1-hour window, the threshold event can stay silent while parking fills — the depth-floor rules fire there (the gauges are the compensating control, ADR-059 §D9).

Transport security

CRAIG is transport-neutral by design: services do not validate that their DATABASE_URL or RABBITMQ_URL declares TLS at the URL layer. Operators are free to put TLS at whichever layer matches their deployment architecture. Common patterns:

Pattern What the operator does

Direct TLS in URL (self-hosted with TLS-direct)

Set DATABASE_URL=postgres://…​?sslmode=verify-full&sslrootcert=/etc/ssl/…​/ca.crt and RABBITMQ_URL=amqps://…​. Standard sqlx + lapin handle the verification.

Sidecar mTLS (Kubernetes service mesh — Istio, Linkerd)

Services connect to localhost in cleartext (or to the sidecar’s local listener). The sidecar handles mTLS to the actual DB / MQ. CRAIG sees the cleartext URL because the sidecar terminates TLS inside the pod.

Managed DB/MQ inside private VPC (AWS RDS + AWS MQ; GCP Cloud SQL; Azure Database)

Operator chooses: enable per-URL TLS (recommended) with the cloud provider’s CA bundle, OR rely on VPC-perimeter network policies. Both are valid postures.

Devstack / docker-compose

No TLS. Containers communicate over the docker bridge network only; never exposed to the outside. Production deploys should never run this pattern.

What CRAIG does protect (independent of transport):

  • Secrets in process memory zeroize on drop (FieldEncryptor.key_bytes, WebSession.{access,refresh,id}_token) — see Plan B § F-004

  • Field-level PII encryption (cases.persons.ssn_last_four, cases.reports.{reporter_*, narrative, children, adults, raw_submission}) — see Plan B § F-001, F-002

  • IP hash salting via per-deployment HMAC secret — see Plan B § F-003

Process-memory + at-rest protection does not require transport TLS; transport TLS is recommended for production but is an operator-environment choice, not a CRAIG enforcement gate.

Database Setup

Each stateful service requires its own PostgreSQL database (craig_intake is optional — the intake edge is stateless per ADR-017 and never populates it):

CREATE DATABASE craig_cases;
CREATE DATABASE craig_composition;
CREATE DATABASE craig_exchange;
CREATE DATABASE craig_financial;
CREATE DATABASE craig_placement;
CREATE DATABASE craig_reporting;
CREATE DATABASE craig_rules;
CREATE DATABASE craig_security;

Per-service DB roles — migration-owner vs runtime (#1310, ADR-063)

The migration gate is process-mode separation; the privilege boundary behind it is TWO Postgres principals per stateful service (the per-service least-privilege shape the RabbitMQ credentials already follow, #1202):

  • a migration-owner role — owns the database (so migrations, including the trusted pg_trgm extension, apply without superuser). Used ONLY by the migration gate; every migration-created object, including sqlx’s _sqlx_migrations ledger, is owned by it.

  • a runtime roleCONNECT + USAGE on schema public + table DML through the owner’s default privileges. Used by the serving boot: the verify-only path needs SELECT on _sqlx_migrations + schema_compat_floor (covered by the same grants), and DDL under this role fails with SQLSTATE 42501 — a compromised serving process can no longer alter the schema.

Per service (names are yours to choose; devstack uses craig_<svc>_owner / craig_<svc>_app with password == role name as a PUBLIC dev fixture):

CREATE ROLE cases_owner LOGIN PASSWORD '<from your secret manager>';
CREATE ROLE cases_app   LOGIN PASSWORD '<from your secret manager>';
ALTER DATABASE craig_cases OWNER TO cases_owner;
GRANT CONNECT ON DATABASE craig_cases TO cases_app;
-- then, connected to craig_cases:
GRANT USAGE ON SCHEMA public TO cases_app;
ALTER DEFAULT PRIVILEGES FOR ROLE cases_owner IN SCHEMA public
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO cases_app;

Set the default privileges BEFORE the first gate run and every future migration-created table is DML-accessible to the runtime role with zero per-release grant churn (CRAIG has no sequences — UUIDv7 keys fleet-wide — so no sequence grants are needed). The gate’s CRAIG_<SVC>__DATABASE_URL carries the owner DSN; the serving container’s carries the runtime DSN — same variable NAME, two per-container values (the split is provisioning, not code).

Meta-table hardening (automatic since #1550): default privileges cannot exclude individual tables, so the runtime role would otherwise hold DML (not just SELECT) on the two schema meta-tables. The gate revokes INSERT, UPDATE, DELETE on _sqlx_migrations + schema_compat_floor from every non-owner grantee — PUBLIC included — on every successful verdict (idempotent; read from the table ACL itself, so it works whatever you named the runtime role and catches grants your session could not see through information_schema; SELECT stays — the verify-only boot reads both tables). No manual step remains; the statement it issues per grantee is the one the pre-#1550 guide asked operators to run by hand:

REVOKE INSERT, UPDATE, DELETE ON _sqlx_migrations, schema_compat_floor FROM cases_app;

Rotation: the two principals rotate independently. The owner credential is needed only during deploys — scope it to the deploy pipeline’s secret store and rotate per release if desired (ALTER ROLE cases_owner PASSWORD '…' between deploys is invisible to serving). The runtime credential rotates like any service secret: ALTER ROLE, then roll the serving pods. Neither rotation touches grants — privileges attach to the role, not the password.

Cutover from a shared credential (the pre-#1310 posture), per service and rollback-safe: provision both roles + ALTER DATABASE … OWNER + default privileges as above, then REASSIGN OWNED BY <old_shared_role> TO <svc>_owner; in each database (existing tables become owner-owned; grant the runtime role on them once — GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO <svc>_app; — since default privileges only cover FUTURE objects), flip the gate DSN, verify a deploy, flip the serving DSN, verify boot, then retire the shared credential. Rollback at any step = point the DSNs back. One sharp edge: REASSIGN OWNED also reassigns SHARED objects (databases, tablespaces) the old role owns, cluster-wide — run the per-database ALTER DATABASE … OWNER steps for ALL services FIRST so no database can transiently land on the wrong service’s owner.

Devstack: devstack/postgres/init.sql provisions all 16 roles (drift-pinned against the xtask service registry); the craig superuser remains the devstack ADMIN principal (seed, test-plane scratch/template databases, xtask tooling, pg_isready). Because init.sql runs only on a fresh data directory, upgrading an existing devstack across #1310 requires cargo xtask dev reseed. One operational upside: the serving roles are no longer superuser, so superuser_reserved_connections (3) now genuinely reserves admin access under connection-pool saturation.

Migration gates (ADR-063, since M3a/#1276)

Schema application is a DEPLOY phase, not a serving-startup side effect. Every service image doubles as its own migration gate: <binary> migrate (exact argv — nothing else) connects with ONE env var (CRAIG_<SVC>__DATABASE_URL — the MIGRATION-OWNER DSN since #1310; serving containers carry the runtime DSN under the same variable name), classifies the schema against its embedded migration set, and acts by verdict:

  • Exact / Ahead — no-op, exit 0. The Ahead no-op is what makes ROLLBACK safe: an older gate against a newer database does nothing instead of refusing.

  • Behind — applies the missing migrations (safe by construction — Behind is reachable only when the applied set is a strict subset of the embedded set).

  • Diverged / Dirty / checksum-mismatch — refuses with a typed message that names the remedy; exit ≠ 0.

The reference compose graph carries the gates automatically: one craig-<svc>-migrate one-shot per stateful service, sharing the app’s image: + build: via a merged fragment (a gate can never run a stale :local image the app wasn’t built from), with the app gating on service_completed_successfully. Locally, cargo xtask migrate apply [--service <name>] builds + runs the gates and reports per service (partial success is retry-safe — applied migrations re-verdict as no-ops).

Kubernetes deployments run the gate as a parallelism-1 Job or deploy hook per service before rolling the serving image. A per-pod init container is NOT equivalent — per-pod means N concurrent appliers, which recreates the boot convoy the gate removes. Production uses immutable image tags/digests so gate and app provably run the same binary.

Since M3b, serving boot is VERIFY-ONLY (a pre-1.0 breaking change, CHANGELOG’d): the gate step is mandatory and serving binaries never issue DDL. Boot verifies BEFORE any JWKS/RabbitMQ init (craig_api::bootstrap_verified — data plane → schema verdict → control plane), so an identity-provider or broker outage can never mask a schema refusal. A Behind database refuses serving startup with the gate named as the remedy; Ahead (rollback) boots. Rollback support is explicit: back to the newest release whose embedded migrations reach the schema’s schema_compat_floor (destructive contract migrations bump it; the verifier refuses binaries older than the floor with "roll forward").

Connection Pool Defaults

  • Max connections: 10 (CRAIG_<SVC>__DB_MAX_CONNECTIONS)

  • Idle timeout: 600 seconds (CRAIG_<SVC>__DB_IDLE_TIMEOUT_SECS)

  • Statement timeout: 30,000 ms (CRAIG_<SVC>__DB_STATEMENT_TIMEOUT_MS) — batch jobs extend per-transaction via SET LOCAL

  • Acquire timeout (serving pools): 5 seconds — fixed, deliberately not a knob (#1160). Under session exhaustion every acquire queues up to the full bound and fails as PoolTimedOut, quantizing overload into 5 s latency cliffs; that fail-fast cliff is the intended trade. Raising the bound would deepen the queue without adding capacity — size the budget below instead, and watch the db_pool_* instruments (Monitoring → Metrics below). The constant lives in crates/craig-db. ONE recorded exception (#1340): the ADR-063 migration gate dials with its own 60 s bound — a one-shot deploy phase should wait out a busy server (rolling deploys keep old replicas serving while the gate runs), not fail the deployment on the serving cliff.

Database connection budget (#1160)

Postgres refuses sessions beyond its server-wide max_connections, and a CRAIG deployment holds more sessions than its pool arithmetic suggests. Budget every class:

Session class Worst case Notes

Service pools

Σ DB_MAX_CONNECTIONS (default 10 × 8 stateful services = 80)

craig-web, craig-intake (edge mode), and craig-intake-keyring hold no database. Horizontal replicas multiply each service’s term.

Dedicated worker pools (ADR-062 §Amendments #1304/#1034/#1326)

+3 (craig-cases) +3 (craig-security) +8 (craig-exchange) = 14

The cases auto-link consumer (nested acquisitions), security’s fleet-wide audit + DLQ subscribers (volume), and exchange’s three domain workers (aggregate starvation: events 3 + send 3 + reconciler 2) ride dedicated pools tagged via application_name. Replicas multiply each term.

Detached advisory-lease sessions

+14 fleet-wide

PoolConnection::detach() releases the pool permit, so these sessions sit OUTSIDE pool accounting: craig-financial +4 (generator, review sweep, transport prune, reconcile sweep; +N more with the import knob on), craig-exchange +2 (transport prune, send-jobs sweep), craig-rules / craig-security +2 each (transport prune, retention-archive lease when enabled), +1 transport prune for each other stateful service.

Migration gates + verdict boot

+2 per service, transient

ADR-063: each gate one-shot holds 1 session (its minimal pool) +1 detached apply session when Behind (#1153); the serving boot’s verdict re-check adds a transient pooled read. Worst case during a rolling deploy: gates ×8 + the OLD replicas' full pools + the surge replica’s pool all coexist — budget the deploy window, not just steady state.

Concurrent list requests

2 pooled connections per request

#1138: list endpoints run page + count concurrently. This consumes POOL headroom, not extra server sessions — about 5 concurrent lists saturate a default-10 pool — but it shapes per-service pool sizing.

Operator tooling

a few

cargo xtask database commands cap their pools at 2; each psql session is one.

Superuser reserve

3

superuser_reserved_connections — unavailable to non-superuser service roles.

Rule of thumb: max_connections ≥ Σ pools + 14 + (1 × services) + tooling + 3, then margin. The devstack pins 250, with its instance arithmetic (including the pre-push battery’s scratch-DB test pools) recorded next to the knob in docker-compose.yml and floor-pinned by crates/craig-db/tests/connection_budget.rs; production deployments substitute their own pool sizes and replica counts.

Per-service pool sizes were reviewed for #1160 and stay at the uniform default 10: differentiated devstack pools would re-introduce per-service acquire cliffs under bursty e2e load for roughly 20 sessions of headroom the right-sized ceiling already provides. Production deployments size pools per service from observed db_pool_* saturation, not from this default.

RabbitMQ Configuration

Exchanges (operator-owned topology, #1202)

Two durable topic exchanges. Since #1202 they are operator-owned — services no longer declare them at runtime (granting a service configure on a shared exchange would include DELETE, letting a compromised service delete/redeclare craig.dlx and silently suppress forensic delivery). Devstack pre-declares both in devstack/rabbitmq/definitions.json; production operators MUST provision them before first boot (a missing exchange fails the first bind/publish loudly rather than being silently recreated).

Name Type Durable Purpose

craig.events

topic

true

Domain-event fan-out — every service’s outbox publishes here; consumers bind their <svc>.events queues.

craig.dlx

topic

true

Dead-letter exchange (ADR-022) — nack-without-requeue and capture publishes route here under dlq.<queue>; only craig-security binds/consumes it.

Queues

Each service declares its own queues on startup (the exchanges are operator-owned — see above). The queue names here are what the #1202 per-service credentials are scoped to (User Configuration, below).

Queue Type Bindings

craig-rules.events

competing

case.intake_created, placement.requested

craig-rules.cache.{uuid}

exclusive

rules.cache_invalidated

craig-placement.events

competing

case.created, case.assignment_changed

craig-exchange.events

competing

case.created, placement.created, eligibility.evaluated

craig-financial.events

competing

placement.activated, placement.ended, eligibility.evaluated, rules.evaluated, case.assignment_changed

craig-reporting.events

competing

case.created, placement.created, eligibility.evaluated, rules.evaluated

craig-security.events

competing

# (wildcard — subscribes to all events for audit logging)

craig-cases.events

competing

case.report_converted (ADR-062 §G4 — the auto-link consumer, #1269)

craig-composition.composition-cache.{uuid}

exclusive

composition.invalidated.{jurisdiction}

<svc>.authz-cache.{uuid}

exclusive

authz.policy_changed — every service that runs the shared authz engine (all eight) declares one

craig-security.dlq

competing

dlq.# (bound to craig.dlx — the fleet-wide dead-letter audit consumer)

craig-security.dlq.parking + craig-security.dlq.quarantine

No bindings (published via the default exchange; ADR-059 §D4) — the bounded-retry and terminal-capture companions

craig-intake is a stateless edge (ADR-017): it holds NO broker connection and forwards reports to craig-cases over HTTP. craig-web (BFF) also holds no broker connection.

Competing consumers (shared queue): use for DB-mutating handlers — ensures each event is processed by exactly one instance.

Exclusive consumers (per-instance queue): use for cache invalidation — every instance must receive the event.

Queue type and delivery limits (#1198)

The "Type" column above is the consumer pattern (competing vs exclusive). This section is about the RabbitMQ queue type property (classic vs quorum vs stream) — a different axis.

Every CRAIG queue is declared x-queue-type=classic (ADR-003 §Amendment #1198): the fleet’s redelivery semantics assume classic-no-delivery-limit, and a quorum queue’s default delivery-limit = 20 would silently drop or re-route deliveries — on the DLX audit queue that means destroying forensic records. The explicit argument makes CRAIG immune to a default_queue_type at fresh declare and turns any conflict into a loud boot failure.

Operator MUST-NOTs:

  • Do not set a vhost-level default_queue_type other than classic on a CRAIG vhost (the management-UI vhost dropdown is the one-click hazard). This is defense-in-depth — CRAIG’s explicit declarations are immune at fresh declare regardless — but a non-classic DQT will still catch any type-omitting declarer and confuse drift diagnosis.

  • Do not apply delivery-limit (or any queue-type-affecting) policies or operator policies to craig* queues. Classic queues ignore delivery limits today; the prohibition keeps the posture honest rather than accidental.

  • Do not enable quorum_queue.property_equivalence.relaxed_checks_on_redeclaration — with it on, the broker ignores x-queue-type on redeclaration and a CRAIG subscriber would silently consume from a quorum queue where classic is pinned.

  • (#1197) Do not apply message-ttl, max-length, max-length-bytes, expires, or a destructive overflow policy to craig* queues: a TTL/length policy on the no-DLX DLQ or the parking/quarantine queues silently DESTROYS forensic captures, and expires deletes the consumer-less quarantine queue outright. These remain operator conventions — services hold AMQP credentials only and cannot enforce them at runtime (ADR-059 §D1 names this failure domain).

Parking and quarantine queues (#1197)

Each DLQ subscription (production: craig-security.dlq) declares two durable classic companions at boot (ADR-059):

  • {queue}.parking — the bounded-retry holding queue. Self-draining: a client-side replayer (prefetch 1) sleeps each capture to parked_at + parking_ttl (default 10 min) and returns it to craig.dlx with a confirmed + mandatory republish. No TTL/DLX arguments — nothing to configure, no redeclare hazard.

  • {queue}.quarantine — terminal captures (permanent failures, park-cap exhaustion after ~2 h of cycles, malformed bodies with their raw bytes base64-embedded, non-canonical routes). NO consumer: operators drain it. Every quarantine publish logs at error! alongside the #1199 dlq_* metrics (triage flows: DLQ Triage Runbook); broker disk alarms are the backstop. Duplicate copies can exist by design (an ack lost after a confirmed publish); correlate on the capture’s capture_id + occurrence_token — tokenless/malformed captures have NO reliable automatic dedup.

Automated drain (#1206): cargo xtask quarantine-drain <queue> --yes [--amqp-url <url>] [--out <dir>] [--limit <n>] implements the procedure below exactly — returnable captures republish bytes-unchanged and ack only on a clean confirm; malformed captures file to a timestamped NDJSON (fsynced before their ack); any unclean confirm stops the drain with the capture left in place. Devstack runs it as the craig-test identity by default; production needs an operator-scoped --amqp-url — the service identities deliberately cannot consume quarantine (#1202), and the recommended production shape is a purpose-scoped account holding exactly read on {queue}.quarantine + write on craig.dlx. The manual procedure below remains the fallback.

Replaying quarantined captures (manual-ack AMQP procedure — never a destructive management get; a management-UI peek must use requeue mode):

  1. Consume from {queue}.quarantine with MANUAL acknowledgements.

  2. Validate the capture (payload._park): read original_routing_key (dlq.…) and inspect reason/last_error. Malformed captures (reason: "malformed") are excluded — they have no replayable route; export them to file.

  3. Republish the message bytes UNCHANGED to the craig.dlx exchange under original_routing_key, with publisher confirms + mandatory, and wait for the clean confirm.

  4. ONLY THEN ack the quarantine delivery. A nack/close before the confirm leaves the capture safely in place.

Verify the contract on a live broker (the first command shows the stored client arguments, which distinguish an explicit pin from a resolved default):

rabbitmqctl list_queues name type arguments
rabbitmqctl list_vhosts name default_queue_type
rabbitmqctl list_policies && rabbitmqctl list_operator_policies
rabbitmq-diagnostics environment | grep relaxed_checks

Failure signature and remedies:

  • Boot failure PRECONDITION_FAILED - inequivalent arg 'x-queue-type' … received 'classic' but current is 'quorum' — a queue pre-exists with the wrong type. With a backlog you care about, shovel/drain the queue first, then delete it and let the service redeclare (a bare delete destroys a durable backlog). The same signature at runtime (service up, not consuming, ERROR-level "operator action required" logs) means a mid-life type flip — same remedy.

  • Queues first declared on pre-3.13 brokers and upgraded in place may lack a stored type argument and 406 on the explicit redeclare — same shovel/drain-then-delete remedy. Queues born on 4.x brokers are unaffected (the resolved type is stored at first declare).

  • A drifted vhost DQT: rabbitmqctl update_vhost_metadata / --default-queue-type classic. Note definitions.json imports apply at first boot only — they never overwrite existing broker state, so fixing definitions alone does not repair a live vhost.

User Configuration — per-service least privilege (#1202)

Each service connects as its OWN broker account, scoped to exactly the queues it declares plus the shared exchanges it uses. A single shared account is a pre-1.0-retired anti-pattern: it let any service read every other service’s dead-letter records and (via configure on a shared exchange, which includes DELETE) delete craig.dlx and suppress forensic delivery. RabbitMQ permission patterns are a substring match, so every pattern below is anchored (^(…​)$).

An administrator-tagged operator account (here craig) is still needed for the management plane (vhost/policy administration). It is NOT a service identity — no service connects as it.

The per-op derivation: write carries craig.events (the outbox publish) and craig.dlx (every events-role queue is declared with an x-dead-letter-exchange argument, which RabbitMQ authorizes as a write on the DLX); read carries craig.events (bind reads) plus the service’s own queues; configure is the service’s own queues ONLY. craig-security additionally holds the dead-letter plane: amq.default write (the parking/quarantine capture publishes go via the default exchange), craig.dlx read (the dlq.# bind), and configure/read on its dlq/dlq.parking/dlq.quarantine (quarantine is configure-only — no consumer).

Production passwords

Devstack uses password == account-name — a PUBLIC ACL fixture for local dev, never a containment boundary (the broker port binds the host, the hashes are in the public tree). Production MUST use a unique high-entropy secret per account (openssl rand -base64 24), stored in your secret manager (Vault / k8s Secret / AWS Secrets Manager) and injected as CRAIG_<SVC>__RABBITMQ_URL. Percent-encode any reserved characters (@ : / ?) in the password before placing it in the URL.

The canonical spec is devstack/rabbitmq/definitions.json (imported at first boot, / vhost). Provision the same via the management API or rabbitmqctl; the set_permissions argument order is configure write read. Example for two representative accounts (a plain service and the audit service):

# A plain service account (craig-rules shown; the six event services follow the
# same shape with their own <svc> prefix):
rabbitmqctl add_user craig-rules "$CRAIG_RULES_BROKER_PASSWORD"
rabbitmqctl set_permissions -p / craig-rules \
  '^(craig-rules\.events|craig-rules\.cache\..*|craig-rules\.authz-cache\..*)$' \
  '^(craig\.events|craig\.dlx|craig-rules\.events|craig-rules\.cache\..*|craig-rules\.authz-cache\..*)$' \
  '^(craig\.events|craig-rules\.events|craig-rules\.cache\..*|craig-rules\.authz-cache\..*)$'

# The audit service (craig-security) — the only account on the dead-letter plane:
rabbitmqctl add_user craig-security "$CRAIG_SECURITY_BROKER_PASSWORD"
rabbitmqctl set_permissions -p / craig-security \
  '^(craig-security\.events|craig-security\.authz-cache\..*|craig-security\.dlq|craig-security\.dlq\.parking|craig-security\.dlq\.quarantine)$' \
  '^(amq\.default|craig\.events|craig\.dlx|craig-security\.events|craig-security\.authz-cache\..*|craig-security\.dlq)$' \
  '^(craig\.events|craig\.dlx|craig-security\.events|craig-security\.authz-cache\..*|craig-security\.dlq|craig-security\.dlq\.parking)$'

# The operator account (management plane only — not a service identity):
rabbitmqctl add_user craig "$CRAIG_BROKER_ADMIN_PASSWORD"
rabbitmqctl set_user_tags craig administrator
rabbitmqctl set_permissions -p / craig '.*' '.*' '.*'

The other services follow the same three-column shape scoped to their own queue names, with these differences from the craig-rules exemplar (which is the only account carrying a .cache family): craig-placement, craig-exchange, craig-financial, and craig-reporting have .events + .authz-cache only (no .cache); craig-cases carries .events + .authz-cache (its craig-cases.events queue arrived with the ADR-062 §G4 auto-link consumer, #1269); craig-composition also omits .events and carries .composition-cache + .authz-cache. Copy the exact triples from definitions.json — it is the executable source of truth.

Re-running the import or set_permissions is idempotent (a permission set replaces, never appends). Verify:

rabbitmqctl list_permissions -p /          # one row per account, triples as above
rabbitmqctl list_users                     # only craig carries [administrator]
# Per-service connect smoke (should succeed as its own identity, fail cross-namespace):
rabbitmq-diagnostics check_port_connectivity

Retiring the shared credential — ordered cutover with rollback

If you are upgrading from the pre-#1202 shared craig service credential, cut over WITHOUT downtime and keep a rollback until every service is confirmed healthy:

  1. Provision all per-service accounts + the two exchanges (above). The old shared craig credential stays valid throughout — additive, no service is touched yet.

  2. Deploy the per-service CRAIG_<SVC>__RABBITMQ_URL secrets and roll the fleet. Each service reconnects as its own identity.

  3. Verify consumption: rabbitmqctl list_consumers -p / shows each service’s queue attached under its own connection; readyz is green fleet-wide; no ACCESS_REFUSED in service logs.

  4. Retire the shared credential’s SERVICE use: confirm no connection remains authenticated as craig on the AMQP plane (rabbitmqctl list_connections user), then repurpose craig to the operator/management plane only.

  5. Scrub the old shared password from every config store.

Rollback (any step before 5): re-point the affected CRAIG_<SVC>__RABBITMQ_URL back at a still-provisioned fallback credential and redeploy — always BEFORE revoking anything, never after. Do not delete the old credential until step 5.

If using a custom rabbitmq.conf, the RABBITMQ_DEFAULT_USER environment variable will not work. Configure users via definitions.json or the management API instead.

Keycloak Configuration

Realm

CRAIG requires a craig realm with:

  • Roles (9): the six operational roles admin, supervisor, caseworker, eligibility_worker, icpc_coordinator, readonly, plus the three office-authority roles county_director, regional_director, state_office (the ADR-054 subsidy approval matrix axis). Plan E additionally requires the per-service service:<svc> realm roles — see IdP Integration

  • Clients:

    • craig-api: public, Direct Access Grant (ROPC) for the CLI and integration tests. Service-to-service auth uses per-service client_credentials principals since Plan E — see IdP Integration

    • craig-ui: public, Authorization Code + PKCE for the web UI

Token Configuration

  • Access token lifetime: 30 minutes (recommended)

  • Refresh token: enabled for craig-ui

Dual URL Pattern

In containerized deployments, Keycloak is typically accessible at different URLs from the host and from service containers:

# JWT 'iss' claim uses the public URL (what the browser/CLI sees)
OIDC_ISSUER=https://auth.example.com/realms/craig

# JWKS is fetched via the internal network
OIDC_INTERNAL_URL=http://keycloak:8080/realms/craig

Both must point to the same Keycloak realm. The iss claim in JWTs is validated against OIDC_ISSUER.

Boot-time discovery retry (#1335)

The boot-time JWKS/discovery fetch tolerates a saturated bring-up: connect/timeout-class failures retry up to 3 attempts with a 2 s pause (a starved GET on a cold host — 14 containers + a cold JVM — must not brick a service into exited (1) for everything downstream of its compose edge). Fail-loud is preserved for genuine misconfiguration: HTTP status errors, JSON-decode failures, and refused connects still surface within seconds (a refused connect fails each attempt instantly, so the whole window is ~4 s); only a fetch that is genuinely WAITING burns its per-attempt HTTP budget, giving real saturation 30-60 s of patience (one vs both GETs timing out per attempt) before the same loud failure.

Boot-time database connect retry (#1353)

The data-plane’s eager DB dial gets the same treatment: PoolTimedOut — the ONLY retried class — means the first connect starved past the serving pools' fixed 5 s acquire cliff (#1160; sqlx retries refused connects internally until that deadline, so a busy or not-yet-accepting server surfaces the same way). Three attempts with a 2 s pause bound the window at ~19 s before the loud failure. Every other error class (bad URL, DNS, TLS, auth) fails on the FIRST attempt, and the pool’s acquire policy is untouched — once serving, overload still cliffs visibly at 5 s (#1340’s migration gate remains the one acquire-bound exception).

Object Storage (S3)

Services that handle file uploads (craig-cases, craig-placement, craig-exchange) require S3-compatible object storage.

Supported Providers

  • Garage (devstack default) — Rust-based, AGPL-3.0, lightweight

  • AWS S3 — production

  • MinIO, Ceph — self-hosted alternatives

Bucket Setup

Create a single bucket for the deployment:

# Using AWS CLI
aws s3 mb s3://craig-prod --endpoint-url https://s3.example.com

# Using Garage CLI
garage bucket create craig-prod
garage bucket allow --read --write --owner craig-prod --key craig-prod-key

Object Key Format

Files are stored with the key pattern:

{service}/{entity_type}/{parent_id}/{object_uuid}/{filename}

Example: cases/contact_attachments/abc123/def456/report.pdf

Retention & Archive

#1129 / ADR-058. Two classes: transport tables prune themselves (30/31-day windows, always on — see the retention knobs in Configuration Reference); audit-class tables (audit_log, dead_letter_audit, rule_evaluations) are ARCHIVED to the object store and only then pruned, behind an explicit consent knob.

A third self-pruning class since B2 (#1194, ADR-062 §B): every service’s request_claims table, swept hourly by the request-claims-retention worker on the deployment-global CRAIGREQUEST_CLAIMSWINDOW_DAYS horizon (platform CRAIG__ prefix — one fleet-wide replay boundary, never per-service; default 30; 0 disables with an explicit Disabled registration in /healthz; an unparseable value also disables, with an error log — the pruner never guesses a horizon). The request_claims_retention_overrun invariant is the beyond-grace watchdog.

Knobs (per archiving service: craig-security, craig-rules)

Env (CRAIG_<SVC>__…) Default Meaning

RETENTION_ARCHIVE__ENABLED

false

THE consent act (D11): enables the scheduled archiver AND the security /archive/run + /archive/purge endpoints. Archiving deletes hot rows after copying them; leave OFF until the storage requirements below are met. The no-overwrite guarantee is backend-independent since #1293 (digest-verified creates — a foreign occupant at an archive key fails the batch before any prune); backends honoring If-None-Match: * (AWS S3, MinIO, R2) additionally get the native conditional-put refusal, while Garage permanently ignores it (ADR-058 §Amendment #1293).

RETENTION_ARCHIVE__HOT_WINDOW_DAYS

90

Rows older than this are archive-eligible. craig-exchange refuses boot below 365 (#1466 B5 — the SSA screening quiescence horizon); set it explicitly for exchange. Operational knob — NOT the DFCS records-retention schedule (⁂ #1073); archives are held indefinitely regardless.

RETENTION_ARCHIVE__INTERVAL_SECONDS

3600

Scheduled-pass cadence; 0 = no worker (the manual endpoints still work when enabled).

RETENTION_ARCHIVE__BATCH_SIZE

1000

Rows per batch (1..=10000); one batch = one object
one crash-atomic prune transaction.

RETENTION_ARCHIVE__MAX_BATCH_BYTES

33554432

Byte cap per archive object; a single row exceeding it alone is quarantined loudly and left in place (D13).

Storage requirements (D9 — enforced or required)

Boot REFUSES (D10 probe, hard startup failure) when the archiver is enabled and:

  • the store backend is local (a single container’s disk is not durable archive storage), or

  • a scoped write → read-back → delete probe under retention-archives/{service}/ fails.

Required by policy (not machine-checkable — verify before enabling):

  • S3-compatible storage with versioning or object lock enabled on the bucket — the recovery path for any operator error, and what makes `put_create’s no-overwrite meaningful against a misbehaving client.

  • Least-privilege credentials: the archiver needs put/get/delete/list under retention-archives/ only. The devstack’s single shared Garage credential is DEV-ONLY.

  • An encrypted bucket (SSE) — archives carry the audit trail’s PII.

  • No lifecycle rule may expire objects under retention-archives/ — deletion happens only through the D14 purge path.

Enable order

The craig-security release carrying the rules.evaluations_archived parser arms must be deployed FLEET-WIDE before CRAIG_RULESRETENTION_ARCHIVEENABLED=true — parser-first, so rules' fleet-bookkeeping events never land in a security instance that would audit-log them as unknown without ledger bookkeeping (a pre-parser instance acks the event, so its fleet copy is permanently absent — bookkeeping-only, never data loss: rules' own archive_ledger is the authority).

Reading an archive (D17)

An evaluation_id (or any archived row id) stays resolvable forever — hot via the service, cold via the owning service’s ledger id-ranges:

# One row by id (verifies the batch sha256 against the ledger first)
cargo xtask archive-fetch craig-rules rule_evaluations --id <uuid>

# Every line of every batch overlapping a time range
cargo xtask archive-fetch craig-security audit_log \
  --from 2026-01-01T00:00:00Z --to 2026-02-01T00:00:00Z

The tool reads the ledger (devstack DSN by default; --database-url otherwise) and the store via the services' own CRAIG_STORE__* contract, and emits the archived NDJSON lines VERBATIM — the manifest’s ColumnSpec schema (stored beside every object) says how to interpret five-year-old lines without the live schema.

Purge posture & attribution

Purge refuses EVERYTHING until DFCS names a records-retention schedule: every destructive statement independently requires retention_until IS NOT NULL AND retention_until ⇐ CURRENT_DATE AND NOT legal_hold (D14), and the engine writes every ledger row with retention_until = NULL. When a schedule exists, an operator sets retention_until (and legal_hold where litigation requires), then drains in bounded slices: craig archive purge --limit 100 until more is false. Every run/purge is attributed — the manual operator’s claims.sub (or system:retention for scheduled passes) lands in archived_by/purged_by and in the staged security.archive_completed / security.archive_purged events.

Orphan objects

A crash between the object puts and the prune commit leaves data+manifest objects with NO ledger row — harmless (the next pass re-archives under a fresh id). Garbage-collect ONLY against the owning service’s LOCAL ledger (D8): list retention-archives/{service}/{table}/, delete objects whose archive_id has no ledger row older than a day. Never GC from the security fleet-copies of rules' rows — archive_ledger in craig_rules is the authority for rules objects.

Scaling

Horizontal Scaling

All services are stateless and can be scaled horizontally:

  • Database connections are pooled per instance — every replica adds its full pool plus its detached-lease sessions to the server budget; re-run the arithmetic in Database Setup → Database connection budget before scaling out

  • RabbitMQ competing consumers distribute events across instances

  • Services with in-memory caches (craig-rules) use exclusive subscriber queues for cache invalidation — every instance receives the invalidation event

Considerations

  • craig-rules caches compiled rule sets in memory. After mutations, a rules.cache_invalidated event triggers every OTHER instance to run an identity-aware reconcile pass against the database (#1188: a conditional per-name apply on the trigger-enforced (id, revision) token — never a wholesale reload, so a pass can’t clobber a concurrent local mutation). Since #1216 the event staging is transactional with the rule-set write (a staging failure rolls the mutation back), and each instance ALSO runs a periodic reconcile sweep (CRAIG_RULES__DECISION_REFRESH_SECONDS, default 300) that bounds lost-event staleness to ≤ ~2×interval + pass duration under a healthy DB (with the DB unreachable the sweep warns and retries next tick); replica sweeps de-phase via a deterministic per-instance start offset.

  • Object storage is shared — all instances read/write to the same S3 bucket.

  • Keycloak JWKS is fetched at startup and auto-refreshed periodically. No inter-instance coordination needed.

Monitoring

Health Checks

All services: GET /livez (k8s liveness, always 200), GET /readyz (k8s readiness, 200/503), GET /healthz (JSON detail). See Health Checks above for the full table.

Metrics

Every service serves Prometheus text at GET /metrics. Instruments export only when OTEL_EXPORTER_OTLP_ENDPOINT is set (otherwise the endpoint returns an empty 200 — the devstack default).

Database session exhaustion is directly observable via the db_pool_* family (#1160), wired to every service’s pool by the shared bootstrap:

  • db_pool_connections_max / db_pool_connections_size / db_pool_connections_idle — scrape-time pool facts (in_use = size - idle). size == max with idle == 0 is pool saturation; size < max alongside rising acquire waits means the SERVER ceiling — the pool cannot grow because Postgres is refusing new sessions (see Database Setup → Database connection budget).

  • db_pool_acquire_wait_seconds — wait of the most recent acquire probe (a timed acquire() every 15 s).

  • db_pool_acquire_probe_timeouts_total — probes that failed to obtain a connection at all: the 5 s acquire-timeout cliff, observed rather than inferred.

sqlx additionally logs any single acquire slower than 2 s at WARN on target sqlx::pool::acquire (its built-in acquire_slow default) — a free per-request signal that complements the sampled probe.

craig-cases also exports the person-matching latency pair (#1157): person_match_candidates_per_entry and person_match_entry_eval_duration_seconds — per ranked report entry, how many candidates were seeded and how long the (bounded, 8 in flight) rules-evaluation phase took.

Logging

Structured JSON logging via tracing + tracing-subscriber:

{"timestamp":"2025-01-01T00:00:00Z","level":"INFO","target":"craig_rules","message":"server listening","addr":"0.0.0.0:8001"}

OpenAPI / Swagger UI

Each API service serves Swagger UI at /swagger-ui:

CI/CD Pipeline

The GitLab CI pipeline runs security scans and promotion only — the full test battery (validate + E2E + perf + security regression) runs in the pre-push hook on every developer machine before the push reaches GitLab. Three CI stages execute in this order:

  1. scan: SAST (GitLab template), secret detection, dependency scanning, and cargo audit advisory-DB check.

  2. promote: docker-promote builds all eleven service images on main/tag commits — the nine core services plus craig-composition (required by every deployment since ADR-035) and craig-intake-keyring (standalone-SHINES profile, ADR-042 §D8; promoted alongside the rest so the profile that needs it can pull it — #1584) — tags them with $CI_COMMIT_SHORT_SHA (or $CI_COMMIT_TAG) plus latest, and pushes to the GitLab Container Registry. On tags, sbom (CycloneDX) and release (GitLab Release) also run in this stage.

  3. deploy: review / pages build the Antora docs site; the review-app environment exposes a merge-request-scoped preview URL.

Workflow rules restrict pipeline creation to four contexts: merge-request pipelines (gating merges), main-branch pipelines (post-merge, driving docker-promote and pages), tag pipelines (releases), and scheduled pipelines (pentest/perf/cluster/triage). Feature-branch pushes without an open MR deliberately produce no pipeline — the pre-push hook already ran the full battery locally, and suppressing branch-ref pipelines avoids duplicate CI runs on the same SHA.

See the .gitlab-ci.yml file at the repository root for job definitions and runner tags.

Edit this page · latest