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
craigrealm -
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 |
|---|---|---|
|
varies |
HTTP listen port |
|
— |
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. |
|
— |
AMQP connection string |
|
— |
Public OIDC issuer URL for JWT |
|
— |
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 |
|
|
Comma-separated allowed CORS origins |
|
|
Maximum request body size in bytes |
|
— (required) |
Jurisdiction identifier used to select the rule set pack (e.g. |
|
— (required) |
Display label for the administrative-unit concept ( |
craig-rules
| Variable | Default | Description |
|---|---|---|
|
— (required) |
Jurisdiction prefix for rule set names. Plan A § Step 12: no fallback default. |
|
|
Budget for one rules evaluation dispatch, ms (valid |
|
|
Per-NODE JS interrupt bound for JDM function nodes, ms (#1046). Explicit values must sit in |
|
|
Decision-cache sweep interval, s (valid |
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 |
|---|---|---|
|
— (required; boot refuses when unset) |
Storage backend ( |
|
— |
S3 bucket name |
|
— |
S3 endpoint URL |
|
— |
S3 access key |
|
— |
S3 secret key |
|
— |
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 |
|---|---|---|
|
|
HTTP listen port |
|
— |
PostgreSQL connection string |
|
— |
AMQP connection string |
|
— |
Public Keycloak URL for JWT |
|
— |
Internal Keycloak URL for JWKS fetching |
|
— (required) |
Jurisdiction prefix for financial rules |
|
|
Comma-separated allowed CORS origins |
|
|
Maximum request body size in bytes |
craig-reporting
| Variable | Default | Description |
|---|---|---|
|
|
HTTP listen port |
|
— |
PostgreSQL connection string |
|
— |
AMQP connection string |
|
— |
Public Keycloak URL for JWT |
|
— |
Internal Keycloak URL for JWKS fetching |
|
— (required) |
Jurisdiction prefix for reporting rules |
|
|
Comma-separated allowed CORS origins |
|
|
Maximum request body size in bytes |
craig-security
| Variable | Default | Description |
|---|---|---|
|
|
HTTP listen port |
|
— |
PostgreSQL connection string |
|
— |
AMQP connection string |
|
— |
Public Keycloak URL for JWT |
|
— |
Internal Keycloak URL for JWKS fetching |
|
— (required) |
Jurisdiction prefix for security rules |
|
|
Comma-separated allowed CORS origins |
|
|
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 |
|---|---|---|
|
|
HTTP listen port |
|
— |
Public Keycloak URL for JWT |
|
— |
Internal Keycloak URL for JWKS fetching |
|
— |
Base URL of craig-cases (for forwarding authenticated reports) |
|
— |
Required (integrated mode). Base URL of craig-security (partner verification + signer-key lookup) |
|
— |
Required. OIDC |
|
— (required) |
Jurisdiction identifier (no default) |
|
— (required) |
Per-deployment HMAC secret for |
|
|
Max requests per hour from a single client IP against public (unauthenticated) endpoints. The devstack overrides this to |
|
|
Comma-separated allowed CORS origins |
craig-web (BFF)
| Variable | Default | Description |
|---|---|---|
|
|
HTTP listen port |
|
— |
craig-rules base URL |
|
— |
craig-cases base URL |
|
— |
craig-placement base URL |
|
— |
craig-exchange base URL |
|
— |
craig-financial base URL |
|
— |
craig-security base URL |
|
— |
Keycloak realm URL (for OIDC) |
|
|
OIDC client ID |
|
— |
Public URL of the web UI (for OIDC redirect) |
|
— |
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 |
|
|
Session cookie lifetime in seconds (default: 30 minutes). |
|
|
Emit the |
|
— (required) |
Theme directory name — since Plan U Step 8 (ADR-036) used only to resolve the agency logo ( |
|
— (required) |
Agency display name in the BFF chrome (e.g. "GEORGIA DEPARTMENT OF HUMAN SERVICES"). Plan A § Step 12: no fallback default. |
|
|
App display name in the BFF chrome. |
|
— |
Optional logo URL. |
|
|
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' |
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, |
Route /report and /report/* → craig-intake (:8008); all other paths → craig-web (:8080).
# 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;
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:
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):
-
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. -
Deploy the surface release (#1071 MR-B) with
CRAIG_FINANCIALSUBSIDY_IMPORTENABLEDUNSET/false — it is knob-off inert (every import write refuses with a typed 403 naming the knob; GETs work).
The conversion window:
-
Enable — set
CRAIG_FINANCIALSUBSIDY_IMPORTENABLED=trueon craig-financial for the window. This gates ALL import writes including finalize; flipping it is the operator’s recorded consent (configuration reference). -
Stage — run
cargo xtask import-subsidy-historywith 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 isApprovalAction::ImportHistoryat state_office floor; an admin session without thestate_officerole 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. -
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) andaged_suspensionwarnings: 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. -
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 itspayment_cutover_month, review slots materialized from the imported anchors (the conversion baseline — SHINES keeps pre-cutover review history). -
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 |
|---|---|---|
|
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. |
|
200 when no Critical worker is dead/missing AND DB + MQ report healthy; 503 with
|
Kubernetes readiness probe and load-balancer signal. Failure → remove from
service rotation without restart. Compose |
|
200 with detailed JSON: |
Human/dashboard view. Always 200; the body’s |
Readiness
Services report ready (/readyz → 200) when:
-
Database migrations have completed
-
JWKS has been fetched from Keycloak
-
RabbitMQ connection is established (services with MQ; craig-web omits)
-
The HTTP listener is bound
-
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 |
|---|---|---|
|
~1 s poll |
> 60 s |
|
hourly sweep |
> 3 h |
|
1 h refresh |
> 3 h |
|
configured discovery interval |
> 3× the interval |
|
|
> 6× the sweep period |
|
30 s consumer-liveness tick while a consume session is live (#1321; silent during reconnect backoff) |
> 120 s |
|
configured refresh interval (devstack 15 s; default 300 s) |
> 3× the interval |
|
hourly |
> 3 h |
|
5 min pass; beats only on a clean pass |
> 30 min |
|
~1 s poll |
> 60 s |
|
configured scan interval (60 s default; absent when Disabled) |
> 3× the interval |
|
configured schedule; EXECUTED runs only — a replica behind a peer-held lease stays silent |
> 3× the interval, alerting on max() across replicas |
|
hourly + jitter; absent when the archive knob is off |
> 3 h |
|
~1 s poll (knob-gated; absent when |
> 60 s |
|
hourly; EXECUTED passes only (a lock-skip stays silent); absent when
|
> 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 nonzeroevictedrate means stored ruleset content stopped compiling (direct-SQL writes — the API pre-validates); sustainedfailedmeans 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 therule_evaluationsdisposition 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 staystimeouthere while its row becomeslate_completed. Infra-class errors (Db/staging/compile/not-found) count nothing. Alert on thetimeout/runtime_errorrate relative tocompleted.
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 |
Sidecar mTLS (Kubernetes service mesh — Istio, Linkerd) |
Services connect to |
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_trgmextension, apply without superuser). Used ONLY by the migration gate; every migration-created object, including sqlx’s_sqlx_migrationsledger, is owned by it. -
a runtime role —
CONNECT+USAGEon schemapublic+ table DML through the owner’s default privileges. Used by the serving boot: the verify-only path needsSELECTon_sqlx_migrations+schema_compat_floor(covered by the same grants), and DDL under this role fails with SQLSTATE42501— 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. TheAheadno-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 —Behindis 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 viaSET 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 thedb_pool_*instruments (Monitoring → Metrics below). The constant lives incrates/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 |
Σ |
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 |
Detached advisory-lease sessions |
+14 fleet-wide |
|
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 |
|
Superuser reserve |
3 |
|
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 |
|---|---|---|---|
|
topic |
true |
Domain-event fan-out — every service’s outbox publishes here; consumers bind their |
|
topic |
true |
Dead-letter exchange (ADR-022) — nack-without-requeue and capture publishes route here under |
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 |
|---|---|---|
|
competing |
|
|
exclusive |
|
|
competing |
|
|
competing |
|
|
competing |
|
|
competing |
|
|
competing |
|
|
competing |
|
|
exclusive |
|
|
exclusive |
|
|
competing |
|
|
— |
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_typeother thanclassicon 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 tocraig*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 ignoresx-queue-typeon 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 destructiveoverflowpolicy tocraig*queues: a TTL/length policy on the no-DLX DLQ or the parking/quarantine queues silently DESTROYS forensic captures, andexpiresdeletes 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 toparked_at + parking_ttl(default 10 min) and returns it tocraig.dlxwith 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 aterror!alongside the #1199dlq_*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’scapture_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):
-
Consume from
{queue}.quarantinewith MANUAL acknowledgements. -
Validate the capture (
payload._park): readoriginal_routing_key(dlq.…) and inspectreason/last_error. Malformed captures (reason: "malformed") are excluded — they have no replayable route; export them to file. -
Republish the message bytes UNCHANGED to the
craig.dlxexchange underoriginal_routing_key, with publisher confirms +mandatory, and wait for the clean confirm. -
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. Notedefinitions.jsonimports 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 |
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:
-
Provision all per-service accounts + the two exchanges (above). The old shared
craigcredential stays valid throughout — additive, no service is touched yet. -
Deploy the per-service
CRAIG_<SVC>__RABBITMQ_URLsecrets and roll the fleet. Each service reconnects as its own identity. -
Verify consumption:
rabbitmqctl list_consumers -p /shows each service’s queue attached under its own connection;readyzis green fleet-wide; noACCESS_REFUSEDin service logs. -
Retire the shared credential’s SERVICE use: confirm no connection remains authenticated as
craigon the AMQP plane (rabbitmqctl list_connections user), then repurposecraigto the operator/management plane only. -
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 rolescounty_director,regional_director,state_office(the ADR-054 subsidy approval matrix axis). Plan E additionally requires the per-serviceservice:<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-serviceclient_credentialsprincipals 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
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 |
|---|---|---|
|
|
THE consent act (D11): enables the scheduled archiver
AND the security |
|
|
Rows older than this are archive-eligible. craig-exchange refuses boot below |
|
|
Scheduled-pass cadence; |
|
|
Rows per batch (1..=10000); one batch = one object |
|
|
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_invalidatedevent 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 == maxwithidle == 0is pool saturation;size < maxalongside 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 timedacquire()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:
-
craig-rules: http://localhost:8001/swagger-ui
-
craig-cases: http://localhost:8002/swagger-ui
-
craig-placement: http://localhost:8003/swagger-ui
-
craig-exchange: http://localhost:8004/swagger-ui
-
craig-financial: http://localhost:8005/swagger-ui
-
craig-reporting: http://localhost:8006/swagger-ui
-
craig-security: http://localhost:8007/swagger-ui
-
craig-intake: http://localhost:8008/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:
-
scan: SAST (GitLab template), secret detection, dependency scanning, and
cargo auditadvisory-DB check. -
promote:
docker-promotebuilds all eleven service images on main/tag commits — the nine core services pluscraig-composition(required by every deployment since ADR-035) andcraig-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) pluslatest, and pushes to the GitLab Container Registry. On tags,sbom(CycloneDX) andrelease(GitLab Release) also run in this stage. -
deploy:
review/pagesbuild 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.