Files
clean-architecture-backend-…/docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md
T

38 KiB

Wave 0 — Red Baseline Evidence

  • Repository HEAD at capture: 2f5d2fc21954286213c1474d19935f571ef896ea
  • Captured: 2026-08-15
  • Toolchain: JDK 21.0.11, Gradle 9.0.0, Docker Engine 29.7.2, Docker Compose 5.4.0
  • Plan: 2026-08-15-wave0-red-baseline.md

Exit state

$ cd src && ./gradlew wave0RedReport --console=plain --no-daemon
BUILD SUCCESSFUL in 1m 7s

12 red, 1 unexpectedly green. The red set matches the plan's expected table except for StartupWarningZeroTest, recorded as a deviation below.

Red test Closed by Confirmed cause
SecretLeakScannerCharacterizationTest.methodCallWithSafeSuffixIsNotALeak Wave 2 C1 safe-suffix $ anchor cannot match past a captured ()
SecretLeakScannerCharacterizationTest.numericFencingIsNotALeak Wave 2 C1 every + read as string concatenation
FiveAdapterOffInventoryTest.jpaOffHoldsNothing Wave 1 T4/T9 see JPA inventory below
FiveAdapterOffInventoryTest.messagingOffHoldsNothing Wave 1 T7 see messaging inventory below
FiveAdapterOffInventoryTest.notificationOffHoldsNothing Wave 1 T3/T8 see notification inventory below
ShippedRuntimeFacadePresenceTest.mongoFacadeIsShipped Wave 1 T5 ClassNotFoundException — not on the runtime classpath
ShippedRuntimeFacadePresenceTest.graphQlFacadeIsShipped Wave 1 T6 ClassNotFoundException
ShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShipped Wave 2 C3 ClassNotFoundException
DefaultProfileBootCharacterizationTest.localProfileStartsWithShippedDefaults Wave 1 T9 relay-enabled with blank broker
DefaultProfileBootCharacterizationTest.devProfileStartsWithShippedDefaults Wave 1 T9 / Wave 3 T2 same validator reached first
ComposeMergeCharacterizationTest.devStackRenders Wave 3 T3 duplicate /var/tmp/heap mount target
ReleaseManifestTaskExistenceTest.mongoReleaseContractNamesOnlyRegisteredTasks Wave 2 B5 three unregistered tasks

Green as planned: StartupWarningRecorderTest, RuntimeMembershipClasspathAgreementTest, ComposeMergeCharacterizationTest base/local, ShippedRuntimeFacadePresenceTest JPA/notification, FiveAdapterOffInventoryTest Mongo/GraphQL (vacuously — see Task 4), the three non-red scanner cases, DefaultProfileBootCharacterizationTest.prodProfileRefusesPlaintextJdbc.

Task 1 — secret scanner

$ ./gradlew :messaging:messaging-observability:test --tests '*SecretLeakScannerCharacterizationTest*'
SecretLeakScannerCharacterizationTest > RED: incrementing a fencing token is arithmetic, not concatenation FAILED
SecretLeakScannerCharacterizationTest > RED: a method call whose name ends in a safe suffix is not a leak FAILED
5 tests completed, 2 failed

The pre-existing repository failure this characterizes:

$ ./gradlew :messaging:messaging-observability:test --tests '*SecretLeakStaticScanTest*'
SecretLeakStaticScanTest > noSensitiveIdentifierIsConcatenatedIntoAString() FAILED
java.lang.AssertionError: [a concatenated secret never reaches the redactor, so it must not be written at all]
Expecting empty but was: ["KafkaSecurityConfigurer.java:104 + oauth.credentialId());",
    "InMemoryAdminOperationJournal.java:110 existing.leaseToken() + 1,"]

Task 3 — off-state inventories

Captured from the failure messages, with all five switches off on local. These are the exact type lists Wave 1 works down.

JPA off — a connection pool, the entity/repository scan, and the H2 vendor configuration all exist:

com.zaxxer.hikari.HikariDataSource
dev.caskeleton.adapter.outbound.persistence.audit.DomainContextAuditContextPort
dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig
dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings
dev.caskeleton.adapter.outbound.persistence.failure.PersistenceExceptionTranslator
dev.caskeleton.adapter.outbound.persistence.failure.StandardSqlStateErrorMapping
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileTransitionRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverCleanupRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverQuotaRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.FileserverRecoveryRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaFileRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.JpaUploadSessionRepository
dev.caskeleton.adapter.outbound.persistence.fileserver.repository.UploadLeaseRepository
dev.caskeleton.adapter.outbound.persistence.h2.H2IdempotencyClaimRepository
dev.caskeleton.adapter.outbound.persistence.h2.H2LocalTimeoutConfigurer
dev.caskeleton.adapter.outbound.persistence.h2.H2OutboxClaimRepository
dev.caskeleton.adapter.outbound.persistence.h2.H2PersistenceConfig
dev.caskeleton.adapter.outbound.persistence.h2.H2SqlStateErrorMapping
(list truncated in the assertion message)

Confirms JPA-INT-001 and the JPA half of §4.3's consumer table: the Fileserver repositories are on this list, which is why Wave 1 Task 4's DataSourceRequirement must name Fileserver as a relational consumer rather than treating the pool as JPA's alone.

Messaging off:

dev.caskeleton.adapter.outbound.messaging.MessagingConfig
dev.caskeleton.adapter.outbound.messaging.MessagingSettings
dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher
dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterConfig
dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterSettings
dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher
dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter

Note DisabledMessagePublisher and DisabledOutboxMessagePublisher: the sentinel behaviour is correct, but off-invariant item 9 requires the composition root to supply it rather than the adapter. Wave 1 Task 7 moves it.

Notification off — settings bind with the master off, which is NTF-INT-005 exactly:

dev.caskeleton.adapter.outbound.notification.NotificationConfig
dev.caskeleton.adapter.outbound.notification.NotificationRoutesSettings
dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier
dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailNotificationAdapterConfig
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings
dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackNotificationAdapterConfig
dev.caskeleton.bootstrap.notification.NotificationPlatformSecretsConfig$NotificationSecretsSettings

Task 5 — default-profile boot

Both local and dev fail on the same validator, with the message it was written to give:

org.springframework.beans.factory.BeanCreationException: Error creating bean with name
'outboxRelayBrokerRequirementValidator' defined in class path resource
[dev/caskeleton/bootstrap/outbox/OutboxConfig.class]: ca-skeleton.outbox.relay-enabled is enabled
but app.messaging.broker is blank, so every claimed outbox row would fail to publish and be
exhausted to DEAD. Either configure a broker, or set ca-skeleton.outbox.relay-enabled=false so
PENDING rows are preserved until one exists.

dev reaches this validator before the ddl-auto=update conflict the spec recorded from bootRun, so the ddl-auto failure is currently masked. It will surface once Wave 1 Task 9 sets relay-enabled: false, and Wave 3 Task 2 closes it.

Task 7 — Compose merge

$ docker compose -f docker-compose.yml -f docker-compose.dev.yml config
services.app.volumes[1]: target /var/tmp/heap already mounted as services.app.tmpfs[1]

Base and local render cleanly (app, and app,db respectively).

Task 8 — runtime project closure

$ ./gradlew :app-bootstrap:runtimeClasspathManifest
$ cat app-bootstrap/build/architecture/runtime-project-closure.txt

The manifest resolves and RuntimeMembershipClasspathAgreementTest is green — direct dependencies and the resolved closure agree today, because no build-only leaf is reachable. This is the gate that must stay green while Waves 1 and 2 add the Mongo, GraphQL, and messaging edges.

Task 9 — release manifest

ReleaseManifestTaskExistenceTest FAILED
the Mongo release contract names task(s) that no build file registers, so a release manifest can
report them green without ever running them
missing: [mongoAtlasTest, mongoKmsTest, mongoShardedTest]
registered: [mongoReplicaSetTest, mongoFailoverTest, mongoMigrationTest, mongoCompatibilityTest,
             mongoSecurityIntegrationTest, mongoPerformanceTest, mongoStableContractTest]

scripts/verify-mongodb-advanced.sh:95 also invokes mongoShardedTest, so that script cannot currently succeed either.


Deviations from the plan

1. StartupWarningZeroTest is green, not red

The plan expected this red. It passes: an all-off, non-web local startup emits zero WARN and zero ERROR.

That is not a contradiction of spec §9.1 — the warnings recorded there (BeanPostProcessorChecker on the authorization beans, two Micrometer late-MeterFilter warnings, a Flyway converter warning on dev) were observed during bootRun, which is a web application with JPA active. This test is narrower than the configuration that produces them.

Consequence for Wave 4: StartupWarningZeroTest as written does not yet measure the warnings Wave 4 must remove. Wave 4 Task 6 already calls for extending it to all three environments and every one-on combination; that extension must also use WebApplicationType.SERVLET, or the gate will keep passing while the warnings remain. Recorded here so the omission is not discovered as a surprise.

2. A test-harness defect surfaced first, and is not a production defect

Booting CaSkeletonApplication in-JVM from app-bootstrap's own test source set fails before any adapter is examined:

BeanDefinitionOverrideException: Invalid bean definition with name 'outboxEventJpaRepository'
defined in ... @EnableJpaRepositories declared on PersistenceJpaConfig: ... there is already
[...] defined in ... @EnableJpaRepositories declared on OutboxContainerTestSupport.OutboxRepositoryConfig

Cause: CaSkeletonApplication component-scans dev.caskeleton.bootstrap; this module's test sources live in that package; so an in-JVM boot discovers a test-only configuration (OutboxContainerTestSupport.OutboxRepositoryConfig) that the shipped jar has never contained.

This is a property of the measurement, not of the product — bootRun is unaffected. Left alone it would have reported the same cause for every activation characterization and hidden the defects they exist to name. ShippedCompositionHarness registers a TypeExcludeFilter that drops candidates whose class file came from a test output directory, which is a rule about provenance rather than a class-name list somebody has to maintain.

This is a workaround for measuring, not a fix. The faithful instrument is running the produced jar as a child process, which is what Wave 2 Task E3 builds for GraphQL. If Wave 1's off-invariant work needs stronger evidence than the harness can give, promote the activation tests to that shape rather than trusting the exclusion.

3. A property-precedence trap worth remembering

SpringApplicationBuilder#properties(String...) contributes to defaultProperties, the lowest-precedence source, so the all-off set lost to application.yml's relay-enabled: true and the "all-off" context died on the relay validator. The harness passes --key=value command-line arguments instead. Any later test that sets an all-off baseline must do the same.

Files added by Wave 0

Production sources changed: none. Verified by inspection — every path below is a test source or a Gradle lane registration.

File Kind
src/messaging/messaging-observability/src/test/java/.../SecretLeakScannerCharacterizationTest.java test
src/app-bootstrap/src/test/java/.../activation/AdapterActivationInventory.java test fixture
src/app-bootstrap/src/test/java/.../activation/ShippedCompositionHarness.java test fixture
src/app-bootstrap/src/test/java/.../activation/FiveAdapterOffInventoryTest.java test
src/app-bootstrap/src/test/java/.../activation/ShippedRuntimeFacadePresenceTest.java test
src/app-bootstrap/src/test/java/.../activation/DefaultProfileBootCharacterizationTest.java test
src/app-bootstrap/src/test/java/.../activation/StartupWarningRecorder.java test fixture
src/app-bootstrap/src/test/java/.../activation/StartupWarningRecorderTest.java test
src/app-bootstrap/src/test/java/.../activation/StartupWarningZeroTest.java test
src/app-bootstrap/src/test/java/.../compose/ComposeMergeCharacterizationTest.java test
src/app-bootstrap/src/test/java/.../registry/RuntimeMembershipClasspathAgreementTest.java test
src/app-bootstrap/src/test/java/.../registry/ReleaseManifestTaskExistenceTest.java test
src/app-bootstrap/build.gradle lane registration (runtimeClasspathManifest, wave0Red)
src/messaging/messaging-observability/build.gradle lane registration (wave0Red)
src/build.gradle lane registration (wave0RedReport)

Wave 1 progress note (same session)

The repository-wide test is green

$ cd src && ./gradlew test --console=plain --no-daemon --continue
BUILD SUCCESSFUL in 3m 20s

It was red at HEAD before this work, on SecretLeakStaticScanTest (spec §3.1). Two changes made it green, and neither is a suppression:

  1. The scanner defect is fixed (Wave 2 C1, pulled forward because it was the only thing keeping the build red). A captured method call keeps its trailing (), so the safe-suffix exemption's $ anchor never matched — stripping the call fixes it, where unanchoring the pattern would also have exempted credentialIdentity, which does carry the value. And a + with a numeric literal on either side is arithmetic, which cannot put a secret into a string. The three true-positive cases in the characterization are what prove the fix was not a weakening.
  2. Wave 0's deliberately-red characterizations are excluded from the ordinary test task and reported by wave0RedReport instead. A permanently red test is a build nobody can use, and a gate nobody can use stops catching the regressions it exists for. Wave 6 requires the tag set to be empty, so the exclusion cannot quietly become forgetting.

Remaining red set: 4

Red Closed by
ShippedRuntimeFacadePresenceTest.graphQlFacadeIsShipped Wave 1 T6
ShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShipped Wave 2 C3
ComposeMergeCharacterizationTest.devStackRenders Wave 3 T3
ReleaseManifestTaskExistenceTest.mongoReleaseContractNamesOnlyRegisteredTasks Wave 2 B5

Down from 12 at the Wave 0 baseline.

Known defect left as found: the shared-contract scope rule

CleanArchitectureTest.SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES has two faults that mask each other:

  • its subject pattern is ..shared.., which matches any package named shared anywhere rather than the shared-contract module;
  • its allowlist contains the bare module root dev.caskeleton.shared and the check matches by prefix, so every package inside the module is blessed automatically.

Net effect: the rule catches nothing inside the module it is named after, and does catch unrelated leaves that happen to have a shared package — which is how it surfaced, when the Mongo leaf joined the composition root's analysis scope.

Fixing it properly requires the violation fixture that proves the rule works to move inside dev.caskeleton.shared, which then puts the fixture in the rule's own analysis scope, which requires reworking how fixtures are scanned. That is a separate change from adapter activation, so the rule is left as found with the defect documented at the rule itself, and the Mongo tenancy package added to the allowlist to keep the build honest in the meantime. This is technical debt, not a fix.

Corrections to the spec and to these plans, found by executing them

Claim Reality
ca-skeleton.idempotency.provider The property does not exist. It is ca-skeleton.capabilities.idempotency.provider.
app.fileserver.enabled It is app.fileserver-platform.enabled.
Notification/GraphQL selectors are legacy master aliases They are subordinate settings that stay valid while the adapter is on. Treating them as aliases made every shipped configuration ambiguous.
AutoConfigurationImportFilter registers in .imports It registers in META-INF/spring.factories. Getting this wrong fails open silently — the filter simply never runs.
The JPA root can import one exported entry Inverting the vendor→config import to create one produced a package cycle. The composition root names both vendor configs instead, and the export surface admits them with that reason recorded.

Shipping Mongo pulled in reactive Mongo

Adding the Mongo leaf to app-bootstrap put spring-boot-starter-data-mongodb-reactive and mongodb-driver-reactivestreams on the runtime classpath. The index's scope boundaries exclude reactive Mongo from the shipped Stable runtime, and leaving the starter there would have let Boot build a second client and pool from the same URI as soon as the master switch went on. Both are excluded at the composition root rather than in the leaf, which still compiles the reactive paths for a future promotion.


Session close — Wave 1 complete, Wave 2/3 partially landed

Verified state

$ cd src && ./gradlew test --console=plain --no-daemon --continue
BUILD SUCCESSFUL in 2m 58s

$ ./gradlew verifyEnvKeys verifyCleanArchitectureDependencies \
    verifyRuntimeModuleMembership verifyPublicPathSnapshot
verifyRuntimeModuleMembership: 2 runtime composition(s) match the registry
BUILD SUCCESSFUL

$ ./gradlew wave0RedReport
1 red remaining

12 red at the Wave 0 baseline → 1. The survivor is ShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShipped, which Wave 2 C3 closes: the messaging platform has no production sender, only a test fake, so giving it runtime membership now would ship a path that cannot work.

Wave 1 — complete (13/13)

Task Delivered
T1 MasterSwitch / MasterSwitchParser in shared-contract — five names in one place, strict parse
T2 MasterSwitchEnvironmentPostProcessor — rejects before any detail namespace binds
T3 Both composition-root scans narrowed; the properties scan names its packages because it has no excludeFilters
T4 PersistenceJpaRootAutoConfiguration + DataSourceRequirement + JpaOffAutoConfigurationImportFilter
T5 Mongo shipped, one authority, reactive starter excluded from the runtime
T6 GraphQL shipped, one authority, Boot GraphQL auto-configurations filtered
T7 Messaging bridge gated; disabled sentinels moved to the composition root
T8 NotificationRootAutoConfiguration — secrets and registries named rather than scanned
T9 Migration, outbox and idempotency under capability roots
T10 CapabilityDependencyValidator — 8 rules, each naming the exact missing switch
T11 DatabaseReadinessGroupPostProcessordb membership derived from the capability closure
T12 8 env registry rows + YAML binding + a contract test derived from the enum
T13 Membership gate reads the resolved runtime closure instead of declared dependencies

Measured effect, JPA off: ~30 beans → 0 (pool, entity scan, repositories, Hibernate, Flyway, DB health all gone).

Pulled forward from later waves

  • Wave 2 C1 — the secret scanner's two false positives fixed at the cause. This is what made a repository-wide green test possible; it had been red at HEAD.
  • Wave 2 B5 — the three ghost Mongo lanes demoted to experimental_contracts rather than implemented, with the script and doc updated to match.
  • Wave 3 T1 — profileless deploys refused, scoped to the deployable artifact so slice tests are unaffected.
  • Wave 3 T3 — the dev Compose tmpfs: !override [] fix; merge verified and mount targets checked for uniqueness in the merged model.

The defaulting cascade, and what it cost

Removing the relay's blanket refusal exposed the failure underneath it, exactly as this document predicted — and then that one exposed a third. The sequence was:

  1. relay-enabled with a blank broker (fixed in T9);
  2. ddl-auto=update against a Flyway-owned schema (fixed by changing the tracked .env);
  3. logging.level.root failing to bind, because 62 placeholders in application.yml had no inline default at all and only application-local.yml pinned enough of them for one profile to start.

55 of those 62 now carry a default. The remaining seven are deliberate: the datasource URL, username and password, the application name, and the JWT issuer and audience — a default for any of them is a deployment running against something nobody chose. CORS allowed-origins was moved out of that set after the fact: CORS is off by default and an empty origin list is the safest value rather than an arbitrary one, so it defaults to empty.

One of the added defaults was itself wrong — max-age-seconds got 600s while the field is a long — which is worth recording because it only surfaced through a real boot. A bulk defaulting pass needs a boot per profile to be believed.

Still open

  • ShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShipped (Wave 2 C3).
  • The ..shared.. ArchUnit rule remains defective and documented at the rule itself; see the earlier note. Unchanged this session.
  • Waves 2 (remaining), 3 (Compose lane matrix, Keycloak, MinIO), 4, 5 and 6 are untouched.

Continuation — Wave 3 T2 and Wave 4 T1 landed; a Wave 4 fix disproved

Verified state

$ cd src && ./gradlew test                        BUILD SUCCESSFUL
$ ./gradlew verifyEnvKeys verifyCleanArchitectureDependencies \
    verifyRuntimeModuleMembership verifyPublicPathSnapshot
                                                  BUILD SUCCESSFUL
$ ./gradlew wave0RedReport                        2 red

Wave 3 T2 — env source separation, complete

src/.env is untracked. src/.env.example (309 keys, generated from the registry, 12 secret-classified keys left empty) and src/.env.local.example are tracked in its place, and .gitignore carries src/.env* with the two examples negated.

verifyEnvKeys now reads the example. Reading the real file made it false in both directions: it passed only where an operator's own environment file happened to exist, and it would have passed with no example at all — so the file an adopter actually copies was never verified, while a file full of real credentials was a build input. Proven by deleting src/.env and re-running: green.

Its rule B was inverted while retargeting. It required every key in the file to be an application.yml placeholder, which is true of a hand-maintained .env and false of a catalogue — most registry keys are bound by typed settings inside a leaf. Inverted to "every registered APP_ key appears in the example", it now catches the drift that actually matters: a key added to the registry that never reached the file an adopter copies.

SPRING_PROFILES_ACTIVE was also corrected in the registry — type: enum, required: true, no default — to match Wave 3 T1.

Wave 4 T1 — MeterFilter ordering, complete

MetricsContractConfig and SampleMetricsContractConfig both install their filters through a MeterRegistryCustomizer instead of a @PostConstruct that fetched the registry. The warning was the visible half of the real defect: a filter applies only to meters registered after it, so the cardinality and distribution policies were being applied to some meters and not others. Both composition roots changed together, because fixing one leaves the warning reproducible from the other.

The Boot 4 package is org.springframework.boot.micrometer.metrics.autoconfigure, found by inspecting the resolved jars rather than assumed.

Wave 4 T4 — attempted, disproved, and left as found

The spec calls the structured log's profile field a drift: it reads SPRING_PROFILES_ACTIVE, so overriding the profile on the command line while a stale value sits in the environment stamps lines with the stale one. The drift is real.

The obvious fix — bind the field to spring.profiles.activedoes not work, and an existing contract test said so. LogProfileDriftCharacterizationTest was written to settle the question by observation rather than argument, and it observed an empty string: Logback initialises before that property resolves. A field that says nothing is not an improvement on a field that is sometimes wrong.

The binding was reverted to what it was, with the reason recorded at the declaration, and the characterization kept as a tagged red. The fix needs a different mechanism — setting the logger context property from the resolved environment once it is ready, rather than declaring the source in XML — which is Wave 4's to build.

Remaining red: 2

Red Closed by
ShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShipped Wave 2 C3 — no production sender exists, only a test fake
LogProfileDriftCharacterizationTest Wave 4 — needs the mechanism above

Continuation — Wave 3 Compose lane matrix (static half) and Keycloak realm

Verified state

$ cd src && ./gradlew test                                          BUILD SUCCESSFUL
$ ./gradlew verifyEnvKeys verifyCleanArchitectureDependencies \
    verifyRuntimeModuleMembership verifyPublicPathSnapshot          BUILD SUCCESSFUL
$ ./scripts/verify-compose-profile-contracts.sh
  all 15 lanes match src/config/runtime/compose-profile-contracts.json
$ ./gradlew wave0RedReport                                          2 red

What landed

src/config/runtime/compose-profile-contracts.json — the lane SSOT. 15 lanes, each fixing a Compose profile, a file stack, an explicit Spring runtime, and the exact service set that stack must render. Exact rather than superset: a lane that quietly gains a service is a lane whose evidence describes a different stack than the one that ran.

docker-compose.infra.yml — every shared service, each carrying Compose profiles so nothing starts unless a lane names it: PostgreSQL, a single-node Mongo replica set with an idempotent initiator, Kafka, Mailpit, MinIO with bucket bootstrap, Keycloak, and the three one-shot smoke clients. Infrastructure no longer lives inside environment overlays, which is what let local stop meaning "the app plus a database".

docker-compose.prod-smoke.yml — a production-shaped runtime whose JDBC URL carries sslmode=verify-full, so the prod validators are satisfied rather than bypassed.

scripts/verify-compose-profile-contracts.sh — the static entry point, wired into the ordinary test suite so a lane cannot drift until somebody remembers to run a shell script. It checks the Compose version floor, the exact service set per lane, the rendered SPRING_PROFILES_ACTIVE, and mount-target uniqueness in the merged model.

Keycloak realm (infra/keycloak/) — ca-skeleton-api as a confidential client with a service account, standard flow and direct access grant off, an audience mapper and realm/client role mappers. The client secret is a ${...} reference; the entrypoint reads it from a mounted secret file and execs kc.sh, so no value reaches Git, the rendered config, or docker inspect.

Smoke clientsauth-smoke (the seven realm checks, against the same issuer URL the app is given), object-storage-smoke (upload → HEAD → download → delete → wrong-credential rejection, none skippable), notification-smoke (three phases, so the handoff lane's accept and verify are the same client talking about the same request id).

What the verifier caught immediately

Writing it was worth it before running anything. On first execution it failed four lanes:

  • off-local rendered app,db, because the db service was still in the local overlay as well as in the new infra file;
  • three lanes could not render at all, because the local overlay's depends_on: db pointed at a service their profile does not enable — Compose refuses that outright.

Both are the same mistake: infrastructure declared where the environment is described. db now lives only in the infra file, and the depends_on is gone — ordering belongs to the runtime-smoke wrapper, which knows which services a lane actually starts.

Two follow-on defects found and fixed

Untracking src/.env broke local Compose on a fresh clone. docker compose config failed outright because the local overlay declared env_file: ./src/.env. Marked required: false, and verified by moving the file aside: the stack renders. A convenience override had become a hard prerequisite for rendering the stack at all.

The developer host-port contract moved with the service. DeveloperExperienceContractTest.localComposePublishesTheHostPortTheCommittedDatasourceUrlTargets asserted against the local overlay. It now asserts against the infra file and additionally that the service carries the local-jpa profile — without which the port assertion would pass for a service no lane ever brings up.

Still open in Wave 3

scripts/run-compose-runtime-smoke.sh — the dynamic half — is not written. Nothing here has been started; what is verified is that all 15 lanes render exactly what they claim, with the right Spring runtime and no mount collisions. The lanes have not been run, and this document does not claim they have.


Continuation — the Compose lanes actually run

Verified state

$ cd src && ./gradlew test verifyEnvKeys verifyCleanArchitectureDependencies \
    verifyRuntimeModuleMembership verifyPublicPathSnapshot      BUILD SUCCESSFUL
$ ./scripts/verify-compose-profile-contracts.sh                 all 15 lanes match
$ ./scripts/run-compose-runtime-smoke.sh --lane off-local       passed
$ ./scripts/run-compose-runtime-smoke.sh --lane off-dev         passed
$ ./scripts/run-compose-runtime-smoke.sh --lane off-prod        passed

Each lane's own report, fetched from the running application rather than asserted from the flags the lane passed in:

lane activeProfile switches on dataSourceRequiredBy
off-local local none not required
off-dev dev none not required
off-prod prod none not required

docker ps -a and docker volume ls show no surviving casmoke project or volume.

This is the Wave 1 exit criterion, demonstrated for the first time in a real container: all five adapters off, three environments, no infrastructure of any kind, and the application reporting so itself.

What was built

scripts/run-compose-runtime-smoke.sh — the dynamic entry point. Unique project per lane, evidence directory that refuses to reuse a previous run's, static contract then config then create, up --wait on long-running services only, a bounded readiness poll, every declared one-shot with a non-zero exit failing the lane, sanitized evidence, and a trap teardown scoped to the lane's own project — logs collected before the teardown, not after.

AdapterActivationEndpoint / AdapterActivationReport — the application's own answer about what resolved on. A lane asserting on its own environment passes whenever it set the variables correctly, which is not the claim being made.

Six defects the lanes found, in the order they surfaced

Each was invisible to every check that existed before, and none would have been found by reading.

  1. A stale image. The app service declares both build: and image:, so Compose reused a tag from an older state of the repository — the first run failed on a class that no longer exists in the tree. The wrapper now builds explicitly. A lane running a stale image produces evidence about code nobody changed.
  2. The actuator is on its own connector. Fetching 8080/actuator returned an empty file that read exactly like a failed assertion about the profile.
  3. The activation endpoint was authenticated. Management auth is JWT, so only a lane with an identity provider could have read it — excluding the all-off lanes, whose claim is the hardest to check any other way. It is now permit-all alongside health/info/prometheus, and AdapterActivationReportShapeTest holds it to property names and booleans so that stays true. ManagementActuatorSecurityContractTest records the allowlist decision rather than absorbing it.
  4. off-local was passing by luck. It read the developer's own src/.env for the seven deliberately-undefaulted values. The wrapper now generates them per run, so a lane reproduces anywhere rather than on the machine it was written on.
  5. The dev overlay has no healthcheck, so up --wait returned as soon as the container was created and the first fetch landed before startup finished. The wrapper polls with a bound rather than trusting --wait alone.
  6. environment: beat env_file: in the prod overlay. APP_DATASOURCE_PASSWORD: "${APP_DATASOURCE_PASSWORD:-}" read the host shell, not the lane's generated file, and injected an empty string — which the prod env validator then refused, correctly, about a value the lane had actually supplied.

Not claimed

The twelve infrastructure-bearing lanes have not been run. --matrix exists and is untested against them; what is demonstrated is the three all-off lanes end to end and that all fifteen render exactly what they claim. Keycloak, MinIO, Mongo, Kafka and Mailpit have been written and rendered, not started.


Continuation — the infrastructure lanes, and what running them found

Verified state

$ cd src && ./gradlew test verifyEnvKeys verifyCleanArchitectureDependencies \
    verifyRuntimeModuleMembership verifyPublicPathSnapshot      BUILD SUCCESSFUL
$ ./scripts/verify-compose-profile-contracts.sh                 all 15 lanes match
$ ./scripts/run-compose-runtime-smoke.sh --matrix src/config/runtime/compose-profile-contracts.json
  all 4 blocking lanes passed

No surviving casmoke container or volume.

lane activeProfile switches on vendor
off-local local none none
off-dev dev none none
off-prod prod none none
local-mongo local persistence-mongo none

local-mongo is the first adapter proven on against real infrastructure: a single-node replica set, the Mongo master switch on, no other switch on, and no relational connection required.

The contract gained two assertions, because a green lane was not yet a meaningful one

expectedSwitchesOn. A lane named local-jpa that ran with JPA off would render the right services, start cleanly, and prove nothing. The wrapper now compares the switches the application reports against what the lane asked for.

expectedPersistenceVendor. local-jpa passed for a while against H2 while the PostgreSQL container it started sat untouched beside it — application-local.yml pinned an in-memory URL as a literal, which outranks any environment a caller supplies. Every other field in the report looked correct. The report now carries the vendor resolved from the JDBC URL, and the lane asserts it.

That pin was not unique. application-local.yml also pinned app.messaging.broker: "" and the two notification provider selectors, so local-messaging started Kafka, set APP_MESSAGING_BROKER=kafka and was refused by the dependency validator for a value it had supplied. All four are placeholders now; the defaults are unchanged, so a developer who sets nothing gets exactly what they got before.

Four defects in the wrapper itself

  1. It reported success for lanes it never ran. docker compose exec consumes stdin, and inside a plain while read loop it ate the remaining lanes — the first ran, the loop ended, and the script said all six passed. Reading on fd 3 fixes it; a ran-count guard makes a partial matrix a failure rather than a pass. A wrapper whose own success message is a false green is worse than no wrapper.
  2. Compose project names reject uppercase, so the run id is lowercased for the project while the evidence directory keeps the readable timestamp.
  3. env_file lists merge and the later file wins. The developer's optional src/.env, declared by the local overlay after the base, silently overrode the lane's own values. Lane settings now go into a generated environment: overlay, which beats every env_file regardless of order.
  4. up --wait is not a readiness gate where no healthcheck exists — the dev overlay has none, so the first fetch landed before startup finished. The wrapper polls with a bound.

Two spec findings confirmed in a real composition, not inferred

MSG-INT-003. With a healthy Kafka and the broker selected, startup fails on a missing KafkaSender bean: the legacy Kafka configuration requires a project-supplied sender, and production has none — only the tests provide a fake. This is precisely why the messaging platform leaves must not get runtime membership before a real transport bridge exists.

GQL-INT-002. APP_GRAPHQL_DEPLOYMENT_MODE is registered and bound, but the platform still reads the old production boolean and environment enum, which default to false and PRODUCTION_PUBLIC. The startup validator therefore sees a production deployment with introspection enabled and refuses.

A third was found that the spec did not predict: shipping GraphQL into the same context as the rest of the application produced two Clock beans, because the platform's clock was conditioned on its own bean name rather than on the type. Every injection point wanting a Clock failed to start. It now backs off on the type, which is what auto-configuration is for — and this could not have happened while the leaf was build-only.

Lanes marked not-blocking, with reasons recorded in the contract

local-jpa, local-messaging-outbox, the three notification lanes, shared-infra-local, shared-infra-dev, prod-smoke and all-adapters are blocked on an open JPA finding: the entity scan is unconditional while the Flyway migration streams are partitioned by capability, so ddl-auto=validate against real PostgreSQL fails on fs_cleanup_item. Scoping the entity scan to active capabilities is Wave 2 JPA work.

local-messaging is blocked on MSG-INT-003 and local-graphql on GQL-INT-002, both above.

Each carries its reason in compose-profile-contracts.json and each keeps its assertions, so the lanes fail loudly rather than passing against the wrong thing.