26 KiB
Wave 3 — Environment Separation and Infrastructure Smoke Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (
- [ ]) syntax. Read2026-08-15-five-adapter-runtime-remediation-index.mdfirst. Entry criterion: Wave 2 complete — fulltestgreen, each adapter's one-on lane passing. Exception: Task 4 (Keycloak realm) is a prerequisite of Wave 2 Task E3 and may be pulled forward. If it was, mark it complete here and continue.
Goal: Separate example configuration from operator input, make a profileless deployment impossible, and build the Compose lane matrix — with its two canonical scripts, Keycloak realm, and MinIO round trip — that proves each activation combination actually runs.
Architecture: One JSON contract file (config/runtime/compose-profile-contracts.json) is the SSOT
for every lane: its Compose profile, its file stack, its explicit Spring runtime, and its exact sorted
service set. Two scripts are the only entry points — one static
(verify-compose-profile-contracts.sh), one dynamic (run-compose-runtime-smoke.sh) — and CI calls
those scripts rather than inlining fragments of them, so a lane cannot be half-run by a workflow that
forgot a flag. Shared infrastructure lives in its own docker-compose.infra.yml, never mixed into an
environment overlay. Each lane gets a unique Compose project, mode-0700 temp directory, mode-0600
secret files, sanitized evidence, and a trap-driven teardown scoped to that project alone.
Tech Stack: Docker Compose ≥ 2.24.4 (this machine: 5.4.0), PostgreSQL 16 with TLS, MongoDB replica
set, Kafka, Mailpit, MinIO + mc, Keycloak with --import-realm, Bash.
Spec: 2026-08-15-five-adapter-runtime-remediation-review-design.md
(§7 in full, §11 Wave 3, §12.4)
Global Constraints
Inherited from the index. Wave 3 adds:
- A secret value never reaches Git, rendered config, a command line, JUnit XML, or an evidence
artifact. Secrets are files created per run at mode
0600and deleted on teardown. up --waitapplies only to long-running services. A one-shot client (auth-smoke,object-storage-smoke,notification-smoke,minio-init) is run withrun --rmand must exit zero. A required one-shot that is missing, skipped, or non-zero fails the whole lane.- Teardown is scoped.
down --volumes --remove-orphansruns against the lane's uniqueCOMPOSE_PROJECT_NAMEonly. Never touch another project or a named volume outside the lane. - Reference docs are authoritative for merge and import semantics, not memory:
- Static verification precedes dynamic.
configandcreatemust pass before anyup.
File Structure
Created
| File | Responsibility |
|---|---|
src/config/runtime/compose-profile-contracts.json |
The lane SSOT: id, Compose profile, file stack, Spring runtime, exact sorted service set, blocking flag. |
docker-compose.infra.yml |
Every shared infrastructure service and one-shot smoke client. Owns nothing environment-specific. |
docker-compose.prod-smoke.yml |
TLS PostgreSQL, prod env source, secret references. Test-only. |
scripts/verify-compose-profile-contracts.sh |
The only static entry point. |
scripts/run-compose-runtime-smoke.sh |
The only dynamic entry point. |
infra/keycloak/realms/ca-skeleton-realm.json |
Reproducible realm import. No secret values. |
infra/keycloak/entrypoint.sh |
Reads the secret file, exports it, execs kc.sh start-dev --import-realm. |
infra/keycloak/smoke/auth-smoke.sh |
The one-shot client-credentials + protected-endpoint assertion. |
infra/minio/smoke/object-storage-smoke.sh |
upload → HEAD → download → delete → wrong-credential rejection. |
infra/minio/init/bucket-bootstrap.sh |
Bucket and minimum policy creation. Not a substitute for the round trip. |
infra/notification/smoke/notification-smoke.sh |
Accept/ingest, Mailpit assertion, duplicate check. |
src/.env.example |
Public key catalog with empty placeholders. Tracked. |
src/.env.local.example |
Local opt-in combination example. Tracked. |
Modified
| File | Change |
|---|---|
src/app-bootstrap/src/main/resources/application.yml |
Remove the ${SPRING_PROFILES_ACTIVE:local} fallback. |
docs/registries/env-keys.yaml |
SPRING_PROFILES_ACTIVE becomes a defaultless enum local|dev|prod. |
src/app-bootstrap/build.gradle |
Replace the bootRun-only .env parsing with a single loader; pass an explicit profile. |
src/build.gradle |
Rewrite verifyEnvKeys's input contract: registry + profile YAML + .env.example + generated metadata; never a gitignored operator .env. |
docker-compose.yml |
App only; no infrastructure. |
docker-compose.local.yml |
Local overlay; profiles for service selection; PostgreSQL moves to infra. |
docker-compose.dev.yml |
tmpfs: !override [] then exactly one /var/tmp/heap bind mount; owns SPRING_PROFILES_ACTIVE=dev and its env source. |
.gitignore |
Ignore src/.env* except the two .example files. |
src/.env |
Untracked (git rm --cached). It is operator input, not a build input. |
src/app-bootstrap/src/main/resources/logback-spring.xml |
Profile field reads the real active profile. |
.github/workflows/ci-quality-gates.yml |
Call the two scripts; do not inline their commands. |
Task 1: Make a profileless deployment impossible
Files:
- Modify:
src/app-bootstrap/src/main/resources/application.yml:22-24 - Modify:
docs/registries/env-keys.yaml(SPRING_PROFILES_ACTIVErow) - Create:
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/RuntimeEnvironmentProfileValidator.java - Modify:
src/app-bootstrap/build.gradle(bootRunpasses an explicit profile) - Modify/replace:
EnvProfileMatrixContractTestand any profileless-permitting test - Test:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/RuntimeEnvironmentProfileValidatorTest.java
Interfaces:
- Produces:
RuntimeEnvironmentProfileValidator, anEnvironmentPostProcessorordered afterMasterSwitchEnvironmentPostProcessor(Wave 1 Task 2). Tasks 5–8 assume exactly one environment profile is resolvable.
Context: Verified at HEAD: spring.profiles.active: ${SPRING_PROFILES_ACTIVE:local}. A bootJar
started with no profile silently becomes local, which — before Wave 2 Task A2 — also meant H2 and
create-drop. Deployable cardinality is exactly one.
Rules: missing ⇒ error; blank ⇒ error; unknown ⇒ error naming the three permitted values; multiple
(local,prod) ⇒ error, and SPRING_PROFILES_ACTIVE is not treated as CSV; test ⇒ rejected for a
deployable artifact, permitted only in a test-source context.
Feature selection must not be expressed as a supplementary Spring profile — the five master switches are for that. The validator therefore rejects any active profile outside the permitted set rather than ignoring extras.
- Step 1: Write the validator test — one case per rule above, plus one asserting that a
test-source context may still use
test. - Step 2: Run to verify it fails.
- Step 3: Remove the
:localfallback fromapplication.yml; write the validator; register it inMETA-INF/spring.factoriesnext to the master-switch post-processor. - Step 4: Update the
SPRING_PROFILES_ACTIVEregistry row to a defaultless enum. - Step 5: Make
bootRunpass an explicit profile so the developer convenience path stays usable without reintroducing an implicit default. - Step 6: Replace
EnvProfileMatrixContractTest's local-fallback expectation with the new fail-closed contract. Do not delete coverage — rewrite it. - Step 7: Run
./gradlew :app-bootstrap:test --console=plain --no-daemonand./gradlew verifyEnvKeys --console=plain --no-daemon. - Step 8: Commit.
Task 2: Separate example configuration from operator input
Files:
- Create:
src/.env.example,src/.env.local.example - Modify:
.gitignore - Untrack:
src/.env(git rm --cached src/.env— the human runs this) - Modify:
src/build.gradle(verifyEnvKeysinput contract, lines ~2209-2229, ~2263-2276) - Modify:
src/app-bootstrap/build.gradle(single env loader, replacing thebootRun-only parser at ~251-272) - Test:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/EnvSourceSeparationTest.java
Context: Verified at HEAD: src/.env is tracked (git ls-files lists it), there is no
.env.example, and .gitignore contains only three unrelated lines. The tracked file carries
SPRING_PROFILES_ACTIVE=local, APP_DATASOURCE_DDL_AUTO=update, and local credentials — which is why
dev inherits ddl-auto=update and fails.
verifyEnvKeys currently requires src/.env to exist and compares required placeholders against
it. That contract is false in both directions: it passes only when a real secret file is present, and
it would pass with no example file at all. The new SSOT is the env registry, the profile YAMLs,
.env.example, and the generated configuration metadata. Gitignored operator .env* files and real
secret values leave the build inputs entirely.
.gitignore addition:
src/.env*
!src/.env.example
!src/.env.local.example
- Step 1: Write
EnvSourceSeparationTest— asserts.env.exampleexists and every registry key appears in it; that no example value looks like a real secret (non-empty for aclassification: secretrow); and thatsrc/.envis not tracked (git ls-files --error-unmatch src/.envmust fail). - Step 2: Run to verify it fails.
- Step 3: Generate
.env.examplefrom the registry — every key, secrets as empty placeholders orsecret://references. Write.env.local.examplewith a documented local opt-in combination. - Step 4: Update
.gitignore; ask the human to rungit rm --cached src/.env. - Step 5: Rewrite
verifyEnvKeys's inputs; replace thebootRun-only.envparser with a loader that selects the file by environment, or with Spring's standard config import. - Step 6: Run
./gradlew verifyEnvKeys :app-bootstrap:test --console=plain --no-daemon. - Step 7: Commit.
Task 3: The Compose contract SSOT and the static verifier
Files:
- Create:
src/config/runtime/compose-profile-contracts.json - Create:
scripts/verify-compose-profile-contracts.sh - Modify:
docker-compose.yml,docker-compose.local.yml,docker-compose.dev.yml - Create:
docker-compose.infra.yml,docker-compose.prod-smoke.yml
Interfaces:
- Produces: the contract file, whose schema is
{ "minimumComposeVersion": "2.24.4", "lanes": [ { "id", "composeProfile"|null, "files": [...], "springRuntime", "services": [...sorted], "blocking": true } ] }. Task 7's runtime-smoke script and Wave 6's matrix both read it.
Context: The full lane table is spec §7.2 and is reproduced here as the exact content to encode.
base, infra, local, dev, prod-smoke mean docker-compose.yml, docker-compose.infra.yml,
docker-compose.local.yml, docker-compose.dev.yml, docker-compose.prod-smoke.yml, merged in the
order listed.
| lane | Compose profile | file stack | Spring runtime | services (sorted) |
|---|---|---|---|---|
off-local |
— | base+local | local |
app |
off-dev |
— | base+dev | dev |
app |
off-prod |
— | base+prod-smoke | prod |
app |
local-jpa |
local-jpa |
base+infra+local | local |
app,db |
local-mongo |
local-mongo |
base+infra+local | local |
app,mongo,mongo-rs-init |
local-messaging |
local-messaging |
base+infra+local | local |
app,kafka |
local-messaging-outbox |
local-messaging-outbox |
base+infra+local | local |
app,db,kafka |
local-notification-ingest |
local-notification-ingest |
base+infra+local | local |
app,db,notification-smoke |
local-notification-serving |
local-notification-serving |
base+infra+local | local |
app,db,mailpit,notification-smoke |
local-notification-handoff |
local-notification-handoff |
base+infra+local | local |
app,db,mailpit,notification-smoke |
local-graphql |
local-graphql |
base+infra+local | local |
app,auth-smoke,keycloak |
shared-infra-local |
shared-infra |
base+infra+local | local |
app,auth-smoke,db,keycloak,minio,minio-init,object-storage-smoke |
shared-infra-dev |
shared-infra |
base+infra+dev | dev |
app,auth-smoke,db,keycloak,minio,minio-init,object-storage-smoke |
prod-smoke |
prod-smoke |
base+infra+prod-smoke | prod |
app,auth-smoke,db,keycloak,minio,minio-init,object-storage-smoke |
all-adapters |
all-adapters |
base+infra+local | local |
app,auth-smoke,db,kafka,keycloak,mailpit,mongo,mongo-rs-init,notification-smoke |
Service membership rules:
auth-smoke∈local-graphql,shared-infra,prod-smoke,all-adapters.object-storage-smoke∈shared-infra,prod-smoke.notification-smoke∈ the three local notification profiles andall-adapters.minio-initbootstraps bucket and policy; it is not a substitute for the round trip.- A Compose profile selects services; it never implies a Spring profile.
The dev merge fix, using Compose ≥ 2.24.4 semantics: the dev overlay declares
tmpfs: !override [] to replace the base tmpfs, then declares the /var/tmp/heap bind mount exactly
once. Do not assume an empty sequence auto-deletes the base sequence — verify target uniqueness in the
merged JSON, which is what the script's final check does.
The verifier checks, per lane:
docker compose version --short≥ the contract'sminimumComposeVersion(semver compare);config --servicesfor the lane's file stack (with--profile <name>, omitted for the three off lanes) equals the contract's sorted set exactly — not a superset;- the rendered
appservice'sSPRING_PROFILES_ACTIVEequals the lane'sspringRuntime; --profile '*' config --format jsonrenders, and every service'svolumes+tmpfstargets are unique within that service.
- Step 1: Write the contract JSON encoding the table above.
- Step 2: Restructure the Compose files: move PostgreSQL out of
docker-compose.local.ymlintodocker-compose.infra.yml; add Mongo +mongo-rs-init, Kafka, Mailpit, MinIO +minio-init+object-storage-smoke, Keycloak +auth-smoke,notification-smoke; add Composeprofiles:to each; createdocker-compose.prod-smoke.yml; apply the!overridefix todocker-compose.dev.ymland give itSPRING_PROFILES_ACTIVE=devplus its env source. - Step 3: Write
scripts/verify-compose-profile-contracts.shimplementing checks 1–4.set -euo pipefail;jqfor JSON; exit non-zero with the lane id and the exact diff on any mismatch. - Step 4: Run
./scripts/verify-compose-profile-contracts.sh. Expected: all 15 lanes pass. - Step 5: Run
cd src && ./gradlew :app-bootstrap:test --tests '*ComposeMergeCharacterizationTest*'— the dev case is now green; remove its@Tag("wave0-red"). - Step 6: Pin the Compose minimum version in
README.mdand the CI workflow. - Step 7: Commit.
Task 4: The Keycloak realm and its acceptance
May be pulled forward — Wave 2 Task E3 depends on this artifact.
Files:
- Create:
infra/keycloak/realms/ca-skeleton-realm.json - Create:
infra/keycloak/entrypoint.sh - Create:
infra/keycloak/smoke/auth-smoke.sh - Modify:
docker-compose.infra.yml,src/app-bootstrap/src/main/resources/application-local.yml
Context: The realm defines ca-skeleton-api as a confidential client with client
authentication and a service account enabled, and with standard flow and direct access grant
disabled. The service account carries realm role user and client role graphql-query; an
audience mapper puts ca-skeleton-api into aud. Authentication for smoke is OAuth 2.0
client_credentials — one method, no alternatives. No test user, no password grant, no direct access
grant.
The seven acceptance checks (spec §7.3):
- realm
ca-skeletonimported; - client/audience
ca-skeleton-apiexists; - the application's roles and the role/permission claim mapping exist;
- a token is issued via the service account's client credentials;
- the token has non-blank
sub, exactiss,aud=ca-skeleton-api,realm_access.rolescontaininguser, andresource_access.ca-skeleton-api.rolescontaininggraphql-query; - public health succeeds unauthenticated; protected REST and GraphQL succeed only with a valid token;
- wrong realm, wrong audience, and expired token are rejected with the expected safe error contract.
The issuer trap, and why one hostname is not enough. application-local.yml:68-76,142-145 defaults
the issuer to localhost:8081. That resolves on the host and, inside the app container, points at the
app itself. JWKS discovery is lazy (JwtDecoderConfig.java:25-58), so startup succeeds and the error
only appears at the first protected request. Do not assume one hostname resolves everywhere:
- bootJar qualification (Wave 2 E3): inject Testcontainers' mapped Keycloak URL into both the token endpoint and the app issuer — the same single URL on both sides.
- Compose smoke: put
appandauth-smokeon the same network and injecthttp://keycloak:8080/realms/ca-skeletoninto both.
A token obtained from one URL and validated against another is not evidence, and neither is a successful startup.
Secret handling. The qualification script creates a URL-safe random secret file at mode 0600 per
run and mounts it as a Compose/Testcontainers secret. entrypoint.sh reads
/run/secrets/keycloak-graphql-smoke-client-secret, exports it as a process-local
KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET, and execs
/opt/keycloak/bin/kc.sh start-dev --import-realm. The realm JSON contains only the
${KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET} reference. The file is removed on teardown.
- Steps 1–8: build the realm, entrypoint, and smoke script; wire the Compose service; run
./scripts/verify-compose-profile-contracts.shand then thelocal-graphqllane; confirm all seven checks; confirm no secret value appears in any rendered config or artifact (grep -rthe evidence directory for the generated value must find nothing).
Task 5: MinIO bucket bootstrap and a real object round trip
Files:
- Create:
infra/minio/init/bucket-bootstrap.sh,infra/minio/smoke/object-storage-smoke.sh - Modify:
docker-compose.infra.yml
Context: MinIO readiness is not success. minio-init creates the test bucket and minimum policy;
object-storage-smoke is a black-box one-shot that consumes the lane's endpoint, bucket, and
secret file and performs, in order and with no step skippable:
- upload known bytes to a random object key;
- HEAD and verify size and checksum;
- download and verify byte equality;
- delete and verify not-found;
- attempt the same operations with a deliberately wrong credential and verify rejection.
Results are written to minio-roundtrip.json in the lane's evidence directory, with no secrets.
Boundaries: the existing object-storage qualification owns its own Testcontainers and random
credentials, so it is not evidence about this Compose service — keep it, but give the Compose lane
a separate name and separate artifacts. Local or static credentials are never passed to prod-smoke.
minio-init succeeding is never accepted in place of the round trip. Whether object storage joins the
app-bootstrap runtime is a separate decision from the five master switches; if it does not, this
smoke client is a release fixture, not a production bean.
The client image is pinned by digest.
- Steps 1–6: build both scripts, wire the services, run the
shared-infra-locallane, verify the artifact, confirm no secret leaked, commit.
Task 6: The notification smoke client and the stateful handoff lane
Files:
- Create:
infra/notification/smoke/notification-smoke.sh - Modify:
docker-compose.infra.yml
Context: Three lanes use this client. local-notification-ingest proves durable accept with zero
provider beans and zero workers. local-notification-serving proves a real Mailpit delivery.
local-notification-handoff is a composite stateful lane, not two lanes concatenated. The same
project, the same PostgreSQL service, and the same named volume persist across four phases:
- Start DB and app with
INGEST_ONLYphase env. Store an accept request using credential-free Mailpit route metadata. Record the request ID and route version in evidence. Confirm via the activation report that provider beans/calls and worker threads are all zero. - Stop only the app, cleanly. Do not bring down the DB or the volume.
- In the same project, recreate the app with
SERVINGphase env and reference-provider settings (--force-recreate), and bring Mailpit to ready. - Re-run the smoke with the phase-1 request ID and frozen route version. Assert exactly one Mailpit message, a terminal DB state, and the same route version. Wait at least one more dispatch poll window and assert duplicates are still zero.
Only after both phases and the intermediate app exit succeed does the lane proceed to shared evidence collection and teardown. Deleting the volume after phase 1, or copying rows into a second project, is not handoff evidence.
- Steps 1–7: build the client, encode the phases in the runtime-smoke wrapper (Task 7), run the lane, verify the evidence, commit.
Task 7: The runtime-smoke wrapper
Files:
- Create:
scripts/run-compose-runtime-smoke.sh
Interfaces:
- Produces:
--matrix <contract.json>(all blocking lanes, zero-discovery and zero-skip) and--lane <id>(focused reproduction only — never a substitute for a matrix run). Wave 6 runs the matrix form.
Context: The wrapper enforces this order internally so no human and no CI job can skip a step:
- Create a per-lane, per-run
COMPOSE_PROJECT_NAMEand a mode-0700temp directory; write env and secret files at mode0600. Fail if the evidence directory already exists — never reuse one. - Run
verify-compose-profile-contracts.sh, thenconfig, thencreate. up --waitthe long-running services only; check app health/readiness and the resolved activation report from Wave 1'sadapteractivationendpoint.run --rmeach one-shot the lane declares (auth-smoke,object-storage-smoke,notification-smoke); for JPA lanes, assert the app's migration/schema/TLS report. A required one-shot that is missing, skipped, or non-zero fails the lane.- Write to
src/app-bootstrap/build/evidence/runtime-smoke/<lane>/<run-id>/:manifest.json, the Compose and service-set digest, activation/health, DB migration/TLS, sanitized Keycloak claims, the MinIO round trip, and a warning/error summary. Never a raw token, URI credential, secret value, or rendered secret. - On both success and failure: collect sanitized logs and container exits first, then in a
traprundown --volumes --remove-orphansagainst this project only, and delete the temp env and secret files. Never touch another project or an outside named volume.
The local-notification-handoff phase sequence from Task 6 lives here.
- Steps 1–8: write it, run
--lane off-localfirst, then--lane local-jpa, then the full--matrix, verifying evidence and teardown each time. Confirm withdocker ps -aanddocker volume lsthat nothing outside the lane's project was touched.
Task 8: Wire CI to the scripts
Files:
- Modify:
.github/workflows/ci-quality-gates.yml
Context: CI calls the two scripts and nothing else for Compose work. Verified at HEAD, the quality
job runs ./gradlew check verifyPublicPathSnapshot verifyDependencyLocks and
./gradlew conditionalTransportQualification, with no Compose verification at all. Inlining wrapper
fragments would let a workflow silently run a lane without its one-shots, and past evidence must never
be aggregated as a current pass.
- Steps 1–4: add the two script invocations, run the workflow (or
act/a branch push), confirm both execute and fail loudly on a deliberately broken lane, commit.
Wave 3 Exit Criteria
./scripts/verify-compose-profile-contracts.sh— all 15 lanes pass../scripts/run-compose-runtime-smoke.sh --matrix src/config/runtime/compose-profile-contracts.json— every blocking lane passes with zero discovery failures and zero skips, includingprod-smokeactually starting TLS DB + app + Keycloak + MinIO and running both one-shots.git ls-files src/.envreturns nothing;src/.env.exampleandsrc/.env.local.exampleare tracked.- A profileless bootJar start fails;
local,prodfails;stagefails; each oflocal,dev,prodsucceeds. cd src && ./gradlew verifyEnvKeys --console=plain --no-daemon— green with the new input contract, and green withsrc/.envabsent../gradlew wave0RedReport— only the Wave 4 warning entry remains.- No secret value appears anywhere under
src/app-bootstrap/build/evidence/(grep -rthe generated values finds nothing).
What Wave 3 explicitly does not do
- No warning removal (Wave 4) and no build-logic extraction (Wave 5).
- No promotion of object storage into the
app-bootstrapruntime — that decision is separate from the five master switches and is not made here. - No reuse of local MinIO or Keycloak credentials in
prod-smoke. - No acceptance of
minio-initsuccess as round-trip evidence, of Keycloak readiness as realm evidence, or of a successful startup as issuer evidence.