Plan J: Env-var Documentation Coverage + Docker-compose DRY
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Plan filing — body lands in the docs-only Plan D refresh MR alongside Plans D/G/H/I/K. nav.adoc + CHANGELOG. No code changes. |
Done (2026-05-15 via !307) |
2 |
F-038 implementation: expand |
Done (2026-05-25) |
3 |
F-039 implementation: docker-compose YAML anchors. Define top-level extension fields and replace duplicated config with anchor references. Verify effective config unchanged via |
Done (2026-05-25) |
4 |
Plan completion audit + archive. |
Done (2026-05-25) — Audit verified all 3 prior steps via the plan-completion-audit subagent per |
Epic: &32 (epic: Env-var Documentation Coverage + Docker-compose DRY (Plan J))
Issues: #432 (Step 2) · #433 (Step 3) · #434 (Step 4)
Branch prefix: chore/env-compose-
*Milestone: TBD
Context
Two configuration-coherence smells surfaced in the 2026-05-15 post-Plan-C/F audit:
-
F-038 (P1):
docker-compose.ymlsets 114 env vars across the 9 CRAIG services;.env.exampledocuments 72. 42 env vars are set in compose but missing from.env.example. Notably:-
All cross-service URLs (
CRAIG_REPORTINGPLACEMENT_URL,CRAIG_REPORTINGCASES_URL,CRAIG_REPORTING__FINANCIAL_URL, etc.) -
All client credentials (
CRAIG_*CLIENT_ID,CRAIG_*CLIENT_SECRET) -
Intake-specific knobs (
CRAIG_INTAKECAPTCHA_SECRET,CRAIG_INTAKEPUBLIC_RATE_LIMIT,CRAIG_INTAKE__MODE) -
CRAIG_STORE__*(S3 backend) vars for the 4 services that store objects (cases, exchange, reporting, security)A new operator copying
.env.examplegets a deployment that boots in degraded mode (missing service URLs) without warning.
-
-
F-039 (P2): docker-compose.yml has substantial config repetition:
-
OIDC_ISSUER: ${OIDC_ISSUER:-http://host.docker.internal:8180/realms/craig}— duplicated 7× -
OIDC_INTERNAL_URL: http://keycloak:8080/realms/craig— duplicated 7× -
JURISDICTION: georgia+ADMIN_UNIT_LABEL: County— duplicated 9× (every service) -
CRAIG_STORE__S3_*credentials block — duplicated 4× (cases, exchange, reporting, security)YAML anchors collapse these to declare-once, merge-in-N-places.
-
Cross-cutting invariants
-
F-038 distinguishes secrets from config. Secrets (CRAIG_*CLIENT_SECRET, CRAIG_*JWS_SIGNER_KEY, S3 credentials) get placeholder values in
.env.example, not the devstack defaults. Comment policy:# REQUIRED: generated by <method>; see <doc-link>. Devstack-only values likepasswordgo indocker-compose.yml, never in.env.example.FILEenv-var pattern: for secrets that are file-mounted in production (per Plan E’sCRAIG<SVC>__SIGNING_JWK_FILE+CRAIG_PEER_JWKS_JSON_FILE), document the_FILEvariant in.env.examplerather than the literal-secret variant — the file-mount pattern is the production deployment recommendation and the literal-secret variant is devstack-only. -
Direct
docker compose configinvocation is a documented exception..claude/docs/coding-conventions.mdsays "NEVER rundocker composedirectly — usecargo xtask dev." That rule covers runtime commands (up,down,start,restart);docker compose configis a config-render command — pure-textual; doesn’t start containers. The Step 3 verification uses it for diff-checking effective YAML, not for orchestration. Document the carve-out in the Step 3 MR body. -
F-039 must be config-equivalent. Before/after verification:
docker compose --profile identity-multibackend config | sort > /tmp/{before,after}.yml && diff /tmp/before.yml /tmp/after.yml. Any diff = bug. (Empty diff = the YAML anchor merge produced equivalent effective config.) -
YAML anchor depth is bounded. Anchors are top-level
x-*extension fields; no nested anchor declarations. Keeps readability OK and matches docker-compose’s documented anchor support.
Scope
In scope (2 findings):
-
F-038
.env.examplecoverage expansion -
F-039 docker-compose YAML-anchor DRY
Out of scope:
-
xtask env-coveragelint to auto-detect drift between.env.exampleand actual env-var consumption — not yet filed. NOTE: an earlier draft of this plan said "fold into Plan K as F-046" but Plan K F-046 is plan-lifecycle doctrine, NOT env-coverage. If implementing the env-coverage lint becomes worth a finding, file it as a fresh finding (next free F-NNN) — likely as part of Plan H (since it’s a workspace-lint expansion) or as a small Plan M if scope warrants. Tracked as a documented TODO for now -
Strum DTO conversion — Plan D
-
DRY refactors in code — Plan G
-
Idiomatic Rust — Plan H
-
Module decomposition — Plan I
-
Canopy xtask backports — Plan K
Steps
Step 2: F-038 .env.example coverage expansion
Files:
-
.env.example— grow from ~72 documented env vars to ~114. Section structure:# ═══════════════════════════════════════════════════════════════ # REQUIRED: per-deployment overrides # ═══════════════════════════════════════════════════════════════ # OIDC issuer URL — typically your IdP's realm URL OIDC_ISSUER=https://idp.example.com/realms/your-org # Encryption mode — set to 'required' in production, 'optional' for devstack only CRAIG_CASES__ENCRYPTION_MODE=required # (similar for craig-placement, craig-financial, craig-exchange) # Service-to-service URLs — internal hostnames; set per deployment topology CRAIG_REPORTING__CASES_URL=http://craig-cases:8002 CRAIG_REPORTING__PLACEMENT_URL=http://craig-placement:8003 # (similar for all cross-service deps) # Per-service OAuth2 client credentials — generated at IdP setup CRAIG_CASES__CLIENT_ID=craig-cases CRAIG_CASES__CLIENT_SECRET=<generated-at-idp-setup> # (similar for every service) # S3-compatible object store (Garage in devstack, S3/MinIO in production) CRAIG_STORE__S3_ENDPOINT=https://s3.example.com CRAIG_STORE__S3_BUCKET=craig-prod-attachments CRAIG_STORE__S3_ACCESS_KEY=<generated-at-s3-provisioning> CRAIG_STORE__S3_SECRET_KEY=<generated-at-s3-provisioning> # ═══════════════════════════════════════════════════════════════ # RECOMMENDED: sane defaults exist; review for production # ═══════════════════════════════════════════════════════════════ # Postgres statement timeout (per-service); default 30s CRAIG_CASES__DB_STATEMENT_TIMEOUT_MS=30000 # Trusted proxies (CIDR list); default empty — set to your load-balancer subnet CRAIG_INTAKE__TRUSTED_PROXIES=10.0.0.0/8 # ═══════════════════════════════════════════════════════════════ # ADVANCED: tune only if you understand the implications # ═══════════════════════════════════════════════════════════════ # Introspection cache TTL ceiling (seconds); default 60 # Per Plan F (introspection-validation-mode.adoc), this is the upper bound # for cached token claims; the effective TTL is min(token.exp - now, this). CRAIG_<SVC>__INTROSPECTION_CACHE_TTL_MAX_SECONDS=60 # (full list — populate during audit)
Branch: chore/env-compose-step2-env-example-expansion
MR title: chore(.env.example): document 42 missing env vars; organize by Required / Recommended / Advanced [Step 2 of env-compose]
Verification:
-
.env.examplecovers every env var found indocker-compose.yml(verify via diff — see derivation algorithm below) -
No secrets in
.env.example(only placeholder values) -
Sections are coherent — a new operator can read top-to-bottom and configure a minimal production deployment
Branch-time baseline re-anchor: before opening the MR, run:
# Current .env.example documented env vars
docs_count=$(grep -cE '^[A-Z][A-Z0-9_]*=' .env.example)
# Current docker-compose.yml env vars (per service `environment:` block)
compose_vars=$(awk '/^[ ]+environment:/,/^[ ]+[a-z]+:/' docker-compose.yml | grep -E '^ [A-Z]' | sed 's/:.*//' | sort -u | wc -l)
echo "docs: $docs_count compose: $compose_vars delta: $((compose_vars - docs_count))"
The 2026-05-15 baseline (72 documented, 42 missing) is point-in-time. Plan F !304 + Plan C !299 + Plan B !280 may have already landed .env.example entries — re-anchor before claiming "+42".
The 42 missing env vars — appendix table: enumerate during Step 2 audit as | Var | Section (R/R/A) | Production-pattern value | Source line in docker-compose.yml |. Append to plan body before Step 2 ships to capture the audit deliverable in-plan.
Step 3: F-039 docker-compose YAML anchors
Files:
-
docker-compose.yml— define anchors at the top:# Top-level extension fields for shared env-var blocks (compose v2 anchors) x-oidc-defaults: &oidc_defaults OIDC_ISSUER: ${OIDC_ISSUER:-http://host.docker.internal:8180/realms/craig} OIDC_INTERNAL_URL: http://keycloak:8080/realms/craig x-jurisdiction-defaults: &jurisdiction_defaults JURISDICTION: georgia ADMIN_UNIT_LABEL: County x-garage-credentials: &garage_credentials CRAIG_STORE__S3_ENDPOINT: http://garage:3900 CRAIG_STORE__S3_BUCKET: craig-devstack CRAIG_STORE__S3_ACCESS_KEY: ${GARAGE_KEY_ID} CRAIG_STORE__S3_SECRET_KEY: ${GARAGE_KEY_SECRET} services: craig-cases: environment: <<: [*oidc_defaults, *jurisdiction_defaults, *garage_credentials] # ... craig-cases-specific env vars ... # similar for craig-placement, craig-exchange, etc.
Branch: chore/env-compose-step3-yaml-anchors
MR title: chore(docker-compose): DRY up env-var blocks via YAML anchors [Step 3 of env-compose]
Verification:
-
docker compose --profile identity-multibackend config | sort > /tmp/before.yml(before MR) -
Apply MR
-
docker compose --profile identity-multibackend config | sort > /tmp/after.yml -
diff /tmp/before.yml /tmp/after.ymlreturns empty — proves no effective config change -
Devstack boots clean:
cargo xtask dev start --force -
Multibackend profile boots clean:
cargo xtask dev multibackend-up
Files Touched
| File | Step | Change |
|---|---|---|
|
2 |
EDIT (~42 new entries; sectioned) |
|
3 |
EDIT (YAML anchors; effective config unchanged) |
Verification
After every step: docker compose config diff is empty (Step 3) + cargo xtask dev start --force boots clean.
Risks
| Risk | Mitigation |
|---|---|
YAML anchor depth reduces compose readability for new contributors |
Counter-argument: 845-line compose with 42 duplications is already hard to scan; anchors make the intent clearer once a one-line "this is an anchor merge" comment is added at each merge site |
|
Future-state: |
Docker Compose’s YAML anchor merge has surprising behavior (e.g. list merges) |
Use the explicit |
|
Use unambiguous placeholders like |
|
Audit the scale file alongside the main compose for anchor opportunities; if it has the same OIDC / JURISDICTION / S3 cred duplications, include in F-039’s scope. Otherwise document as in-scope-but-no-changes-needed |
After this plan lands
-
.env.exampledocuments every operator-tunable env var, sectioned by Required / Recommended / Advanced -
docker-compose.ymldeclares OIDC, jurisdiction, and S3-credentials blocks once via YAML anchors; effective config unchanged -
New-operator onboarding: a copy of
.env.example+ per-deployment overrides is sufficient to configure a production CRAIG deployment without reading source code