Local Development

On this page

Prerequisites

  • Rust 1.97.0 (edition 2024): rust-toolchain.toml pins the exact version, so rustup installs the right one automatically

  • cargo-nextest: cargo install cargo-nextest

  • Docker Desktop (Windows/macOS) or Docker Engine (Linux), with Compose v2

  • glab CLI for GitLab issue and MR management: install from the GitLab CLI releases

  • Git hooks: git config core.hooksPath .githooks (runs fmt, clippy, build, and nextest before push)

  • mold linker (Linux only, optional, #1371): a per-machine opt-in that speeds up cargo build/check/test; see Linux + mold linker below

First-Run Setup

  1. Clone: git clone git@gitlab.com:gadhs/application/ccwis/craig.git && cd craig

  2. Set hooks path: git config core.hooksPath .githooks

  3. Copy env template: cp .env.example .env (edit if needed — defaults work for devstack)

  4. Build workspace: cargo build --workspace --locked

  5. Start devstack: cargo xtask dev reload

Linux + host.docker.internal (/etc/hosts)

host.docker.internal is a Docker Desktop feature; the Docker daemon on Linux does not provide it. The devstack’s browser-facing URLs (Keycloak’s issuer, craig-web’s redirect URI) use that name so one configuration serves host browsers and Playwright-in-Docker alike. On Linux, host-side tooling needs the mapping added once:

echo '127.0.0.1 host.docker.internal' | sudo tee -a /etc/hosts

Without the mapping, browser logins and host-side CLI or test calls fail with could-not-resolve errors for host.docker.internal. Containers are unaffected: compose maps the name via extra_hosts: host-gateway wherever a container needs it (the Playwright e2e container, for example). If DNS resolves but connections time out instead, you are looking at the UFW problem below.

Linux + UFW (host firewall)

Docker Desktop on macOS/Windows handles container-to-host routing transparently, but Docker Engine on Linux with UFW enabled blocks user-defined bridge networks from reaching host-published ports. The symptom: cargo xtask e2e hangs with page.goto: Timeout while navigating to http://host.docker.internal:<port>/…​;; DNS resolves, but the TCP connection times out.

cargo xtask dev start prints a warning when it detects active UFW. To unblock docker-bridge traffic without disabling the firewall:

# Allow forwarding between docker bridges and the host
sudo ufw default allow routed
sudo ufw reload

Or, if you prefer a tighter rule that only opens docker bridge interfaces:

sudo ufw route allow in on br+ out on br+
sudo ufw reload

Check sudo ufw status verbose to confirm. Firewalld/nftables users may need the equivalent rule for their stack.

Linux + mold linker

The mold linker (-fuse-ld=mold) is several times faster than the platform-default ld.bfd on this 66-crate workspace, which matters most on the link-time tail of every incremental cargo build/check/test. It is an optional local speedup, not a requirement: .cargo/config.toml deliberately commits no target.*.rustflags for it.

Leaving it uncommitted is a deliberate choice (#1371, from the MR !1310 review), not an oversight. Cargo picks ONE rustflags source, first found wins: CARGO_ENCODED_RUSTFLAGS, then RUSTFLAGS, then target.<triple>.rustflags, then build.rustflags. But when the same array key appears in multiple config files, the values join rather than replace, and higher-precedence entries land last. A committed target.<triple>.rustflags = […​-fuse-ld=mold] therefore could not be cleanly reverted by a personal ~/.cargo/config.toml override: the project’s entry would always land last in the merged array, and because the linker driver honours the last -fuse-ld flag it sees, mold would win no matter what a personal config tried to set. Committing the flag would have made mold a de facto mandate for every Linux machine, with no working opt-out.

Opt in locally

Add it to your own (gitignored, outside the repo) ~/.cargo/config.toml:

# Debian/Ubuntu
sudo apt install mold

# Arch
sudo pacman -S mold

# Fedora
sudo dnf install mold
# ~/.cargo/config.toml
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]

[target.aarch64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]

This works cleanly (unlike the reverse case above) precisely because the project contributes no rustflags of its own to join with: your personal config is the only source. Add the musl triples too if you cross-build for Alpine targets locally.

Alternatively, per-invocation via the env var (which replaces config rustflags wholesale rather than joining):

RUSTFLAGS="-C link-arg=-fuse-ld=mold" cargo build

Docker: always on

The Alpine/musl Docker builder stage (Dockerfile) forces mold unconditionally: apk add mold plus ENV RUSTFLAGS="-C link-arg=-fuse-ld=mold" on the shared chef base stage, so both the cargo chef cook layer and the final build see identical rustflags (a mismatch would invalidate the ADR-039 dependency cache). No local install or opt-in is needed to build images. This is also where the win matters most: CI on RUNNER_SMALL was the original out-of-memory pressure that motivated #1371.

Running Locally

  • Start/rebuild all containers: cargo xtask dev reload (builds images, starts containers, initializes Garage, waits for health)

  • Restart services only (no rebuild): cargo xtask dev restart

  • Smart minimum rebuild: cargo xtask dev refresh (reads SHA-256 markers; rebuilds only what changed)

  • Run E2E tests: cargo xtask e2e (checks health, starts if needed, rebuilds Playwright container, runs tests)

  • Commands are idempotent: safe to re-run at any time

Never invoke docker compose …​ directly to bring up, rebuild, or restart devstack services; always go through cargo xtask dev …​. The dev subcommands wrap docker compose with environment loading from .ports.env, the auto-generated ephemeral-port mapping. A direct docker compose up or restart recreates containers with the default static port mappings instead of the ephemeral ports, which breaks every test that resolves service URLs through .ports.env. Use dev reload (whole stack, always correct), dev restart-service <name> (fast, but does not pick up new images), dev reseed (wipe data and recreate with cached images), or dev start (cold start) instead.

Read-only docker commands (docker logs, docker exec, docker ps, docker port) are fine; they do not mutate compose state.

When to use reload vs restart vs refresh

dev refresh (Plan K F-043 / Step 4) is the daily-driver third lifecycle command, alongside reload and restart. It reads the same .devstack/*.sha256 markers that dev start writes, works out what actually changed, and dispatches the cheapest compose action that covers the drift. reload and restart stay available as escape hatches.

Situation Command Why

I edited .rs in services/, crates/, or tools/

dev refresh

Cached rebuild of changed images only; reuses .ports.env

I edited static assets (services/craig-web/static/, services/craig-intake/static/)

dev refresh

Cached rebuild; baked into image via Dockerfile COPY

I changed Cargo.toml / Cargo.lock

dev refresh

No-cache rebuild (deps changed)

I changed Dockerfile

dev refresh

No-cache rebuild

I changed docker-compose.yml or devstack/ configs

dev refresh

Cached rebuild + compose down first (forces re-create)

I added a new migration .sql (no edits to existing)

dev refresh

Cached rebuild; new migration applies on startup

I edited or deleted an existing migration .sql

dev refresh

Wipes volumes + rebuilds + reseeds (destructive migration)

I changed rulesets/ only

dev refresh

Cached rebuild; the recreated seed one-shot re-runs and the convergent rules phase creates/updates rule sets in place (#1118 — no data wipe)

I changed tools/craig-seed/src/ or tests/fixtures/ only

dev refresh

Triggers dev reseed (data wipe + seed) under the hood

Nothing changed; I just want to check state

dev refresh

Reports "already up to date"; no Docker action

The .devstack/ markers are gone (a failed reseed/converge left them cleared, dev clean ran, or a concurrent cargo xtask dev run clobbered them)

dev refresh

Prints a [refresh] WARN, then a conservative cached rebuild of all services — data volumes preserved, no reseed, no port churn — and rewrites the markers so the next refresh is incremental again

I want to force a full rebuild regardless of markers

dev reload

Cached compose down + compose up --build

I want a clean slate (wipe data AND images)

dev restart

Full teardown + cold no-cache rebuild

I only want to restart one service

dev restart-service <name>

compose restart <svc> + health-wait

Every lifecycle verb that converges the stack rewrites the markers: dev start, dev refresh, dev reload, dev restart, and dev reseed. The blessing lives in bring_up’s success arm, after the health wait and seed verification, so a written marker always describes the running stack (#1405). `dev status shows what refresh would do without acting, and dev start --force bypasses the staleness check entirely. When the markers are absent, dev refresh no longer aborts (#955); it degrades to the conservative cached rebuild described above rather than forcing you back to a heavyweight dev start, which would reseed and churn the ephemeral ports.

Source: xtask/src/devstack_guard.rs::auto_refresh (refresh dispatch), xtask/src/devstack.rs::ensure_ready (start dispatch).

Seed integrity works in two layers. First, cargo xtask dev start/refresh (and the pre-push validate battery) poll the craig-seed one-shot to a clean exit; the check is state-aware, so a still-running seed is never misread as success. Second, the battery runs cargo xtask verify-seed --expect keyed to assert the seeded PII is genuinely encrypted at rest. The devstack has run field encryption end-to-end since epic &66 C7: the devstack/field-key.env key is mounted into craig-cases and craig-seed (only those two), both with ENCRYPTION_MODE: required. A key change or a missing .devstack/field-key.kcv marker forces the full-volume wipe and keyed reseed automatically, and since ADR-064 the key itself is adopted from the repo-committed encrypted store for recipients, so a committed rotation drives the same wipe-and-reseed on the next mutating command. See the quality-gates "Field-encryption seed verification gate" for the gate detail and how to run verify-seed manually.

Multi-backend identity stack (Authentik + ZITADEL)

Opt-in profile for the IdP-neutral integration tests. Brings up Authentik 2026.2.2 + ZITADEL 4.15.0 + a Redis dep alongside the existing devstack. Default cargo xtask dev start is unchanged — these containers are heavy and only needed when verifying the cargo xtask identity render output against real backends.

cargo xtask dev multibackend-up
# wait ~90s for Authentik first-boot blueprint apply (ZITADEL boots in ~30s)
cargo nextest run -p craig-test-lib --test identity_multibackend
cargo xtask dev multibackend-down

Per coding-conventions.md § Docker & DevStack, always activate through cargo xtask dev — never invoke docker compose --profile identity-multibackend directly. See docs/modules/ROOT/pages/plans/idp-multibackend-tests.adoc for the plan + devstack/authentik/README.md + devstack/zitadel/README.md for backend-specific notes (Authentik applies a declarative blueprint at boot; ZITADEL requires a one-time operator-side Terraform apply with a PAT extracted from the Console).

Devstack Staleness Guard

cargo xtask dev start and dev status run a staleness check backed by SHA-256 marker files in .devstack/ (gitignored). The guard hashes inputs at several granularities and picks the minimum rebuild action needed:

Category Paths hashed Action on change

Source code

services/, crates/, tools/ (.rs)

Cached rebuild

Dependencies

Cargo.toml, Cargo.lock

No-cache rebuild

Dockerfile

Dockerfile

No-cache rebuild

Compose config

docker-compose.yml

Cached rebuild + compose down

Infrastructure

devstack/

Cached rebuild + compose down

Rulesets

rulesets/

Cached rebuild; seed re-run converges the rules phase (no wipe, #1118)

Static assets

services/craig-web/static/, services/craig-intake/static/

Cached rebuild

Migrations (additive)

new .sql files

Cached rebuild (applied on start)

Migrations (destructive)

modified/deleted .sql

Volume wipe + reseed

Seed

tools/craig-seed/src/, tests/fixtures/

Reseed only

Field-encryption key

devstack/field-key.env KCV vs .devstack/field-key.kcv (absent or changed)

Volume wipe + keyed reseed

Committed secrets store

secrets/dev.yaml key vs the local key (ADR-064; consulted BEFORE this table)

Adoption: teardown → publish new key → cold start + reseed

Markers are (re)written by every lifecycle verb that converges the stack (dev start/reload/restart/reseed via bring_up’s success arm, `dev refresh via auto_refresh — #1405). Use cargo xtask dev status to see what would happen on the next dev start without acting. Use cargo xtask dev start --force to skip the staleness check and do a full rebuild.

Devstack lifecycle logic lives in xtask/src/devstack.rs (bring_up, tear_down, reseed, wait_for_health, verify_seed, ensure_ready). Garage bootstrap is NOT an xtask concern: it runs as the in-graph garage-init compose one-shot (devstack/garage-init/, C4-gate #1012), which every docker compose up path executes before the gated seed. devstack_guard owns only the staleness detection and marker files. Non-CLI callers (pre-push validate, etc.) that need a ready devstack should call devstack::ensure_ready(false). The cargo xtask dev CLI in cmd::dev is a thin shell that dispatches into devstack::*.

Postgres data volume + seed idempotency (ADR-048 §D1/§D2)

Postgres persists in a named postgres-data volume (mounted at /var/lib/postgresql, the path postgres:18-alpine declares). A plain docker compose down && up KEEPS the data; only dev clean / dev reseed / dev restart (which pass down -v) wipe it.

One-time transition (pre-1.0 break). If your devstack predates this change, its data lives in the image’s anonymous volume. Run cargo xtask dev clean --confirm once before the next dev start to drop it cleanly. A bare dev start also works (it re-migrates and re-seeds onto the fresh named volume) but leaves the old anonymous volume dangling; reclaim that with docker volume prune. Note that dev clean, like every down -v path, clears the .devstack/ staleness markers but PRESERVES .devstack/snapshots/ (#1013), so your migrate snapshot pg_dump backups survive every lifecycle command. The wiped data volumes are a separate matter: restore a snapshot with cargo xtask migrate rollback after the reseed if you want the old data back.

Idempotent, fail-loud seed. The one-shot craig-seed container writes a per-database public._seed_marker (a content-fingerprint identity covering the generated SQL, the seed and family counts, and the on-disk tests/fixtures/ and seed.sh) and runs psql under ON_ERROR_STOP. A re-run against a current fixture therefore skips cleanly, while a genuine SQL error or duplicate key fails loud instead of silently rolling back.

If you edit tools/craig-seed/src/, seed.sh, or tests/fixtures/, StalenessReport.seed flips to Reseed. Both dev start (via decide_action’s `StartAction::SeedOnlyReseed, #1374) and dev refresh (via auto_refresh) detect this and drive a tear_down(false) + bring_up reseed cycle themselves, wiping just the data volumes so the rebuilt seed image converges against a clean slate in one call; no manual cargo xtask dev reseed is needed. (Before #1374, dev start consulted only .volume and .rebuild, so seed-only drift reused the stale volume and craig-seed’s identity guard refused with "run cargo xtask dev reseed`", looping forever under `cargo xtask e2e’s start-and-retry fallback. `dev refresh was never affected, since auto_refresh already consulted .seed.)

rulesets/ is the exception (#1118). The rules phase carries its own fingerprint in the craig_rules rules marker and is convergent: the re-run creates or updates each rule set via the craig-rules API (JDM-validated, cache-swapped, change-notified) to the fixture’s (name, version), verifies a fixture-versus-DB receipt, and only then rewrites its marker. A ruleset version bump therefore lands on dev refresh with no volume wipe. A rules-phase failure is fatal — the seed exits non-zero and the C4 gate refuses the stack — never a silently kept stale version.

Every local path that runs the seed checks its exit code: dev start/reload/reseed/restart and validate via bring_up, and dev refresh via auto_refresh. Since the C4 gate (#1012) the compose graph itself enforces the same thing declaratively: craig-web gates on craig-seed: service_completed_successfully, and the seed gates on the in-graph garage-init one-shot. So even a raw docker compose up — CI’s path, with no xtask involved — fails loud on a bad seed, and the seed’s attachment upload can never race an uninitialized bucket on a first start.

Source: xtask/src/devstack_guard.rs (guard — unit-tested per dimension), xtask/src/devstack.rs (lifecycle).

Storage layer (#1125, rescoped by #1148)

The devstack postgres runs with synchronous_commit at its default (on), plus modest shared-buffer and checkpoint easing. This applies to the devstack and CI only; production deployments own their own postgres tuning.

The history behind that default is worth knowing. #1125 originally set synchronous_commit=off after measuring one WAL fdatasync at 1.1 ms idle but 5,068 ms under concurrent I/O (k6 create_referral p95: 8 ms idle rising to 9.5 s loaded). The #1129 root-cause analysis then reframed that measurement: fsync on this box is ~718 µs/op even at load average 25 (pg_test_fsync, 2026-07-26), and the multi-second events are episodic host page-cache transients from co-tenant memory pressure, not a chronic function of load. #1148 restored the default: muting a durability semantic in a system-of-record test environment to absorb a minutes-per-day transient was the wrong trade, and it masked exactly the commit-path behavior the battery exists to observe. The transient class is absorbed where it belongs, in the e2e retry policy (retries: 1, unconditional since the #1129 analysis).

Connection ceiling (#1160): the devstack postgres also raises max_connections to 250. The stock 100 was under water for this stack — 8 services with pool size 10 each, around 14 detached advisory-lease sessions outside pool accounting, plus the api suites' live-DB read pools — which surfaced as 5-second acquire-timeout cliffs during the pre-push battery. Since #1403 the battery’s scratch-DB churn pools live on the dedicated postgres-test instance instead (with its own 150-connection floor). The full arithmetic is recorded next to the knob in docker-compose.yml; the floor is pinned by crates/craig-db/tests/connection_budget.rs, and the production sizing formula lives in the deployment guide’s "Database connection budget" section.

Optional host-side mitigation for btrfs boxes: Docker named volumes live under /var/lib/docker/volumes and inherit COW + compression, which amplifies database fsync cost. The standard exemption (the same technique commonly applied to build-artifact dirs like a cargo target/): make volumes/ a dedicated subvolume marked chattr +C (NoCOW — applies to newly created files only, so recreate volumes after flipping it; note NoCOW also disables btrfs data checksums for those files). This is a per-machine choice, not repo state.

Healthchecks and resource limits (#1135): every devstack healthcheck carries a start_period (probes during it don’t count toward unhealthy) sized for build-saturated cold starts — keycloak’s is 120 s because --import-realm under concurrent image-build I/O routinely exceeds its JVM-boot window. Compose-level CPU/memory limits were considered and REJECTED for now: dev boxes vary too widely for one set of numbers, limits would slow the builds that share the box, and the async-commit change above removed the sensitivity that made contention visible. Revisit if healthcheck flakes return.

Secrets: the repo-committed encrypted store + the field-encryption key (ADR-064, ADR-048 §D5)

The devstack field-encryption key has TWO sources, resolved in this order on every mutating lifecycle command (dev start/reseed/refresh/reload/restart/restart-service, plus e2e’s and validate’s ensure_ready):

  1. The repo-committed encrypted store (secrets/dev.yaml, sops + age, policy in .sops.yaml) — for store recipients (maintainers + CI). If your age identity (~/.config/sops/age/keys.txt) decrypts the store, the store’s key is adopted into the gitignored devstack/field-key.env automatically; if the store key CHANGES (a rotation commit), the next mutating command tears the stack down (volumes wiped FIRST), publishes the new key atomically, and cold-starts + reseeds under it. Rotation is a git commit — no manual key handling.

  2. Generate-once fallback — for contributors WITHOUT an identity (positively classified: no keys.txt), and for identities in the joining window (not yet added as a recipient): a random key is generated once-if-absent, written atomically, hardened to 0600 (best-effort
    a warning on native Windows — use WSL2). An existing file is validated and reused, never re-keyed; symlinks and loose permissions are refused/repaired. Contributor devstacks are fully functional on this path — the store only ADDS shared-key semantics.

The key is live (epic &66 C7): compose env_file-mounts it into craig-cases + craig-seed — only those two — and both run ENCRYPTION_MODE: required, so every devstack seed and every cases write path is genuinely encrypted, and a missing key file fails docker compose up outright. At boot, craig-cases verifies the mounted key against the seeded data’s crypto_key_lineage anchor and refuses to start on a mismatch (ADR-048 §D3; the refusal names the remedy). The KCV fingerprint (never key material) rides .devstack/field-key.kcv; an absent/changed KCV escalates to the volume wipe + keyed reseed, exactly as before. cargo xtask dev status reports store drift and in-flight adoptions read-only (no docker/sops invocation).

Workflows

  • First run (recipient): nothing extra — the first dev start adopts the store key. If your identity is not yet a recipient, you get the fallback path plus the joining handoff below.

  • First run (contributor, no identity): nothing extra — generate-once, exactly as always.

  • Daily: nothing — the warm-path marker (.devstack/secrets-adoption.v1.json) makes the check hash-cheap; sops runs only when the store or your key actually changed.

  • Joining (becoming a recipient): run cargo xtask secrets init and post the printed PUBLIC key; an existing recipient runs cargo xtask secrets add-recipient <age1…​> and commits. Until then your devstack uses the fallback key (and the seed data differs from store-keyed boxes — expected).

  • Leaving (revoking a recipient): another recipient runs cargo xtask secrets remove-recipient <age1…​> — this is REAL revocation: the store re-encrypts from scratch (fresh sops data key) AND the field-encryption key value rotates in the same pass. Git history remains readable to the removed identity forever (removal protects only future values). Self-removal is refused; the recipient floor is 3.

  • Rotating the secret: cargo xtask secrets edit → commit → any mutating devstack command consumes the transition (CI picks it up on its next pipeline via CRAIG_CI_AGE_KEY). A crash mid-adoption resumes from the root-level .craig-pending-adoption.v1.json record at the next mutating command.

  • Edit limits: secrets edit opens $EDITOR INSIDE the devtools container (terminal editors only — vi is the in-container fallback; GUI editors do not work). A no-change close is a clean no-op.

  • Forks: a fork can never rewrap the upstream store — run cargo xtask secrets fork-rebootstrap --force once and mint fork-own identities.

Hygiene

  • Never paste an AGE-SECRET-KEY-1…​ line into a shell command (argv + history leak) — the tooling only ever reads identities from files. Keep ~/.config/sops/age/ at 0700/0600.

  • CI logs never carry key material by design (the store tooling prints KCVs — HKDF-derived check values — and paths only); treat any plaintext key sighting in a log as an incident.

  • The dev identity is SHARED with canopy (keys.txt is one file) — compromise or rotation of that identity couples both projects (accepted threat model, ADR-064; ratified on #1031).

Source: xtask/src/field_key.rs (generate-once provisioning, hardened load), xtask/src/field_key_reconcile.rs (store adoption: warm marker, pending record, fail-closed matrix), xtask/src/devstack.rs::consume_store_transition (lifecycle wiring), xtask/src/cmd/secrets.rs (the operator command family), xtask/src/devstack_guard.rs (KCV staleness). Decision record: ADR-064.

Local DB snapshot/rollback (Plan K F-045)

cargo xtask migrate wraps pg_dump/pg_restore for the 8 stateful CRAIG service databases: craig_cases, craig_composition, craig_exchange, craig_financial, craig_placement, craig_reporting, craig_rules, and craig_security. The list derives from the ONE typed registry in xtask/src/registry.rs (since #1308, which also fixed craig_composition having been silently skipped); craig_intake is the stateless edge per ADR-017 and is excluded. The postgres binaries run inside the devstack postgres container via docker exec, so the host does not need a matching pg_dump installed. Since #1276 the same command family carries cargo xtask migrate apply [--service <name>], which builds and runs the ADR-063 one-shot migration gates with per-service reporting.

Subcommand What it does When to use

cargo xtask migrate snapshot

pg_dump -F c --no-owner --no-privileges of each DB into .devstack/snapshots/<UTC-timestamp>/. Writes manifest.json with git HEAD SHA + each DB’s latest _sqlx_migrations.version.

Before applying a risky/experimental migration locally.

cargo xtask migrate rollback [--id <ts>] [--db <name>…​] [--force]

pg_restore --clean --if-exists from the most recent snapshot (or --id <ts> for a specific one), connecting as the service’s MIGRATION-OWNER role (#1310 — with --no-owner, restored objects belong to the connecting role, so they keep the runtime role’s default-privilege DML grants and stay alterable by future gates). --db restores a subset. Bails if the live schema is ahead of the snapshot’s recorded _sqlx_migrations head (override with --force).

When a migration breaks something and you want your data back.

cargo xtask migrate list

Prints available snapshots: timestamp, entry count, total bytes, git short SHA, per-DB schema version.

Inventory between sessions.

Since #1310 the devstack runs the ADR-063 privilege boundary: each service database is owned by craig_<svc>_owner (which the migration gates connect as) while serving containers connect as craig_<svc>_app, which can run DML only — DDL refuses with error 42501. devstack/postgres/init.sql provisions the 16 roles on a FRESH data directory only, so upgrading an existing devstack across #1310 requires cargo xtask dev reseed. The craig superuser stays the devstack admin principal for the seed, the test-plane scratch/template databases, and xtask tooling.

The schema-version-ahead bail (Plan K cross-cutting invariant #4) catches the silent-corruption case where the local devstack has applied a migration after the snapshot was taken — restoring older data against a newer schema can violate freshly-added CHECK constraints or NOT-NULL columns. --force overrides for the "I know my migration just broke and want the old data back" case; the operator owns the consequences.

Snapshots live under .devstack/snapshots/ (gitignored) and SURVIVE dev clean/reseed/restart (#1013: clear_markers preserves the snapshots/ subdirectory while clearing the staleness markers around it). pg_dump/pg_restore invocations are gated by the same exclusive xtask lock as dev start/dev reload/etc., so a concurrent rebuild can’t race a snapshot.

Source: xtask/src/cmd/migrate.rs (port of canopy xtask/src/cmd/migrate.rs, with single-shared-postgres adaptation + CRAIG-side schema-version invariant; 11 unit tests).

Container Management

Infrastructure Services

Service Image Ports Healthcheck

postgres

postgres:18-alpine (custom Dockerfile)

5432

pg_isready -U craig

rabbitmq

rabbitmq:4.2-management-alpine (custom)

5672, 15672

rabbitmq-diagnostics -q ping

keycloak

quay.io/keycloak/keycloak:26.5 (custom)

8180

HTTP GET /realms/craig

garage

dxflrs/garage:v2.2.0 (custom)

3900 (S3), 3903 (Admin)

/garage status

Application Services

Each stateful service depends on its ADR-063 migration gate (service_completed_successfully) plus postgres, rabbitmq, and keycloak (service_healthy). The rules, cases, exchange, reporting, and security services also depend on garage, and everything but craig-rules depends on craig-rules itself, whose authz engine must be up first. craig-intake is the exception: as the stateless edge (ADR-017) it has no postgres, rabbitmq, or garage edges, and depends only on keycloak, craig-cases, and craig-security. docker-compose.yml is authoritative.

Service Port Database Extra Env

craig-rules

8001

craig_rules

JURISDICTION=georgia

craig-cases

8002

craig_cases

RULES_ENGINE_URL, CRAIG_STORE__*, BODY_LIMIT=50MiB

craig-placement

8003

craig_placement

RULES_ENGINE_URL=http://craig-rules:8001

craig-exchange

8004

craig_exchange

CRAIG_STORE__*, BODY_LIMIT=50MiB

craig-financial

8005

craig_financial

JURISDICTION=georgia

craig-reporting

8006

craig_reporting

craig-security

8007

craig_security

craig-intake

8008

— (stateless, ADR-017; the pre-created craig_intake DB is never populated)

CASES_URL, SECURITY_URL, CLIENT_ID/SECRET, IP_HASH_SECRET, TRUSTED_PROXIES

craig-composition

8009

craig_composition

composition layer engine (ADR-035; Plan X)

craig-intake-standalone

8010 (container 8009)

MODE=standalone, forwards to craig-intake:8008

craig-intake-standalone-shines

8011 (container 8009)

MODE=standalone, BACKEND_PROFILE=shines, forwards to the SHINES mock (ADR-042)

Keycloak URL Pattern

Object Storage (Garage)

  • The Garage image is distroless: no shell, just the binary at /garage. Healthcheck: ["CMD", "/garage", "status"]

  • Config: devstack/garage/garage.toml is baked into the image via the Dockerfile (project convention: no bind mounts)

    • db_engine = "lmdb", compression_level = 2, replication_factor = 1 (single-node devstack)

  • Init is the in-graph garage-init compose one-shot (devstack/garage-init/, the C4 gate from #1012), NOT xtask

    • alpine + curl/jq against the Garage admin API on garage:3903 (bearer token from the committed devstack garage.toml); the distroless garage container is never exec’d

    • Idempotent per step: layout applied only at layoutVersion == 0, key imported only when GetKeyInfo 404s, bucket created only when GetBucketInfo 404s, and AllowBucketKey ALWAYS re-granted (it is idempotent), so any partial state repairs on re-run

    • craig-seed gates on it (service_completed_successfully), so the bucket exists before the seed’s attachment upload on every path

  • Devstack credentials: access key GKdeadbeefdeadbeefdeadbeef, secret 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

    • Access key format: GK + 24 hex chars (12 bytes). Non-hex chars are rejected by Garage v2.

  • Bucket: craig-dev, region: garage

  • Volumes: garage-meta, garage-data (named Docker volumes, ephemeral with docker compose down -v)

  • Services receive CRAIG_STORE__* env vars + BODY_LIMIT=52428800 (50 MiB)

    • CRAIG_STORE__BACKEND is explicit-required (#1362) — compose sets s3 via the x-garage-credentials anchor for every store-consuming service; a service booted outside compose without it refuses startup with the remedy (there is no silent local fallback; local is an explicit dev/test opt-in)

Volumes

  • garage-meta: Garage metadata

  • garage-data: Garage object data

Multi-replica devstack

cargo xtask dev start --replicas SERVICE=N[,SERVICE=N…] brings up the devstack with the named CRAIG application services scaled to N containers each. Loads docker-compose.scale.yml to drop the host-port pins on scaled services so docker-compose can assign ephemeral host ports per replica.

# 2 craig-cases replicas + 2 craig-exchange replicas; rest scale=1.
cargo xtask dev start --replicas craig-cases=2,craig-exchange=2

Reaching scaled replicas:

  • From inside the compose network: by Docker DNS — craig-cases-1, craig-cases-2.

  • From the host: via docker compose port --index N <svc> <internal-port> (each replica gets a distinct ephemeral host port).

Limitations:

  • Postgres / RabbitMQ / Keycloak / Garage stay scale=1 — sharing state requires external replication this devstack doesn’t model.

  • craig-web (BFF) is not in docker-compose.scale.yml; its 8080 host port pins to the first replica. Use craig-web=1.

  • .ports.env reflects the first replica’s ports only; tests that need per-replica ports use MultiReplicaCluster (see testing.md).

MultiReplicaCluster (in craig-test-lib) is the test-side analogue — it spawns a dedicated cluster with a unique compose project name so its stop() cannot wipe the developer’s persistent devstack volumes.

Seed Data

  • Generated by craig-seed, the deterministic seed generator (tools/craig-seed/)

  • Pinned defaults: CRAIG_SEED=42, CRAIG_FAMILIES=12 (pinned in docker-compose.yml; CI inherits 12 through compose’s :-12 fallback — there is no separate .gitlab-ci.yml pin). The generator binary’s OWN default stays --families 9 — the reproducibility baseline pinned by the seed42/families9 byte-identity fixture. The 12-vs-9 split is intentional two-tier layering (#1006): operational dataset vs baseline corpus; the compose env line carries the authoritative comment.

  • SQL: auto-generated at container startup, seeded via craig-seed binary in Docker

  • TypeScript manifest: tests/e2e/lib/seed.ts — auto-generated via craig-seed --manifest

  • With seed=42, families=12: 50 persons, 15 referrals, 12 investigations, 10 cases, 6 plans, 6 contacts, 5 court orders, 3 foster homes, 12 placements, 5 partners, 6 agreements, 1 ICPC, 5 rate tables, ~36 payments, 1 claiming record

Common Tasks

Task Command

Build workspace

cargo build --workspace --locked

Unit tests

cargo nextest run --workspace --lib

All tests (needs devstack)

cargo nextest run --workspace --locked --profile integration

E2E tests

cargo xtask e2e

Reseed DB (wipe volumes, keep images)

cargo xtask dev reseed

Format check

cargo fmt --check --all

Lint

cargo clippy --workspace --locked — -D warnings

License/advisory check

cargo deny check

Stop containers (keep data)

docker compose down

Wipe containers + data

docker compose down -v

Dockerfile (Multi-Stage Build)

  • Build stage: rust:1.97-alpine, pinned to the toolchain per .claude/CLAUDE.md § Project overrides. It installs musl-dev, pkgconfig, and curl; cargo chef cooks the dependency layer, then a single cargo build invocation builds every workspace binary in one pass

  • 13 named runtime targets (all alpine:3.23 base): craig-rules, craig-cases, craig-placement, craig-exchange, craig-financial, craig-reporting, craig-security, craig-composition, craig-intake, craig-intake-keyring, craig-web (includes static/), craig-mock-server, craig-seed (includes rulesets/, psql client, jq)

  • Build a single target: docker build --target craig-rules -t craig-rules:tag .

  • CI builds + pushes to GitLab Container Registry with tags $CI_COMMIT_SHORT_SHA and latest

Serial devstack image builds — cache-key grouping (#1347)

docker-compose.yml declares 16 build: blocks across those 13 targets — one target, craig-intake, is built for FOUR services: craig-intake, craig-intake-standalone, and craig-intake-standalone-shines share identical build args and image tag (one real build satisfies all three); craig-intake-standalone-shines-tls builds the same target with a distinct build arg (CRAIG_INTAKE_FEATURES=self-signed-tls) and a distinct image tag, so it needs its own build. All of them share the SAME builder stage, which compiles every workspace binary in ONE cargo chef cook
cargo build invocation — so the layer that step produces is a pure function of its build args, not of which final target you asked for. Those args boil down to three distinct recipes across the whole compose file:

Recipe Build args Targets

1

CARGO_PROFILE=devstack

craig-rules, craig-cases, craig-placement, craig-exchange, craig-financial, craig-reporting, craig-security, craig-intake (plain), craig-intake-keyring, craig-mock-server, craig-seed

2

+ CRAIG_WEB_FEATURES=plugin-example

craig-web, craig-composition

3

+ CRAIG_INTAKE_FEATURES=self-signed-tls

craig-intake (the TLS SHINES instance, distinct -tls image tag)

docker compose build and up --build schedule all requested targets CONCURRENTLY by default, and BuildKit only commits a layer to its cache once the build that produced it finishes. So when two same-recipe targets start close together, the second starts before the first’s cargo chef cook and cargo build layer has landed in the cache, sees nothing to reuse, and redundantly re-runs the full-workspace cargo build itself. In the worst case this stacks up to N concurrent full-workspace builds, each with its own set of rustc and linker processes, instead of 1 real build plus N-1 cache hits. That multiplication lands at the exact moment memory is already highest (musl static-linking ring/aws-lc-sys for rustls is a heavy step on its own), and it is a confirmed source of OOM-killed builds on memory-constrained dev machines and the smaller self-hosted CI runners (RUNNER_SMALL).

The fix: xtask/src/docker.rs’s `build_devstack_images_serial (backing BUILD_SPECS, the table above encoded in Rust) issues one docker build --target <X> per distinct (target, build-args, image-tag) tuple, ONE AT A TIME, ordered so every recipe’s builds run back-to-back — group 1’s targets first, then group 2’s, then group 3’s. Every xtask call site that could trigger a docker compose build/up --build goes through this instead and drops --build from its subsequent up: cargo xtask dev (start/reload/restart/reseed), dev refresh’s auto-rebuild path, `cargo xtask migrate apply, and cargo xtask validate’s Docker build phase (CI-covered). Building via `docker compose directly (as the raw-compose CI jobs still do — out of scope for #1347) reintroduces the race.

Adding a 14th image target: add its BuildSpec entry to the group matching its (CARGO_PROFILE, CRAIG_WEB_FEATURES, CRAIG_INTAKE_FEATURES) build args — inside that group’s contiguous run, not appended after a different group — and do NOT reach for docker compose build/up --build as a shortcut; that is exactly the scheduling this section fixed.

Infra images are a separate, disjoint set. BUILD_SPECS only covers the 13 targets sharing the root cargo-chef Dockerfile — it deliberately excludes postgres, rabbitmq, keycloak, garage, and garage-init, which each declare build: as a bare string pointing at their own standalone devstack/<x>/Dockerfile (no shared layer, so no race to serialize around). Dropping --build from up without also covering these left them frozen at whatever image was last built — editing devstack/postgres/init.sql (or any other infra Dockerfile/config) silently had no effect, since up alone never rebuilds a service’s image. xtask/src/docker.rs’s `infra_build_services discovers this set dynamically (services whose build: value is a plain string, not a mapping — the same signal build_specs_match_docker_compose_yml uses to rule them OUT of BUILD_SPECS, so the two lists can never silently overlap or gap) and build_infra_images hands them to a single docker compose build <services> call — concurrent scheduling is fine here since there’s no cargo-chef layer to race. Every call site above runs it alongside (never instead of) build_devstack_images_serial. A name-based filter (e.g. a craig- prefix) was considered and rejected: mock-server is a BUILD_SPECS target with no craig- prefix, and compose’s own default image naming makes the unrelated craig-postgres/craig-rabbitmq/etc. images ALSO start with craig- — the heuristic is wrong in both directions on this exact compose file.

Infrastructure Dockerfiles

devstack/postgres

  • Dockerfile: FROM postgres:18-alpine, COPY init.sql

  • init.sql creates 9 CRAIG databases (craig_rules, craig_cases, craig_placement, craig_exchange, craig_financial, craig_reporting, craig_security, craig_composition, and craig_intake — the last pre-created but never populated; stateless edge per ADR-017), the multibackend-profile IdP stores (authentik, zitadel), and the per-service migration-owner/runtime role pairs (#1310, ADR-063)

  • init.sql only ever runs once, against a FRESH/empty data directory — upstream Postgres entrypoint behavior, not a CRAIG choice. The devstack’s postgres-data volume is a persistent named volume that survives a plain down/up (ADR-048 §D1), so a rebuilt image alone does nothing: editing init.sql on an already-initialized volume silently has no effect until the volume is wiped. xtask/src/devstack_guard.rs’s `check_postgres_init_stale hashes devstack/postgres/ separately from the rest of devstack/ for exactly this reason — a change there escalates straight to VolumeAction::Wipe (+ reseed) instead of the plain cached rebuild the rest of devstack/ gets. Same escalation mechanism as a modified migration file or a rotated field-encryption key.

devstack/rabbitmq

  • Dockerfile: copies definitions.json + rabbitmq.conf

  • rabbitmq.conf: management.load_definitions = /etc/rabbitmq/definitions.json

  • definitions.json (#1202) declares 10 users with SHA-256 password hashes: craig (the admin/operator), craig-test (the broad AMQP test identity), and 8 per-service least-privilege accounts (craig-<svc>, password equal to the account name — a public dev ACL fixture). It also declares the / vhost and the two durable topic exchanges craig.events and craig.dlx, which are operator-owned; services no longer declare them

  • WARNING: RABBITMQ_DEFAULT_USER env var does NOT work when rabbitmq.conf is COPYed (overrides entrypoint config)

devstack/keycloak

  • Dockerfile: FROM quay.io/keycloak/keycloak:26.5, COPY craig-realm.json

  • Command: start-dev --import-realm

  • Bootstrap admin: admin/admin (Keycloak admin console)

  • See CLAUDE.md for realm users/roles/clients

CI vs Local Differences

Pre-push hook runs the full test battery locally — it is the sole functional-correctness gate. CI carries security/supply-chain scans, the no-devstack ci-tests subset, secrets/docs policy jobs, Docker promotion, Pages, and the scheduled pentest/perf/cluster jobs.

Aspect Local (pre-push) CI

Lint/clippy/tests

cargo xtask validate

Not run (pre-push gate)

E2E (Playwright)

cargo xtask e2e

Not run (pre-push gate)

Security scans

Not run

SAST, secret detection, dep scanning, cargo-audit

Docker images

Not built

docker-promote on main/tags only

Docs

Not built

Antora review apps + Pages

CI Helper Scripts (devstack/ci/)

  • write-field-key.sh materializes devstack/field-key.env by DECRYPTING the repo-committed store (secrets/dev.yaml) with the CI age identity CRAIG_CI_AGE_KEY, inside the pinned devtools container (ADR-048 §D5 as amended by ADR-064 U7, #1385). The output is validated before an atomic publish, and the prior file is retained on failure. An unset variable is a hard error: a CI devstack must never come up keyless, and a store rotation reaches CI on the next run.

  • Two scripts were retired by the C4 gate (#1012): garage-init.sh, because Garage bootstrap now runs as the in-graph garage-init compose one-shot on every path, and wait-healthy.sh, because the CI jobs use docker compose up -d --wait --wait-timeout 900, which the seed and garage-init completion gates make fail-loud.

Edit this page · latest