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

710 lines
38 KiB
Markdown

# 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`](../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 | `DatabaseReadinessGroupPostProcessor``db` 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.active`**does 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 clients**`auth-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.