# 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.autoconfigure.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.