feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링

This commit is contained in:
DongHyeonka
2026-08-18 10:59:56 +09:00
parent 2f5d2fc219
commit e98b56eb03
372 changed files with 25131 additions and 20357 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,281 @@
# Five-Adapter Runtime Remediation — Plan Index
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
> (recommended) or superpowers:executing-plans to implement the wave plans task-by-task. Steps use
> checkbox (`- [ ]`) syntax for tracking. **Read this index first** — its Global Constraints section
> is implicitly part of every task in every wave plan.
**Goal:** Ship a single `app-bootstrap` bootJar that carries the MongoDB, Messaging, Notification,
JPA, and GraphQL runtime facades on one classpath, each governed by an explicit master env switch
that defaults to `false`, with `false` meaning zero beans/sockets/threads/endpoints and `true`
meaning startup-time fail-closed dependency validation.
**Architecture:** One activation authority per adapter. Each of the five adapters gets exactly one
master-gated root auto-configuration registered in `AutoConfiguration.imports`; that root owns the
master condition and imports every child configuration. The composition root's broad component scan
and `@ConfigurationPropertiesScan` are narrowed so a leaf's stereotypes and
`@ConfigurationProperties` cannot be discovered outside its root. Vendor Spring Boot
auto-configuration (JPA/Flyway/Hikari, Mongo, GraphQL, Kafka/Rabbit) is blocked in the off state by
`AutoConfigurationImportFilter`s, following the mechanism `MongoOptInAutoConfigurationImportFilter`
already establishes. Subordinate capabilities that consume an adapter (outbox relay, JDBC
idempotency, distributed lock, notification store, DB readiness) are computed from the same
dependency closure and fail closed at startup rather than at first request.
**Tech Stack:** Java 21, Spring Boot 4.0.0, Gradle 9.0.0 (multi-module, `src/` as the Gradle root),
JUnit 5 + AssertJ, ArchUnit 1.3.0, Testcontainers, Flyway, PostgreSQL 1618, MongoDB, Kafka/RabbitMQ,
Keycloak, MinIO, Docker Compose 5.4.0 (spec floor: 2.24.4).
**Spec:** [`docs/superpowers/specs/2026-08-15-five-adapter-runtime-remediation-review-design.md`](../specs/2026-08-15-five-adapter-runtime-remediation-review-design.md)
---
## Baseline facts verified at HEAD `2f5d2fc`
These were re-verified in this repository before the plans were written. Every wave argues from
them; do not re-derive them from the spec's prose.
| Fact | Evidence |
| --- | --- |
| Registry has 44 modules; `adapter-outbound-persistence-mongo` has `allowed_dependencies: []` and `runtime_memberships: []` | `src/config/architecture/modules.json` |
| `adapter-inbound-graphql` has `runtime_memberships: []` | same |
| All 24 `messaging-*` leaves have `runtime_memberships: []` | same |
| `app-bootstrap.allowed_dependencies` has 12 entries and lists neither mongo, graphql, nor any `messaging-*` platform leaf | same |
| `CaSkeletonApplication` already excludes `dev\.caskeleton\.bootstrap\.autoconfigure\..*` from its component scan, but its `@ConfigurationPropertiesScan` has **no** such exclusion | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java:29-53,66-68` |
| `app-bootstrap` registers 3 auto-configurations: fileserver, httpclient, jpa | `src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
| The JPA platform auto-configuration lives in **`app-bootstrap`**, not in the JPA leaf | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java` |
| Mongo registers 2 auto-configurations with no master-gated single root | `src/adapter/outbound/persistence-mongo/src/main/resources/META-INF/spring/...imports` |
| Messaging starter registers 5 independent auto-configurations, none master-gated | `src/messaging/messaging-spring-boot-starter/src/main/resources/META-INF/spring/...imports` |
| `spring.profiles.active: ${SPRING_PROFILES_ACTIVE:local}` — profileless boots as local | `src/app-bootstrap/src/main/resources/application.yml:22-24` |
| `ca-skeleton.outbox.relay-enabled: true` is the shipped default | `src/app-bootstrap/src/main/resources/application.yml:539` |
| `APP_IDEMPOTENCY_PROVIDER` default is `jdbc` | `src/app-bootstrap/src/main/resources/application.yml:352` |
| `management.endpoint.health.group.readiness.include: readinessState,db` is static, with `validate-group-membership: true` | `src/app-bootstrap/src/main/resources/application.yml:248,278` |
| `src/.env` is **git-tracked**; no `.env.example` and no `.env.local.example` exist | `git ls-files \| grep '\.env'` |
| `logback-spring.xml` reads `SPRING_PROFILES_ACTIVE` with `defaultValue="local"`, independent of the real active profile | `src/app-bootstrap/src/main/resources/logback-spring.xml:8-9` |
| `scripts/` holds only 3 files; there is no compose verification or runtime-smoke script | `ls scripts/` |
| `infra/` has no `keycloak/` or `minio/` directory | `find infra -maxdepth 2 -type d` |
| Full `test` fails on exactly one test with two offenders | reproduced below |
### The one reproduced red test
```
$ cd src && ./gradlew :messaging:messaging-observability:test \
--tests '*SecretLeakStaticScanTest*' --console=plain --no-daemon
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,"]
```
Root cause, confirmed by reading the scanner: `CONCATENATION_OPERAND` captures a method call
*including* its trailing `()`
(`src/messaging/messaging-observability/src/test/java/dev/caskeleton/messaging/observation/SecretLeakStaticScanTest.java:39-42`),
but `DESCRIBES_RATHER_THAN_REVEALS` anchors its safe suffixes with `$`
(same file, `:47-49`). So `tail` is the string `credentialId()`, the `$` anchor never matches `Id`,
and the safe-suffix exemption is dead for every method call. The second offender,
`existing.leaseToken() + 1`, is numeric fencing — an integer increment, which cannot concatenate at
all — and needs a separate exemption for numeric operands.
### Environment capabilities confirmed
| Tool | Version | Consequence |
| --- | --- | --- |
| Docker Engine | 29.7.2 | Wave 3 Compose lanes are executable here |
| Docker Compose | 5.4.0 | above the spec's 2.24.4 floor, so `!override` merge semantics are available |
| JDK | 21.0.11 | matches the toolchain |
---
## Global Constraints
Every task in every wave plan implicitly includes this section.
**Repository policy**
- Commit policy is `human-only`. Agents do **not** run `git add`, `git commit`, `git amend`, or
`git push`. Where a wave task says "Commit", it means: stop, report the staged-file list and the
proposed message to the human, and let them commit. (`AGENTS.md:65`)
- The eight HARD-STOP conditions in `AGENTS.md:17-24` outrank every instruction in these plans.
- `src/config/architecture/modules.json` is the only source of a leaf's Gradle path, allowed
dependency edges, and runtime memberships. Never infer them from a document.
- Focused tests are derived as `./gradlew <gradle_path>:test --console=plain`, read from that
registry.
- Non-trivial work ends with an LLM Wiki capture at
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/<branch-name>.md`
(`AGENTS.md:79-90`).
- All Gradle commands run from `src/`, which is the Gradle root.
**Activation contract (spec §5.1) — exact values, copied verbatim**
| Adapter | canonical env | Spring property | default |
| --- | --- | --- | --- |
| JPA | `APP_PERSISTENCE_JPA_ENABLED` | `ca-skeleton.persistence-jpa.enabled` | `false` |
| MongoDB | `APP_PERSISTENCE_MONGO_ENABLED` | `ca-skeleton.persistence-mongo.enabled` | `false` |
| Messaging | `APP_MESSAGING_ENABLED` | `app.messaging.enabled` | `false` |
| Notification | `APP_NOTIFICATION_PLATFORM_ENABLED` | `ca-skeleton.notification.platform.enabled` | `false` |
| GraphQL | `APP_GRAPHQL_ENABLED` | `backend.graphql.enabled` | `false` |
Subordinate selectors registered alongside them:
| env | Spring property | contract |
| --- | --- | --- |
| `APP_PERSISTENCE_MONGO_ACTIVE_PROFILE` | `ca-skeleton.persistence-mongo.active-profile` | required non-blank when Mongo is on; selects exactly one profile |
| `APP_GRAPHQL_DEPLOYMENT_MODE` | `backend.graphql.deployment-mode` | required when GraphQL is on; one of `LOCAL`, `DEV`, `PRODUCTION_INTERNAL`, `PRODUCTION_PUBLIC` |
GraphQL deployment mode is constrained by the runtime environment:
| runtime environment | permitted GraphQL mode |
| --- | --- |
| `local` | `LOCAL` |
| `dev` | `DEV` |
| `prod` | exactly one operator-named value of `PRODUCTION_INTERNAL` or `PRODUCTION_PUBLIC` |
`TEST` is test-source only. `STAGING` is not permitted in a shipped env key until a `stage` runtime
environment exists.
**Master scalar parsing rule (spec §5.1)**
Master scalars are parsed *before* any detail `@ConfigurationProperties` binds, and strictly:
- unset ⇒ `false`;
- the only accepted raw values are `true` and `false`, case-insensitive, with no surrounding
whitespace;
- `yes`, `1`, `on`, empty string, and any typo are a **configuration error**, never a silent off;
- canonical and legacy key both present ⇒ rejected as ambiguity, even when the values agree;
- legacy key alone ⇒ migration error that names the replacement key.
The early validator must not bind the detail namespace, or it breaks the off invariant it exists to
protect.
**Off invariant (spec §5.2) — the acceptance shape for every "off" test**
With its master switch `false`, an adapter must satisfy all of the following in a
**full-context** test:
1. its detail `@ConfigurationProperties` are neither bound nor validated;
2. it owns zero production beans;
3. no socket, client, connection pool, session, executor, scheduler, or watcher is created;
4. JPA-off additionally means zero `DataSource`/`HikariDataSource`, zero `EntityManagerFactory`,
zero Flyway, and zero DB health/metrics beans;
5. no migration and no schema validation runs;
6. no health contributor and no actuator detail is registered;
7. for an inbound adapter, no route, schema, or controller is exposed;
8. an invalid detail setting left in the environment does not block startup;
9. where the application requires a port bean unconditionally, the disabled sentinel is supplied by
the **composition root**, not by the adapter, and fails fast with `ADAPTER_DISABLED` when called.
This is implemented by **structural gating** — one root auto-configuration owning the master
condition and importing children — never by repeating `@ConditionalOnProperty` on each bean.
**Profile cardinality (spec §7.1)**
A deployable runtime has exactly one environment profile. `SPRING_PROFILES_ACTIVE` becomes an enum
`local|dev|prod` with **no default**. Missing, blank, unknown, and multi-value (`local,prod`) are all
startup failures. Feature selection is never expressed as a supplementary Spring profile — that is
what the five master switches are for. The `test` profile is test-source only; a release artifact
booting under `test` is rejected.
**Evidence rules**
- A finding is not closed by an auto-configuration existing; it is closed by a test that exercises
the real path.
- Class-existence assertions and test-only Basic Auth never count as release evidence.
- Secret values must not appear in Git, rendered config, command lines, JUnit XML, or evidence
artifacts.
- A blocking lane that discovers zero tests, skips a test, or reads a stale XML fails.
- Never claim "complete" / "all passing" / "production-ready" without the corresponding command
output. Use `superpowers:verification-before-completion`.
**Compose contract (spec §7.2)**
- Minimum Docker Compose version pinned at `2.24.4` in docs and CI.
- `config/runtime/compose-profile-contracts.json` is the SSOT for lane → profile → file stack →
Spring runtime → exact sorted service set.
- `scripts/verify-compose-profile-contracts.sh` is the only static entry point;
`scripts/run-compose-runtime-smoke.sh` is the only dynamic entry point. CI must not inline
fragments of either.
---
## Wave map
Each wave is a separate plan that produces working, testable software on its own. Execute them in
order; a wave's exit criterion is the entry criterion of the next.
| Wave | Plan | Delivers | Exit criterion |
| --- | --- | --- | --- |
| 0 | [wave0-red-baseline](2026-08-15-wave0-red-baseline.md) | Characterization tests that pin every current defect as an explicit, named red | Every spec §2 failure is reproduced by a test that fails for the documented reason |
| 1 | [wave1-activation-ssot](2026-08-15-wave1-activation-ssot.md) | Five canonical switches, structural gating, classpath/registry alignment, dependency closure validators | `all-off` boots on `local`, `dev`, and `prod` with no external infrastructure |
| 2 | [wave2-module-on-path](2026-08-15-wave2-module-on-path.md) | Per-adapter on-path blockers closed (JPA-INT-001..4, MNG-INT-001..5, MSG-INT-001..5, NTF-INT-001..7, GQL-INT-001..4) | Each adapter's one-on lane passes against real infrastructure |
| 3 | [wave3-environment-and-infra](2026-08-15-wave3-environment-and-infra.md) | Env-source separation, profileless fail-closed, Compose contract SSOT + both scripts, Keycloak realm, MinIO round trip | The full Compose lane matrix passes zero-skip with evidence |
| 4 | [wave4-warning-zero](2026-08-15-wave4-warning-zero.md) | MeterFilter ordering, BeanPostProcessor early-instantiation removal, Flyway warning root cause, IDE suppression narrowing, log/profile agreement | `local`, `dev`, `prod` startup logs contain zero WARN and zero ERROR, with an empty allowlist |
| 5 | [wave5-gradle-build-logic](2026-08-15-wave5-gradle-build-logic.md) | `build-logic` included build with eight TestKit-tested convention plugins; duplicated source-set/lane/API-surface machinery removed | Task graph, dependency graph, test selection, and evidence output are byte-identical to the Wave 4 baseline |
| 6 | [wave6-final-qualification](2026-08-15-wave6-final-qualification.md) | Full `clean check`, the activation matrix, every environment smoke, doc/metadata drift checks, Wiki capture | Every Definition-of-Done checkbox in spec §13 is ticked with attached evidence |
### Design patterns (spec §8) — where each one lands
Spec §8 is a constraint on *how* the waves are built, not a deliverable of its own. It is mapped here
so no executor treats it as unassigned.
| Pattern to apply | Where |
| --- | --- |
| Conditional auto-configuration as a plugin boundary — one root condition owns the whole adapter graph | Wave 1 Tasks 48 |
| Strategy + registry — provider selection is a closed descriptor plus a real implementation registry; unknown or duplicate rejected at startup | Wave 2 C4 (broker), D2 (notification provider) |
| Factory / Builder — one factory composes secret, TLS, pool, and lifecycle together | Wave 2 B2 (Mongo client), C3 (broker client), D2 (provider) |
| State machine + fencing — durable transitions guarded by owner/fencing token and DB compare-and-set | Wave 2 D5 (notification delivery), C2/C3 (outbox, settlement) |
| Typed settings + validator — no scattered `@Value`, no duplicate namespace; validate the resolved runtime object | Wave 2 A1 (resolved `DataSource`), Wave 1 Task 10 |
| Decorator — metrics, redaction, retry only at boundaries, never altering core behaviour | Wave 4 Task 1 |
| Pattern to avoid | Enforced by |
| --- | --- |
| The same `@ConditionalOnProperty` copied onto every adapter bean | Wave 1's structural gating; index §Off invariant closing paragraph |
| A plain factory named `...AutoConfiguration` mixed with real auto-configuration | Wave 1 Tasks 48 convert imported factories to `@Configuration` |
| `ObjectProvider` absence silently becoming a no-op, hiding missing production wiring | Wave 2 C3 (no fake sender), D2 (no assembler ⇒ capability stays off) |
| `@Primary` resolving a JPA/Mongo implementation clash by accident | Wave 1 Task 10's ambiguity rejection |
| A fake or in-memory implementation offered as a production runtime fallback | Wave 2 Global Constraints ("No fake in production") |
| One over-general DSL merging release matrices whose provider meanings differ | Wave 5 Global Constraints |
| Moving `build.gradle` content into `apply from:` files while leaving the duplicated model | Wave 5 Task 9 exit criteria |
The aim is not more patterns. It is one activation authority, one publication authority, one settings
SSOT, and a real execution path.
### Dependency ordering rationale
Wave 5 is deliberately last-but-one and never shares a diff with runtime changes: moving build logic
on top of a red or unverified baseline produces a task graph that looks green because a task
silently stopped existing (spec §14). Wave 3 depends on Wave 1 because a Compose lane cannot assert
an activation report that does not exist yet. Wave 2's per-module fixes depend on Wave 1's single
activation authority, or each module invents its own.
---
## Scope boundaries carried from spec §14
These plans approve **an assemblable artifact that is off by default**. They do not approve every
internal algorithm of the five platforms as production-ready. The following stay explicitly out of
scope and must not be silently promoted:
- Mongo **reactive** support — the reactive starter/auto-configuration is removed from the
production runtime or blocked even when the master is on. Not listed as supported.
- Mongo **change streams**`experimental`, always `false`, zero beans and zero threads. A
replica-set qualification observing that the server *could* support change streams is not evidence
of shipped support.
- Mongo **transactions** — a typed subordinate switch defaulting to `false`; when on, the real
replica-set capability of the data-plane credential is verified.
- `mongoShardedTest`, `mongoAtlasTest`, `mongoKmsTest` — the Mongo release registry points at tasks
and classes that **do not exist**. Either implement them with protected-environment evidence, or
remove their Stable blocking claim and demote them to explicit experimental/conditional promotion.
A green release manifest naming a task that does not exist is not permitted.
- Notification at-rest payload sensitivity (NTF-INT-007) — Notification is not promoted to Stable
until either application-level encryption is implemented end to end (codec/port, ciphertext
envelope, key ID, rotation/history, row migration, decryption failure contract) or a written
threat model justifies restricted variable types plus storage-level encryption. Plaintext storage
is not approved by default.
- Object storage inclusion in the `app-bootstrap` runtime is a **separate** decision from the five
master switches. If it is not included, the MinIO smoke client is a release fixture only, never a
production bean.
- Fileserver internals are not redesigned. Only the composition consumers that break `all-off` are
gated or turned into dependency errors; module hardening stays a separate spec.
Each wave plan restates the boundary that applies to it, so an executor reading one plan in
isolation cannot promote something this index excluded.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,612 @@
# Wave 2 — Module On-Path Blockers Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
> (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
> **Read [`2026-08-15-five-adapter-runtime-remediation-index.md`](2026-08-15-five-adapter-runtime-remediation-index.md)
> first.**
> **Entry criterion:** Wave 1 complete — all-off boots on `local`, `dev`, and `prod`, and every
> `FiveAdapterOffInventoryTest` case is green.
**Goal:** Close every per-module blocker that stands between "the switch turns it on" and "the thing
it turns on actually works against real infrastructure", for all five adapters.
**Architecture:** Wave 1 decided whether a bean exists; Wave 2 decides what it does. Each adapter
gets one publication/settings/assembly authority to match the one activation authority it now has:
JPA validates the *resolved* `DataSource` rather than a parallel property namespace; Mongo builds
exactly one sync client from the active profile's credential; Messaging replaces the fake sender with
a real transport bridge and gains its Stable facade membership only in the change that proves a live
broker round trip; Notification gains production provider assemblers and a frozen-route ingest
contract; GraphQL collapses its two contradictory safety axes into one deployment mode carried
through the real request path.
**Tech Stack:** Testcontainers (PostgreSQL 16/17/18, MongoDB replica set, Kafka, RabbitMQ, Mailpit),
Spring GraphQL, Spring Security OAuth2 resource server, Flyway, Micrometer.
**Spec:** [`2026-08-15-five-adapter-runtime-remediation-review-design.md`](../specs/2026-08-15-five-adapter-runtime-remediation-review-design.md)
(§6 in full, §11 Wave 2, §12.2, §12.3)
---
## Global Constraints
Inherited from the index. Wave 2 adds:
- **Membership is earned, never granted in advance.** A Stable facade gains `runtime_memberships`
and an `app-bootstrap` dependency **in the same change unit that proves it works against real
infrastructure**. Wiring first and qualifying later ships a known-broken path.
- **No fake in production.** A fixture, in-memory implementation, or test double must not be
reachable from a production runtime path. Where the production implementation is absent, the
capability stays off and its promotion claim is removed from the registry.
- **Each finding is re-reproduced at current HEAD before it is fixed.** The five detailed module
reviews below remain authoritative inputs, but they were written against a different HEAD; do not
copy a failure forward without reproducing it.
- `docs/reviews/2026-08-14-mongodb-module-code-review.md`
- `docs/reviews/2026-08-14-messaging-module-code-review.md`
- `docs/reviews/2026-08-14-notification-module-code-review.md`
- `docs/reviews/2026-08-14-jpa-module-code-review.md`
- `docs/reviews/2026-08-14-graphql-module-code-review.md`
- **A P0 correctness or security finding on a runtime path is a prerequisite, not a follow-up.**
Connecting wiring over a known data-loss path is forbidden (spec §11 Wave 2 closing note).
---
## Section A — JPA
### Task A1: Validate the resolved DataSource, not a parallel namespace (JPA-INT-002)
**Files:**
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceSettings.java`
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceProfileValidator.java`
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/PersistenceJpaRootAutoConfiguration.java`
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaResolvedDataSourceValidationTest.java`
**Interfaces:**
- Produces: `JpaDataSourceProfileValidator.validateResolved(DataSource)` — replaces
`validateStable()`. Task A2 and Wave 3's `local-jpa` lane both consume it.
**Context:** Two defects, both verified in the spec against HEAD.
1. `JpaDataSourceSettings` binds `app.jpa-platform.datasource.*` while the pool that actually gets
built comes from `spring.datasource.hikari.*` (`application.yml:25-50`). Two namespaces describing
one pool means the validator can pass while the pool it validated is not the pool in use.
2. `JpaDataSourceProfileValidator` is created as a bean but `validateStable` is never invoked at
startup (`JpaPlatformRuntimeAutoConfiguration.java:93-99,159-165`). A validator nobody calls is a
comment.
The fix removes the duplicate namespace entirely and validates the injected `DataSource`
`HikariDataSource#getMaximumPoolSize`, `getConnectionTimeout`, the resolved JDBC URL, and the product
name and version read from `DatabaseMetaData`. Invocation moves into an `InitializingBean` inside the
JPA root, so it runs exactly when JPA is on and never when it is off.
- [ ] **Step 1:** Write `JpaResolvedDataSourceValidationTest` — a Testcontainers PostgreSQL context
asserting that (a) a pool whose `maximum-pool-size` violates the REQUIRES_NEW lower bound
documented at `application.yml:33-37` fails startup naming `spring.datasource.hikari.maximum-pool-size`;
(b) removing every `app.jpa-platform.datasource.*` property changes nothing, proving the
namespace is dead; (c) an unreachable database fails startup rather than at first query.
- [ ] **Step 2:** Run to verify it fails.
- [ ] **Step 3:** Delete the `app.jpa-platform.datasource` binding from `JpaDataSourceSettings`;
rewrite `JpaDataSourceProfileValidator` to take a `DataSource`; register the invocation in
`PersistenceJpaRootAutoConfiguration`.
- [ ] **Step 4:** Run to verify it passes.
- [ ] **Step 5:** Run `./gradlew :adapter:outbound:persistence-jpa:test :app-bootstrap:test --console=plain --no-daemon`.
- [ ] **Step 6:** Remove the now-dead `app.jpa-platform.datasource.*` rows from
`docs/registries/env-keys.yaml`; run `./gradlew verifyEnvKeys`.
- [ ] **Step 7:** Commit.
### Task A2: Separate local H2 from the default-off contract (JPA-INT-003)
**Files:**
- Modify: `src/app-bootstrap/src/main/resources/application-local.yml`
- Create: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/LocalJpaVendorParityTest.java`
- Modify: `src/app-bootstrap/build.gradle` (a named non-release H2 developer task)
**Context:** H2 becomes repository/unit-test scope plus one explicitly-named developer convenience
task. The user-facing completion criterion — "local JPA runtime smoke" — uses PostgreSQL, Flyway, and
`ddl-auto=validate`, so local and dev share vendor semantics. Consequences to implement:
- `local` all-off boots with **no database at all** (Wave 1 already delivers this).
- `local` + `APP_PERSISTENCE_JPA_ENABLED=true` requires the Compose PostgreSQL of Wave 3's
`local-jpa` lane.
- Profile absence must never resolve to H2/`create-drop`; Wave 3 Task 1 makes profile absence a
startup error, and this task removes the H2 default that made absence dangerous.
- [ ] **Steps 16:** TDD cycle. The parity test asserts that `local` + JPA-on resolves the same
vendor, migration mode, and schema policy as `dev` + JPA-on, differing only in address and
credential.
---
## Section B — MongoDB
### Task B1: Move to the canonical Boot 4 namespace (MNG-INT-002, part 1)
**Files:**
- Modify: every source and test referencing `spring.data.mongodb.*`
- Test: `src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/MongoNamespaceContractTest.java`
**Context:** `spring.data.mongodb.*` is deprecated at error level in Spring Boot 4 metadata; the
canonical namespace is `spring.mongodb.*`. `MongoPersistenceProperties`' own Javadoc points operators
at the deprecated one. Building the new activation design on a namespace Boot reports as an error is
building on sand.
- [ ] **Step 1:** Write a test asserting no production source or resource references
`spring.data.mongodb.` — a source-tree scan, in the shape of the existing
`SecretLeakStaticScanTest`.
- [ ] **Steps 26:** migrate, run `./gradlew :adapter:outbound:persistence-mongo:test`, update the
Javadoc, commit.
### Task B2: One SSOT from active profile to the real `MongoClientSettings` (MNG-INT-002, part 2)
**Files:**
- Create: `src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/client/MongoClientSettingsFactory.java`
- Modify: `MongoRootAutoConfiguration` to register it as a `MongoClientSettingsBuilderCustomizer`
- Test: `.../client/MongoClientSettingsFactoryTest.java`
**Context:** Typed profile, credential resolver, TLS, Stable API, and pool/timeout policy exist but
are not connected to the builder Boot actually uses. One factory consumes the active profile and the
secret reference and produces the real settings; the test asserts on the built
`MongoClientSettings`, not on the intermediate typed objects.
Cardinality contract, from index §Scope boundaries: exactly one sync client and one pool; zero
reactive inventory; secrets resolved **only** for the active profile — a profile present in the map
but not selected must not have its secret read or its client built.
- [ ] **Steps 16:** TDD cycle asserting: one client, one pool, zero reactive beans, and that a
non-active profile's deliberately-invalid secret reference is never resolved.
### Task B3: Startup validation that cannot fail open (MNG-INT-003)
**Files:**
- Modify: `src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformAutoConfiguration.java`
- Test: `.../autoconfigure/MongoStartupValidationTest.java`
**Context:** The startup check is created only when a `MongoTopologyProbe` bean is present
(`MongoPlatformAutoConfiguration.java:137-183`), so a missing probe silently skips validation
entirely. Additionally, transactions and change streams are hardcoded `true, true` in the capability
flags, and absent admin credentials are treated as "no validation needed".
The fix, per index §Scope boundaries:
- Mongo on ⇒ a probe is built from the live data-plane client, or the absence is a startup error.
- Capability flags come from typed settings, never literals.
- Admin credential/gateway belongs to a migration or deployment job's composition; the shipped
application neither binds nor requires it.
- `transactions` is a subordinate switch defaulting `false`; when on, the real replica-set capability
of the data-plane credential is verified.
- `change-streams` is experimental, always `false`, zero beans and zero threads. A replica-set
qualification observing server capability is not evidence of shipped support.
- [ ] **Steps 16:** TDD cycle with one case per bullet.
### Task B4: Separate role from credential identity (MNG-INT-004)
**Files:**
- Modify: the credential identity hash implementation
- Test: `.../MongoCredentialIdentityTest.java`
**Context:** The identity hash includes the role, so the same secret reference used under two roles
looks like two different credentials, defeating the separation it was meant to enforce.
- [ ] **Steps 15:** TDD cycle; the regression case is one secret reference under two roles, which
must be detected as the same credential.
### Task B5: Resolve the three ghost release lanes
**Files:**
- Modify: `src/config/mongodb/release-contracts.json` **or** `src/adapter/outbound/persistence-mongo/build.gradle`
- Modify: `docs/mongodb/advanced/sharding.md`, `scripts/verify-mongodb-advanced.sh`
- Test: `ReleaseManifestTaskExistenceTest` (Wave 0 Task 9) is the arbiter
**Context:** Verified at HEAD: `release-contracts.json` names `mongoShardedTest` (line 30),
`mongoAtlasTest` (38), and `mongoKmsTest` (46); `persistence-mongo/build.gradle` registers seven mongo
lanes and none of those three. `scripts/verify-mongodb-advanced.sh:95` invokes `mongoShardedTest`, so
that script currently cannot succeed either.
Two legitimate outcomes — choose one and record the decision in the plan's evidence log:
- **(a) Implement.** Register the three lanes with real required classes and protected-environment
evidence, and include them in the Stable blocking set.
- **(b) Demote.** Remove the Stable blocking claim from `release-contracts.json`, move the three to
an explicit experimental/conditional promotion section, update `docs/mongodb/advanced/sharding.md`
to stop describing an unrunnable gate, and make `verify-mongodb-advanced.sh` fail with a clear
"not promoted" message rather than invoking a task that does not exist.
**Recommendation: (b).** Sharding, Atlas, and KMS each need a protected environment this repository
does not have, and index §Scope boundaries already places them outside the shipped Stable runtime.
Implementing them to satisfy a manifest entry would be the tail wagging the dog.
- [ ] **Steps 15:** apply the chosen outcome, run
`./gradlew :app-bootstrap:test --tests '*ReleaseManifestTaskExistenceTest*'` to green, remove
its `@Tag("wave0-red")`, commit.
---
## Section C — Messaging
### Task C1: Fix the secret scanner without weakening it (MSG-INT-005)
**Files:**
- Create: `src/messaging/messaging-observability/src/main/java/dev/caskeleton/messaging/observation/SecretConcatenationClassifier.java`
- Modify: `src/messaging/messaging-observability/src/test/java/dev/caskeleton/messaging/observation/SecretLeakStaticScanTest.java`
- Modify: `src/messaging/messaging-observability/src/test/java/dev/caskeleton/messaging/observation/SecretLeakScannerCharacterizationTest.java`
**Interfaces:**
- Produces: `SecretConcatenationClassifier.leaksASensitiveValue(String line)` — extracted from the
test so the characterization can call the real thing.
**Context:** Root cause, confirmed by reading the scanner (index §The one reproduced red test):
`CONCATENATION_OPERAND` captures a method call including its trailing `()`, while
`DESCRIBES_RATHER_THAN_REVEALS` anchors with `$`. The safe-suffix exemption is therefore dead for
every method call. Second offender: `existing.leaseToken() + 1` is arithmetic.
The two fixes:
1. Strip a trailing `()` from `tail` before matching the safe-suffix pattern. Not: loosen the anchor —
an unanchored `Id` would exempt `credentialIdentity`, which does carry the value.
2. Treat an operand paired with a numeric literal as arithmetic. Detect it by inspecting the *other*
side of the `+`: a decimal, hex, or floating literal makes the expression arithmetic.
Neither fix may weaken true-positive detection, which is what the characterization's first three
cases exist to prove.
- [ ] **Step 1:** Extract the classifier to `src/main/java` unchanged, and point both tests at it.
Run — the same 2 failures, now against the real class.
- [ ] **Step 2:** Apply fix 1. Run — `methodCallWithSafeSuffixIsNotALeak` green, true positives still
green.
- [ ] **Step 3:** Apply fix 2. Run — `numericFencingIsNotALeak` green.
- [ ] **Step 4:** Delete the duplicated classifier from
`SecretLeakScannerCharacterizationTest` and have it call the extracted one, per that file's own
Javadoc promise.
- [ ] **Step 5:** Run `./gradlew :messaging:messaging-observability:test --console=plain --no-daemon`
— the full module green, including `SecretLeakStaticScanTest`.
- [ ] **Step 6:** Run `./gradlew test --console=plain --no-daemon --continue` — the repository-wide
suite, which spec §3.1 recorded as failing on exactly this test. Record the result.
- [ ] **Step 7:** Commit.
### Task C2: One publication authority and one settings owner (MSG-INT-002)
**Files:**
- Modify: `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingSettings.java` (delete after migration)
- Modify: `src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/MessagingProperties.java`
- Modify: `src/config/architecture/modules.json`
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/messaging/MessagingAuthorityContractTest.java`
**Context:** Three authorities share the `app.messaging` namespace today: `MessagingSettings`
(adapter), the legacy `KafkaSender` config, and `MessagingProperties` (starter). The target:
```text
app-bootstrap
-> adapter:outbound:messaging # application port bridge
-> messaging runtime starter # Stable runtime assembly
adapter:outbound:messaging
-> messaging-core-api # platform publish contract only
messaging runtime starter
-> selected platform implementation leaves
```
Registry edges to add: `app-bootstrap -> messaging-spring-boot-starter` and
`adapter-outbound-messaging -> messaging-core-api`.
Port discipline: the real application-owned contract is `OutboxMessagePublishPort`. The legacy generic
`MessagePublisher` is an adapter-local type and must not leak into core. If general publish becomes a
use-case need, define an application port first.
Forbidden: legacy and new publisher both emitting the same event (dual write).
- [x] **Characterization and the dual-write guard:** done. `MessagingAuthorityContractTest` records
the two owners of `app.messaging` by name and asserts that exactly one production type
implements `OutboxMessagePublishPort`, plus that the adapter-local `MessagePublisher` does not
reach `application-core` or `domain-core`. A source scan, because no module sees both the
adapter and the starter — which is the boundary working, not a gap in the test.
- [ ] **The settings collapse moves into C3's change unit.** Deleting `MessagingSettings` means the
adapter stops selecting a broker and becomes a port bridge over the platform's publish
contract; it can only do that once the platform *has* a production publisher. Removing the
binding first would leave the adapter unable to select anything, which is a worse state than
the split it fixes. The guard above is what keeps the split honest until then, and it fails the
moment a third owner appears or a second publisher starts emitting.
### Task C3: A real production sender, and membership earned by a live round trip (MSG-INT-003)
**Files:**
- Create: the production Kafka and RabbitMQ sender implementations in the selected platform leaves
- Modify: `src/config/architecture/modules.json`, `src/app-bootstrap/build.gradle`
- Test: `src/app-bootstrap/src/test/java/.../MessagingLiveRoundTripQualificationTest.java` (Testcontainers Kafka)
**Context:** The legacy Kafka config requires a project-supplied `KafkaSender` that exists only as a
test fake. A production app therefore has no sender at all, and every "messaging works" signal comes
from a fixture.
**Membership rule, enforced here:** the starter and every internal leaf that actually resolves onto
the runtime classpath gain `app-bootstrap` membership **in the same change unit** that turns
`MessagingLiveRoundTripQualificationTest` green against a real broker. Leaves that are unsupported or
unqualified are excluded from both the starter's dependencies and the registry.
Because Wave 1 Task 13 made the membership gate closure-based, adding the starter will surface every
transitive leaf at once — that is intended, and each must be either recorded as a member or removed
from the starter's dependency graph.
- [ ] **Steps 18:** TDD cycle ending with `./gradlew verifyRuntimeModuleMembership` green and
`ShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShipped` green with its tag removed.
### Task C4: One master-gated starter root (MSG-INT-004)
**Files:**
- Create: `src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/MessagingPlatformRootAutoConfiguration.java`
- Modify: `src/messaging/messaging-spring-boot-starter/src/main/resources/META-INF/spring/...AutoConfiguration.imports`
- Test: `.../MessagingStarterOffContractTest.java`
**Context:** Verified at HEAD, the starter registers five independent auto-configurations
(`MessagingCoreAutoConfiguration`, `KafkaMessagingAutoConfiguration`,
`RabbitMessagingAutoConfiguration`, `MessagingReliabilityAutoConfiguration`,
`MessagingAdminAutoConfiguration`) and none carries a messaging master condition.
After this task `imports` holds exactly one entry — the root — which imports the selected provider and
reliability children. Kafka and Rabbit must never assemble together merely because both client
libraries are on the classpath; provider selection is a closed descriptor + registry, and an unknown
or duplicate selection is a startup error.
The off test is full-context and includes starter imports **and** vendor Boot auto-configuration:
zero beans, zero clients, zero threads.
- [ ] **Steps 17:** TDD cycle.
---
## Section D — Notification
### Task D1: Fix the mode SSOT drift (NTF-INT-002)
**Files:**
- Modify: `docs/registries/env-keys.yaml:4266-4277`
- Modify: every YAML, doc, and test using `ACCEPT_ONLY`
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationModeSsotTest.java`
**Context:** The Java enum is `SERVING|INGEST_ONLY`
(`NotificationPlatformMode.java:11-23`); the env registry declares `SERVING|ACCEPT_ONLY`. Canonical
name is `INGEST_ONLY`. The test asserts that the registry's enum values equal
`NotificationPlatformMode.values()` — derived, so it cannot drift again.
- [ ] **Steps 15:** TDD cycle; smallest task in this wave, do it first so later tasks use one name.
### Task D2: Production provider assemblers (NTF-INT-001)
**Files:**
- Create: production `ProviderRuntimeAssembler` implementations under
`src/adapter/outbound/notification/src/main/java/.../platform/provider/`
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationPlatformProviderConfig.java`
- Test: `.../NotificationServingAssemblyTest.java`
**Context:** Configuration assembles `List<ProviderRuntimeAssembler>` but production main source has
no implementation (`NotificationPlatformProviderConfig.java:78-99`). `SERVING` therefore cannot work
in production regardless of settings.
`SERVING` turns on only when the selected provider family has a real assembler, secret resolver,
timeout/rate/permit policy, and readiness contributor. A provider without one is not documented as
Stable — update the capability docs in the same change.
Reference provider for the Wave 3 `local-notification-serving` lane: SMTP via Mailpit.
- [ ] **Steps 17:** TDD cycle.
### Task D3: Mode-scoped worker lifecycle (NTF-INT-003)
**Files:**
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationPlatformWorkerConfig.java`
- Test: `.../NotificationWorkerLifecycleTest.java`
**Context:** The worker config checks only master-enabled and unconditionally `start()`s background
workers and the scheduler (`NotificationPlatformWorkerConfig.java:135-166`). `INGEST_ONLY` must start
zero dispatch, recovery, and reconciliation threads.
Asserted on **live thread count**, via `AdapterActivationInventory.liveThreadNamesMatching`, not on
bean absence — a worker bean that exists but was never started is acceptable; a thread is not.
- [ ] **Steps 16:** TDD cycle.
### Task D4: Retire the legacy selector namespace (NTF-INT-004)
**Files:**
- Modify: `docs/registries/env-keys.yaml`, the notification YAML, and the optional-bean tests
- Test: `.../NotificationLegacyNamespaceRetirementTest.java`
**Context:** The registry and YAML use provider selectors while the actual bean conditions mix
`slack-webhook.enabled`, `google-email.enabled`, and `routes.*`. The delivery platform becomes the
canonical runtime; the legacy R0 selector is isolated behind a migration shim that raises a naming
migration error, then removed.
- [ ] **Steps 16:** TDD cycle; the negative assertion — that no legacy selector is documented as an
activation key — is required by spec §6.4 and belongs in
`MasterSwitchRegistryContractTest` (Wave 1 Task 12).
### Task D5: A frozen, non-empty route plan for INGEST_ONLY (NTF-INT-006)
**Files:**
- Modify: `src/adapter/outbound/notification/src/main/java/.../CanonicalNotificationPlanWriter.java`
- Modify: `.../PolicyRoutePlanner.java`
- Create: versioned route-metadata loading in the notification root
- Test: `src/app-bootstrap/src/test/java/.../NotificationIngestHandoffQualificationTest.java`
**Context:** The most consequential blocker in this section, and the one most easily mistaken for
working. Today the plan writer freezes a provider-specific routing plan at accept time and stores an
**empty** plan when the route catalog is empty (`CanonicalNotificationPlanWriter.java:82-128`,
`PolicyRoutePlanner.java:50-76`). Dispatch consumes the stored snapshot verbatim
(`NotificationDispatchService.java:298-307`). So a row accepted in `INGEST_ONLY` with no routes never
becomes deliverable, no matter how the application is later restarted.
The contract:
- `INGEST_ONLY` startup **requires** versioned route metadata — provider family/id, channel
eligibility, route config version — readable **without** credentials or live provider beans.
- Accept freezes a non-empty immutable plan plus its route version.
- An empty route catalog or empty plan is rejected at startup or at the accept boundary, explicitly.
- No automatic replan after accept. A policy change needing replan is a separate backfill/migration
with operator approval, idempotency, and audit.
- `SERVING` startup verifies the production assembler registry supports every stored provider
ID/version; a mismatched row is never silently reinterpreted.
- During `INGEST_ONLY`: zero provider credentials, zero provider runtime beans, zero workers.
Qualification, which is also Wave 3's `local-notification-handoff` lane:
`INGEST_ONLY accept → process stop → SERVING restart → exactly one delivery on the same frozen
route`, against a real database and provider fixture.
- [ ] **Steps 19:** TDD cycle, ending with the handoff qualification green against Testcontainers
PostgreSQL + Mailpit.
### Task D6: Decide the at-rest payload sensitivity contract (NTF-INT-007)
**Files:**
- Either: create the encryption codec/port, ciphertext envelope, key ID, rotation/history, row
migration, and decryption-failure contract
- Or: create `docs/notification/at-rest-threat-model.md` plus a static variable-type restriction
- Test: `.../NotificationPayloadAtRestContractTest.java`
**Context:** The accept path stores `encoded.variablesPayload()` into the request row in plaintext
(`CanonicalNotificationPlanWriter.java:60-79`). The `PAYLOAD_ENCRYPTION` key is consumed only by
callback raw-payload protection (`AesGcmCallbackPayloadProtection.java:88-97`), so requiring it in
`INGEST_ONLY` would demand a secret that protects nothing — do not paper over the gap that way.
Per index §Scope boundaries, Notification is **not promoted to Stable** until one of the two branches
is complete. Neither branch is optional; pick one, implement it fully, and record the decision.
**Recommendation: the threat-model branch**, if and only if the variable types can genuinely be
restricted to non-sensitive values. Application-level encryption without rotation and migration
designed in is a larger commitment than this wave can honour, and a half-built envelope is worse than
a documented restriction.
- [ ] **Steps 16:** implement the chosen branch fully; a partial implementation of either is a fail.
---
## Section E — GraphQL
### Task E1: Collapse the two safety axes into one deployment mode (GQL-INT-002)
**Files:**
- Modify: `src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformProperties.java`
- Modify: `.../GraphQlPlatformStartupValidator.java`, `.../GraphQlPlatformAutoConfiguration.java`
- Test: `.../GraphQlDeploymentModeContractTest.java`
**Context:** `GraphQlPlatformProperties` defaults to `production=false` **and**
`environment=PRODUCTION_PUBLIC` simultaneously (`GraphQlPlatformProperties.java:32-54`). This is not a
display inconsistency: anonymous principal handling, allow-by-default authorization, and part of
request protection read only the boolean (`GraphQlPlatformStartupValidator.java:33-45`,
`GraphQlPlatformAutoConfiguration.java:302-343,441-459`), so the enum can say production while the
protections behave as if it is not — a configuration split-brain that bypasses production safety.
Both collapse into `backend.graphql.deployment-mode`, with the environment constraint from index
§Global Constraints. Using either legacy key while GraphQL is on, alone or alongside the new one, is a
migration error naming `APP_GRAPHQL_DEPLOYMENT_MODE`. With the master off, the detail namespace is
neither bound nor validated.
- [x] **Steps 17:** done. `GraphQlPlatformEnvironment``GraphQlDeploymentMode`; `production` and
`environment` are gone from the record and `production()` is derived from the mode;
`GraphQlActivationEnvironmentPostProcessor` refuses both retired keys;
`GraphQlDeploymentModeContractTest` (22 cases) and
`GraphQlDeploymentModeRegistryParityTest` are green, and `local-graphql` passes end to end.
**Three amendments made while implementing, all recorded in
[`evidence/2026-08-15-wave2-decisions.md`](evidence/2026-08-15-wave2-decisions.md):**
1. **Four modes, not six.** `TEST` and `STAGING` were unreachable from every runtime profile the
composition-root validator permits, so they are gone. The registry parity test now derives its
cases from the enum.
2. **Boot's introspection default contradicted the platform's** — with the switch on and nothing else
set, `spring.graphql.schema.introspection.enabled=true` against a platform console default of
`false`, and the runtime validator correctly refused a contradiction nobody had configured. The
same post-processor now contributes the platform's console values as the framework's defaults at
the lowest precedence.
3. **The Keycloak realm artifact could never have imported.** Keycloak rejects unknown fields, so the
`_comment` and `_flowComment` annotation keys failed the whole import. Removed, rationale moved to
`infra/keycloak/README.md`, and `verify-compose-profile-contracts.sh` now fails on any `_`-prefixed
key in that artifact. The smoke client also could not read the 0600 secret (uid mismatch) and now
runs as root in-container.
### Task E2: Prove the policy pipeline on the real request path (GQL-INT-003)
**Files:**
- Create: `src/adapter/inbound/graphql/src/test/java/.../GraphQlPolicyRequestPathTest.java`
**Context:** Auto-configuration and a startup validator existing is not evidence that cost, authz,
cursor, and idempotency policies apply. The test uses a random-port `/graphql` and asserts a policy
violation is rejected **before** the resolver or use case is invoked — verified with a spy on the use
case that must record zero invocations — and that JWT actor/tenant context reaches the resolver.
- [ ] **Steps 16:** TDD cycle, one case per policy.
### Task E3: One blocking JWT composition lane (GQL-INT-004)
**Files:**
- Create: `src/app-bootstrap/src/graphqlRuntimeQualificationTest/java/dev/caskeleton/bootstrap/graphql/GraphQlJwtRuntimeQualificationTest.java`
- Modify: `src/app-bootstrap/build.gradle` (register `graphqlRuntimeQualification`)
- Modify: `src/gradle/graphql-platform-conventions.gradle`
- Modify: `src/build.gradle` (`conditionalTransportQualification`)
- Modify: `.github/workflows/ci-quality-gates.yml`
**Context:** Three separate defects, all verified:
1. `ConditionalTransportCompositionContractTest` asserts only that GraphQL classes exist
(`:15-46`) — class existence is not composition evidence.
2. `GraphqlHttpBoundaryQualificationTest` authenticates with test-only Basic Auth
(`:40-59,189-229`) — not the shipped JWT composition.
3. CI runs `check verifyPublicPathSnapshot verifyDependencyLocks` and
`conditionalTransportQualification`, and **never** `graphqlStableTest`
(`.github/workflows/ci-quality-gates.yml:48-53`), so that lane's required-class guard protects
nothing in CI.
The canonical task is `:app-bootstrap:graphqlRuntimeQualification`. It:
- depends on `bootJar`;
- forces required class
`dev.caskeleton.bootstrap.graphql.GraphQlJwtRuntimeQualificationTest` in the
`graphqlRuntimeQualificationTest` source set;
- runs the produced jar as a **child process**;
- obtains a client-credentials token from a Keycloak container importing the same tracked realm
artifact Wave 3 Task 4 creates;
- calls real HTTP `/graphql`;
- writes JUnit XML to `app-bootstrap/build/test-results/graphqlRuntimeQualification` and sanitized
process/claim/startup logs to `app-bootstrap/build/evidence/graphql-runtime/`;
- rejects zero-discovery, any skip, and stale XML.
Then: replace the GraphQL Basic Auth leg of root `conditionalTransportQualification` with this task,
leaving the gRPC and WebSocket legs untouched; keep `GraphqlHttpBoundaryQualificationTest` as a module
contract test but stop aggregating it into release evidence; and change the CI quality job to run
`:adapter:inbound:graphql:graphqlStableTest :app-bootstrap:graphqlRuntimeQualification
conditionalTransportQualification` with a single dependency edge so the GraphQL task cannot execute
twice.
**Ordering note:** this task depends on Wave 3 Task 4 (the Keycloak realm artifact). Either run Wave 3
Task 4 early, or defer E3 to immediately after it. Record which you chose.
- [ ] **Steps 19:** TDD cycle ending with the lane green and the CI workflow updated.
---
## Wave 2 Exit Criteria
- [ ] `./gradlew test --console=plain --no-daemon` — the full ordinary suite green (the single
pre-existing failure closed by Task C1).
- [ ] `./gradlew wave0RedReport --console=plain --no-daemon` — only Wave 3 and Wave 4 entries remain.
- [ ] Each adapter's one-on lane passes against real infrastructure:
```bash
cd src
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=16 --console=plain
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=17 --console=plain
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=18 --console=plain
./gradlew :adapter:outbound:persistence-mongo:mongoStableContractTest \
:adapter:outbound:persistence-mongo:mongoReplicaSetTest \
:adapter:outbound:persistence-mongo:mongoFailoverTest \
:adapter:outbound:persistence-mongo:mongoMigrationTest \
:adapter:outbound:persistence-mongo:mongoCompatibilityTest \
:adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest \
:adapter:outbound:persistence-mongo:mongoPerformanceTest --console=plain
./gradlew :adapter:inbound:graphql:graphqlStableTest \
:app-bootstrap:graphqlRuntimeQualification conditionalTransportQualification --console=plain
```
- [ ] `./gradlew verifyRuntimeModuleMembership verifyCleanArchitectureDependencies verifyEnvKeys --console=plain` — green.
- [ ] Every Section D and Section B decision (B5, D6) is recorded with its rationale in
`docs/superpowers/plans/evidence/2026-08-15-wave2-decisions.md`.
## What Wave 2 explicitly does not do
- No `SPRING_PROFILES_ACTIVE` change, `.env` split, Compose file, Keycloak realm, or MinIO fixture
(Wave 3) — except that Wave 2 Task E3 **consumes** Wave 3 Task 4's realm artifact.
- No warning removal (Wave 4).
- No build-logic extraction (Wave 5).
- No promotion of Mongo reactive, change streams, sharding, Atlas, or KMS.
- No promotion of Notification to Stable until D6 is complete.
@@ -0,0 +1,444 @@
# Wave 3 — Environment Separation and Infrastructure Smoke Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
> (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
> **Read [`2026-08-15-five-adapter-runtime-remediation-index.md`](2026-08-15-five-adapter-runtime-remediation-index.md)
> first.**
> **Entry criterion:** Wave 2 complete — full `test` green, each adapter's one-on lane passing.
> **Exception:** Task 4 (Keycloak realm) is a prerequisite of Wave 2 Task E3 and may be pulled
> forward. If it was, mark it complete here and continue.
**Goal:** Separate example configuration from operator input, make a profileless deployment
impossible, and build the Compose lane matrix — with its two canonical scripts, Keycloak realm, and
MinIO round trip — that proves each activation combination actually runs.
**Architecture:** One JSON contract file (`config/runtime/compose-profile-contracts.json`) is the SSOT
for every lane: its Compose profile, its file stack, its explicit Spring runtime, and its exact sorted
service set. Two scripts are the only entry points — one static
(`verify-compose-profile-contracts.sh`), one dynamic (`run-compose-runtime-smoke.sh`) — and CI calls
those scripts rather than inlining fragments of them, so a lane cannot be half-run by a workflow that
forgot a flag. Shared infrastructure lives in its own `docker-compose.infra.yml`, never mixed into an
environment overlay. Each lane gets a unique Compose project, mode-0700 temp directory, mode-0600
secret files, sanitized evidence, and a `trap`-driven teardown scoped to that project alone.
**Tech Stack:** Docker Compose ≥ 2.24.4 (this machine: 5.4.0), PostgreSQL 16 with TLS, MongoDB replica
set, Kafka, Mailpit, MinIO + `mc`, Keycloak with `--import-realm`, Bash.
**Spec:** [`2026-08-15-five-adapter-runtime-remediation-review-design.md`](../specs/2026-08-15-five-adapter-runtime-remediation-review-design.md)
(§7 in full, §11 Wave 3, §12.4)
---
## Global Constraints
Inherited from the index. Wave 3 adds:
- **A secret value never reaches Git, rendered config, a command line, JUnit XML, or an evidence
artifact.** Secrets are files created per run at mode `0600` and deleted on teardown.
- **`up --wait` applies only to long-running services.** A one-shot client (`auth-smoke`,
`object-storage-smoke`, `notification-smoke`, `minio-init`) is run with `run --rm` and must exit
zero. A required one-shot that is missing, skipped, or non-zero fails the whole lane.
- **Teardown is scoped.** `down --volumes --remove-orphans` runs against the lane's unique
`COMPOSE_PROJECT_NAME` only. Never touch another project or a named volume outside the lane.
- **Reference docs are authoritative for merge and import semantics**, not memory:
- [Docker Compose merge rules](https://docs.docker.com/reference/compose-file/merge/)
- [Keycloak realm import](https://www.keycloak.org/server/importExport)
- **Static verification precedes dynamic.** `config` and `create` must pass before any `up`.
---
## File Structure
### Created
| File | Responsibility |
| --- | --- |
| `src/config/runtime/compose-profile-contracts.json` | The lane SSOT: id, Compose profile, file stack, Spring runtime, exact sorted service set, blocking flag. |
| `docker-compose.infra.yml` | Every shared infrastructure service and one-shot smoke client. Owns nothing environment-specific. |
| `docker-compose.prod-smoke.yml` | TLS PostgreSQL, prod env source, secret references. Test-only. |
| `scripts/verify-compose-profile-contracts.sh` | The only static entry point. |
| `scripts/run-compose-runtime-smoke.sh` | The only dynamic entry point. |
| `infra/keycloak/realms/ca-skeleton-realm.json` | Reproducible realm import. No secret values. |
| `infra/keycloak/entrypoint.sh` | Reads the secret file, exports it, execs `kc.sh start-dev --import-realm`. |
| `infra/keycloak/smoke/auth-smoke.sh` | The one-shot client-credentials + protected-endpoint assertion. |
| `infra/minio/smoke/object-storage-smoke.sh` | upload → HEAD → download → delete → wrong-credential rejection. |
| `infra/minio/init/bucket-bootstrap.sh` | Bucket and minimum policy creation. Not a substitute for the round trip. |
| `infra/notification/smoke/notification-smoke.sh` | Accept/ingest, Mailpit assertion, duplicate check. |
| `src/.env.example` | Public key catalog with empty placeholders. Tracked. |
| `src/.env.local.example` | Local opt-in combination example. Tracked. |
### Modified
| File | Change |
| --- | --- |
| `src/app-bootstrap/src/main/resources/application.yml` | Remove the `${SPRING_PROFILES_ACTIVE:local}` fallback. |
| `docs/registries/env-keys.yaml` | `SPRING_PROFILES_ACTIVE` becomes a defaultless enum `local\|dev\|prod`. |
| `src/app-bootstrap/build.gradle` | Replace the `bootRun`-only `.env` parsing with a single loader; pass an explicit profile. |
| `src/build.gradle` | Rewrite `verifyEnvKeys`'s input contract: registry + profile YAML + `.env.example` + generated metadata; never a gitignored operator `.env`. |
| `docker-compose.yml` | App only; no infrastructure. |
| `docker-compose.local.yml` | Local overlay; profiles for service selection; PostgreSQL moves to infra. |
| `docker-compose.dev.yml` | `tmpfs: !override []` then exactly one `/var/tmp/heap` bind mount; owns `SPRING_PROFILES_ACTIVE=dev` and its env source. |
| `.gitignore` | Ignore `src/.env*` except the two `.example` files. |
| `src/.env` | **Untracked** (`git rm --cached`). It is operator input, not a build input. |
| `src/app-bootstrap/src/main/resources/logback-spring.xml` | Profile field reads the real active profile. |
| `.github/workflows/ci-quality-gates.yml` | Call the two scripts; do not inline their commands. |
---
## Task 1: Make a profileless deployment impossible
**Files:**
- Modify: `src/app-bootstrap/src/main/resources/application.yml:22-24`
- Modify: `docs/registries/env-keys.yaml` (`SPRING_PROFILES_ACTIVE` row)
- Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/RuntimeEnvironmentProfileValidator.java`
- Modify: `src/app-bootstrap/build.gradle` (`bootRun` passes an explicit profile)
- Modify/replace: `EnvProfileMatrixContractTest` and any profileless-permitting test
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/RuntimeEnvironmentProfileValidatorTest.java`
**Interfaces:**
- Produces: `RuntimeEnvironmentProfileValidator`, an `EnvironmentPostProcessor` ordered **after**
`MasterSwitchEnvironmentPostProcessor` (Wave 1 Task 2). Tasks 58 assume exactly one environment
profile is resolvable.
**Context:** Verified at HEAD: `spring.profiles.active: ${SPRING_PROFILES_ACTIVE:local}`. A bootJar
started with no profile silently becomes `local`, which — before Wave 2 Task A2 — also meant H2 and
`create-drop`. Deployable cardinality is exactly one.
Rules: missing ⇒ error; blank ⇒ error; unknown ⇒ error naming the three permitted values; multiple
(`local,prod`) ⇒ error, and `SPRING_PROFILES_ACTIVE` is **not** treated as CSV; `test` ⇒ rejected for a
deployable artifact, permitted only in a test-source context.
Feature selection must not be expressed as a supplementary Spring profile — the five master switches
are for that. The validator therefore rejects any active profile outside the permitted set rather than
ignoring extras.
- [ ] **Step 1:** Write the validator test — one case per rule above, plus one asserting that a
test-source context may still use `test`.
- [ ] **Step 2:** Run to verify it fails.
- [ ] **Step 3:** Remove the `:local` fallback from `application.yml`; write the validator; register
it in `META-INF/spring.factories` next to the master-switch post-processor.
- [ ] **Step 4:** Update the `SPRING_PROFILES_ACTIVE` registry row to a defaultless enum.
- [ ] **Step 5:** Make `bootRun` pass an explicit profile so the developer convenience path stays
usable without reintroducing an implicit default.
- [ ] **Step 6:** Replace `EnvProfileMatrixContractTest`'s local-fallback expectation with the new
fail-closed contract. Do not delete coverage — rewrite it.
- [ ] **Step 7:** Run
`./gradlew :app-bootstrap:test --console=plain --no-daemon` and
`./gradlew verifyEnvKeys --console=plain --no-daemon`.
- [ ] **Step 8:** Commit.
---
## Task 2: Separate example configuration from operator input
**Files:**
- Create: `src/.env.example`, `src/.env.local.example`
- Modify: `.gitignore`
- Untrack: `src/.env` (`git rm --cached src/.env` — the human runs this)
- Modify: `src/build.gradle` (`verifyEnvKeys` input contract, lines ~2209-2229, ~2263-2276)
- Modify: `src/app-bootstrap/build.gradle` (single env loader, replacing the `bootRun`-only parser at ~251-272)
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/EnvSourceSeparationTest.java`
**Context:** Verified at HEAD: `src/.env` **is tracked** (`git ls-files` lists it), there is no
`.env.example`, and `.gitignore` contains only three unrelated lines. The tracked file carries
`SPRING_PROFILES_ACTIVE=local`, `APP_DATASOURCE_DDL_AUTO=update`, and local credentials — which is why
`dev` inherits `ddl-auto=update` and fails.
`verifyEnvKeys` currently **requires** `src/.env` to exist and compares required placeholders against
it. That contract is false in both directions: it passes only when a real secret file is present, and
it would pass with no example file at all. The new SSOT is the env registry, the profile YAMLs,
`.env.example`, and the generated configuration metadata. Gitignored operator `.env*` files and real
secret values leave the build inputs entirely.
`.gitignore` addition:
```gitignore
src/.env*
!src/.env.example
!src/.env.local.example
```
- [ ] **Step 1:** Write `EnvSourceSeparationTest` — asserts `.env.example` exists and every registry
key appears in it; that no example value looks like a real secret (non-empty for a
`classification: secret` row); and that `src/.env` is **not** tracked
(`git ls-files --error-unmatch src/.env` must fail).
- [ ] **Step 2:** Run to verify it fails.
- [ ] **Step 3:** Generate `.env.example` from the registry — every key, secrets as empty
placeholders or `secret://` references. Write `.env.local.example` with a documented local
opt-in combination.
- [ ] **Step 4:** Update `.gitignore`; ask the human to run `git rm --cached src/.env`.
- [ ] **Step 5:** Rewrite `verifyEnvKeys`'s inputs; replace the `bootRun`-only `.env` parser with a
loader that selects the file by environment, or with Spring's standard config import.
- [ ] **Step 6:** Run `./gradlew verifyEnvKeys :app-bootstrap:test --console=plain --no-daemon`.
- [ ] **Step 7:** Commit.
---
## Task 3: The Compose contract SSOT and the static verifier
**Files:**
- Create: `src/config/runtime/compose-profile-contracts.json`
- Create: `scripts/verify-compose-profile-contracts.sh`
- Modify: `docker-compose.yml`, `docker-compose.local.yml`, `docker-compose.dev.yml`
- Create: `docker-compose.infra.yml`, `docker-compose.prod-smoke.yml`
**Interfaces:**
- Produces: the contract file, whose schema is
`{ "minimumComposeVersion": "2.24.4", "lanes": [ { "id", "composeProfile"|null, "files": [...],
"springRuntime", "services": [...sorted], "blocking": true } ] }`.
Task 7's runtime-smoke script and Wave 6's matrix both read it.
**Context:** The full lane table is spec §7.2 and is reproduced here as the exact content to encode.
`base`, `infra`, `local`, `dev`, `prod-smoke` mean `docker-compose.yml`, `docker-compose.infra.yml`,
`docker-compose.local.yml`, `docker-compose.dev.yml`, `docker-compose.prod-smoke.yml`, merged in the
order listed.
| lane | Compose profile | file stack | Spring runtime | services (sorted) |
| --- | --- | --- | --- | --- |
| `off-local` | — | base+local | `local` | `app` |
| `off-dev` | — | base+dev | `dev` | `app` |
| `off-prod` | — | base+prod-smoke | `prod` | `app` |
| `local-jpa` | `local-jpa` | base+infra+local | `local` | `app,db` |
| `local-mongo` | `local-mongo` | base+infra+local | `local` | `app,mongo,mongo-rs-init` |
| `local-messaging` | `local-messaging` | base+infra+local | `local` | `app,kafka` |
| `local-messaging-outbox` | `local-messaging-outbox` | base+infra+local | `local` | `app,db,kafka` |
| `local-notification-ingest` | `local-notification-ingest` | base+infra+local | `local` | `app,db,notification-smoke` |
| `local-notification-serving` | `local-notification-serving` | base+infra+local | `local` | `app,db,mailpit,notification-smoke` |
| `local-notification-handoff` | `local-notification-handoff` | base+infra+local | `local` | `app,db,mailpit,notification-smoke` |
| `local-graphql` | `local-graphql` | base+infra+local | `local` | `app,auth-smoke,keycloak` |
| `shared-infra-local` | `shared-infra` | base+infra+local | `local` | `app,auth-smoke,db,keycloak,minio,minio-init,object-storage-smoke` |
| `shared-infra-dev` | `shared-infra` | base+infra+dev | `dev` | `app,auth-smoke,db,keycloak,minio,minio-init,object-storage-smoke` |
| `prod-smoke` | `prod-smoke` | base+infra+prod-smoke | `prod` | `app,auth-smoke,db,keycloak,minio,minio-init,object-storage-smoke` |
| `all-adapters` | `all-adapters` | base+infra+local | `local` | `app,auth-smoke,db,kafka,keycloak,mailpit,mongo,mongo-rs-init,notification-smoke` |
Service membership rules:
- `auth-smoke``local-graphql`, `shared-infra`, `prod-smoke`, `all-adapters`.
- `object-storage-smoke``shared-infra`, `prod-smoke`.
- `notification-smoke` ∈ the three local notification profiles and `all-adapters`.
- `minio-init` bootstraps bucket and policy; it is **not** a substitute for the round trip.
- A Compose profile selects services; it never implies a Spring profile.
The dev merge fix, using Compose ≥ 2.24.4 semantics: the dev overlay declares
`tmpfs: !override []` to replace the base tmpfs, then declares the `/var/tmp/heap` bind mount exactly
once. Do not assume an empty sequence auto-deletes the base sequence — verify target uniqueness in the
merged JSON, which is what the script's final check does.
The verifier checks, per lane:
1. `docker compose version --short` ≥ the contract's `minimumComposeVersion` (semver compare);
2. `config --services` for the lane's file stack (with `--profile <name>`, omitted for the three off
lanes) equals the contract's sorted set **exactly** — not a superset;
3. the rendered `app` service's `SPRING_PROFILES_ACTIVE` equals the lane's `springRuntime`;
4. `--profile '*' config --format json` renders, and every service's `volumes` + `tmpfs` targets are
unique within that service.
- [ ] **Step 1:** Write the contract JSON encoding the table above.
- [ ] **Step 2:** Restructure the Compose files: move PostgreSQL out of `docker-compose.local.yml`
into `docker-compose.infra.yml`; add Mongo + `mongo-rs-init`, Kafka, Mailpit, MinIO +
`minio-init` + `object-storage-smoke`, Keycloak + `auth-smoke`, `notification-smoke`; add
Compose `profiles:` to each; create `docker-compose.prod-smoke.yml`; apply the `!override` fix
to `docker-compose.dev.yml` and give it `SPRING_PROFILES_ACTIVE=dev` plus its env source.
- [ ] **Step 3:** Write `scripts/verify-compose-profile-contracts.sh` implementing checks 14.
`set -euo pipefail`; `jq` for JSON; exit non-zero with the lane id and the exact diff on any
mismatch.
- [ ] **Step 4:** Run `./scripts/verify-compose-profile-contracts.sh`. Expected: all 15 lanes pass.
- [ ] **Step 5:** Run
`cd src && ./gradlew :app-bootstrap:test --tests '*ComposeMergeCharacterizationTest*'`
— the dev case is now green; remove its `@Tag("wave0-red")`.
- [ ] **Step 6:** Pin the Compose minimum version in `README.md` and the CI workflow.
- [ ] **Step 7:** Commit.
---
## Task 4: The Keycloak realm and its acceptance
> **May be pulled forward** — Wave 2 Task E3 depends on this artifact.
**Files:**
- Create: `infra/keycloak/realms/ca-skeleton-realm.json`
- Create: `infra/keycloak/entrypoint.sh`
- Create: `infra/keycloak/smoke/auth-smoke.sh`
- Modify: `docker-compose.infra.yml`, `src/app-bootstrap/src/main/resources/application-local.yml`
**Context:** The realm defines `ca-skeleton-api` as a **confidential** client with client
authentication and a service account enabled, and with standard flow and direct access grant
**disabled**. The service account carries realm role `user` and client role `graphql-query`; an
audience mapper puts `ca-skeleton-api` into `aud`. Authentication for smoke is OAuth 2.0
`client_credentials` — one method, no alternatives. No test user, no password grant, no direct access
grant.
The seven acceptance checks (spec §7.3):
1. realm `ca-skeleton` imported;
2. client/audience `ca-skeleton-api` exists;
3. the application's roles and the role/permission claim mapping exist;
4. a token is issued via the service account's client credentials;
5. the token has non-blank `sub`, exact `iss`, `aud=ca-skeleton-api`, `realm_access.roles` containing
`user`, and `resource_access.ca-skeleton-api.roles` containing `graphql-query`;
6. public health succeeds unauthenticated; protected REST and GraphQL succeed only with a valid token;
7. wrong realm, wrong audience, and expired token are rejected with the expected safe error contract.
**The issuer trap, and why one hostname is not enough.** `application-local.yml:68-76,142-145` defaults
the issuer to `localhost:8081`. That resolves on the host and, inside the app container, points at the
app itself. JWKS discovery is lazy (`JwtDecoderConfig.java:25-58`), so startup succeeds and the error
only appears at the first protected request. Do not assume one hostname resolves everywhere:
- **bootJar qualification** (Wave 2 E3): inject Testcontainers' *mapped* Keycloak URL into both the
token endpoint and the app issuer — the same single URL on both sides.
- **Compose smoke**: put `app` and `auth-smoke` on the same network and inject
`http://keycloak:8080/realms/ca-skeleton` into both.
A token obtained from one URL and validated against another is not evidence, and neither is a
successful startup.
**Secret handling.** The qualification script creates a URL-safe random secret file at mode `0600` per
run and mounts it as a Compose/Testcontainers secret. `entrypoint.sh` reads
`/run/secrets/keycloak-graphql-smoke-client-secret`, exports it as a process-local
`KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET`, and `exec`s
`/opt/keycloak/bin/kc.sh start-dev --import-realm`. The realm JSON contains only the
`${KEYCLOAK_GRAPHQL_SMOKE_CLIENT_SECRET}` reference. The file is removed on teardown.
- [ ] **Steps 18:** build the realm, entrypoint, and smoke script; wire the Compose service; run
`./scripts/verify-compose-profile-contracts.sh` and then the `local-graphql` lane; confirm all
seven checks; confirm no secret value appears in any rendered config or artifact
(`grep -r` the evidence directory for the generated value must find nothing).
---
## Task 5: MinIO bucket bootstrap and a real object round trip
**Files:**
- Create: `infra/minio/init/bucket-bootstrap.sh`, `infra/minio/smoke/object-storage-smoke.sh`
- Modify: `docker-compose.infra.yml`
**Context:** MinIO readiness is not success. `minio-init` creates the test bucket and minimum policy;
`object-storage-smoke` is a **black-box** one-shot that consumes the lane's endpoint, bucket, and
secret file and performs, in order and with no step skippable:
1. upload known bytes to a random object key;
2. HEAD and verify size and checksum;
3. download and verify byte equality;
4. delete and verify not-found;
5. attempt the same operations with a deliberately wrong credential and verify rejection.
Results are written to `minio-roundtrip.json` in the lane's evidence directory, with no secrets.
Boundaries: the existing object-storage qualification owns its own Testcontainers and random
credentials, so it is **not** evidence about this Compose service — keep it, but give the Compose lane
a separate name and separate artifacts. Local or static credentials are never passed to `prod-smoke`.
`minio-init` succeeding is never accepted in place of the round trip. Whether object storage joins the
`app-bootstrap` runtime is a **separate decision** from the five master switches; if it does not, this
smoke client is a release fixture, not a production bean.
The client image is pinned **by digest**.
- [ ] **Steps 16:** build both scripts, wire the services, run the `shared-infra-local` lane, verify
the artifact, confirm no secret leaked, commit.
---
## Task 6: The notification smoke client and the stateful handoff lane
**Files:**
- Create: `infra/notification/smoke/notification-smoke.sh`
- Modify: `docker-compose.infra.yml`
**Context:** Three lanes use this client. `local-notification-ingest` proves durable accept with zero
provider beans and zero workers. `local-notification-serving` proves a real Mailpit delivery.
`local-notification-handoff` is a **composite stateful lane**, not two lanes concatenated. The same
project, the same PostgreSQL service, and the same named volume persist across four phases:
1. Start DB and app with `INGEST_ONLY` phase env. Store an accept request using credential-free
Mailpit route metadata. Record the request ID and route version in evidence. Confirm via the
activation report that provider beans/calls and worker threads are all zero.
2. Stop **only the app**, cleanly. Do not bring down the DB or the volume.
3. In the same project, recreate the app with `SERVING` phase env and reference-provider settings
(`--force-recreate`), and bring Mailpit to ready.
4. Re-run the smoke with the phase-1 request ID and frozen route version. Assert exactly one Mailpit
message, a terminal DB state, and the same route version. Wait at least one more dispatch poll
window and assert duplicates are still zero.
Only after both phases and the intermediate app exit succeed does the lane proceed to shared evidence
collection and teardown. Deleting the volume after phase 1, or copying rows into a second project, is
not handoff evidence.
- [ ] **Steps 17:** build the client, encode the phases in the runtime-smoke wrapper (Task 7), run
the lane, verify the evidence, commit.
---
## Task 7: The runtime-smoke wrapper
**Files:**
- Create: `scripts/run-compose-runtime-smoke.sh`
**Interfaces:**
- Produces: `--matrix <contract.json>` (all blocking lanes, zero-discovery and zero-skip) and
`--lane <id>` (focused reproduction only — never a substitute for a matrix run). Wave 6 runs the
matrix form.
**Context:** The wrapper enforces this order internally so no human and no CI job can skip a step:
1. Create a per-lane, per-run `COMPOSE_PROJECT_NAME` and a mode-`0700` temp directory; write env and
secret files at mode `0600`. **Fail** if the evidence directory already exists — never reuse one.
2. Run `verify-compose-profile-contracts.sh`, then `config`, then `create`.
3. `up --wait` the long-running services only; check app health/readiness and the resolved activation
report from Wave 1's `adapteractivation` endpoint.
4. `run --rm` each one-shot the lane declares (`auth-smoke`, `object-storage-smoke`,
`notification-smoke`); for JPA lanes, assert the app's migration/schema/TLS report. A required
one-shot that is missing, skipped, or non-zero fails the lane.
5. Write to `src/app-bootstrap/build/evidence/runtime-smoke/<lane>/<run-id>/`: `manifest.json`, the
Compose and service-set digest, activation/health, DB migration/TLS, sanitized Keycloak claims,
the MinIO round trip, and a warning/error summary. Never a raw token, URI credential, secret value,
or rendered secret.
6. On **both** success and failure: collect sanitized logs and container exits **first**, then in a
`trap` run `down --volumes --remove-orphans` against this project only, and delete the temp env and
secret files. Never touch another project or an outside named volume.
The `local-notification-handoff` phase sequence from Task 6 lives here.
- [ ] **Steps 18:** write it, run `--lane off-local` first, then `--lane local-jpa`, then the full
`--matrix`, verifying evidence and teardown each time. Confirm with
`docker ps -a` and `docker volume ls` that nothing outside the lane's project was touched.
---
## Task 8: Wire CI to the scripts
**Files:**
- Modify: `.github/workflows/ci-quality-gates.yml`
**Context:** CI calls the two scripts and nothing else for Compose work. Verified at HEAD, the quality
job runs `./gradlew check verifyPublicPathSnapshot verifyDependencyLocks` and
`./gradlew conditionalTransportQualification`, with no Compose verification at all. Inlining wrapper
fragments would let a workflow silently run a lane without its one-shots, and past evidence must never
be aggregated as a current pass.
- [ ] **Steps 14:** add the two script invocations, run the workflow (or `act`/a branch push), confirm
both execute and fail loudly on a deliberately broken lane, commit.
---
## Wave 3 Exit Criteria
- [ ] `./scripts/verify-compose-profile-contracts.sh` — all 15 lanes pass.
- [ ] `./scripts/run-compose-runtime-smoke.sh --matrix src/config/runtime/compose-profile-contracts.json`
— every blocking lane passes with zero discovery failures and zero skips, including
`prod-smoke` actually starting TLS DB + app + Keycloak + MinIO and running both one-shots.
- [ ] `git ls-files src/.env` returns nothing; `src/.env.example` and `src/.env.local.example` are
tracked.
- [ ] A profileless bootJar start fails; `local,prod` fails; `stage` fails; each of `local`, `dev`,
`prod` succeeds.
- [ ] `cd src && ./gradlew verifyEnvKeys --console=plain --no-daemon` — green with the new input
contract, and green with `src/.env` absent.
- [ ] `./gradlew wave0RedReport` — only the Wave 4 warning entry remains.
- [ ] No secret value appears anywhere under
`src/app-bootstrap/build/evidence/` (`grep -r` the generated values finds nothing).
## What Wave 3 explicitly does not do
- No warning removal (Wave 4) and no build-logic extraction (Wave 5).
- No promotion of object storage into the `app-bootstrap` runtime — that decision is separate from the
five master switches and is not made here.
- No reuse of local MinIO or Keycloak credentials in `prod-smoke`.
- No acceptance of `minio-init` success as round-trip evidence, of Keycloak readiness as realm
evidence, or of a successful startup as issuer evidence.
@@ -0,0 +1,254 @@
# Wave 4 — Runtime Warning and IDE Error Zero Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
> (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
> **Read [`2026-08-15-five-adapter-runtime-remediation-index.md`](2026-08-15-five-adapter-runtime-remediation-index.md)
> first.**
> **Entry criterion:** Wave 3 complete — the Compose lane matrix passes and each environment starts
> from its own env source.
**Goal:** Zero WARN and zero ERROR in `local`, `dev`, and `prod` startup logs with an **empty**
allowlist, plus a structured-log profile field that agrees with the real active profile, plus IDE
suppressions narrowed to the exact false positives they were written for.
**Architecture:** Every warning is fixed at its cause, never suppressed. The three runtime warning
families each have a different root cause and therefore a different fix: the Micrometer warnings are an
ordering problem (a filter installed after meters exist), the `BeanPostProcessorChecker` warnings are a
dependency-graph problem (a bean resolved too early), and the Flyway one is diagnosed before it is
fixed because "framework bug" and "application eager dependency" call for opposite responses. The
warning gate itself is the Wave 0 recorder, promoted from characterization to a blocking check.
**Tech Stack:** Micrometer `MeterRegistryCustomizer`/`MeterFilter`, Spring `ObjectProvider`, Logback
`springProfile`, Eclipse JDT preferences, Spring Tools LS settings.
**Spec:** [`2026-08-15-five-adapter-runtime-remediation-review-design.md`](../specs/2026-08-15-five-adapter-runtime-remediation-review-design.md)
(§9 in full, §11 Wave 4)
---
## Global Constraints
Inherited from the index. Wave 4 adds:
- **Fix the cause, never the symptom.** Lowering a log level, adding a logger exclusion, or marking a
bean `ROLE_INFRASTRUCTURE` to quiet a checker are all forbidden. `ROLE_INFRASTRUCTURE` is called out
by name in spec §9.2 because it looks like a fix and is a mute button.
- **The allowlist is empty by default and empty at the end.** A third-party warning that genuinely
cannot be removed during implementation may be quarantined in a registry entry carrying an owner, an
upstream issue link, and an expiry date — but the final warning-zero judgement requires **zero
allowlist entries** unless the user separately approves an exception.
- **A Gradle gate does not speak for the IDE.** IDE Problems zero is confirmed by a human, against a
named JDK, extension set, and settings file. Do not claim a Gradle task verified it.
- **Hikari leak-detection messages are not memory leaks.** They are a distinct diagnostic; connection
leaks and ThreadLocal/executor lifecycle get their own tests rather than being folded into this
wave's warning count.
---
## Baseline
Reproduced during the review and pinned by Wave 0 Task 6:
| Warning | Source | Task |
| --- | --- | --- |
| `BeanPostProcessorChecker` early instantiation of `RolePermissionPolicy`, `RolePermissionRegistry`, `AuthorizationAdapter` | authorization E2E bean-creation chain | 2 |
| ×2 "meter registered before MeterFilter added" | `MetricsContractConfig.java:17-50` installs filters in `@PostConstruct` | 1 |
| `BeanPostProcessorChecker` on a Flyway converter (dev only) | Boot/Flyway configuration ordering | 3 |
| structured-log `profile` field disagrees with the real active profile | `logback-spring.xml:8-9` reads `SPRING_PROFILES_ACTIVE` with `defaultValue="local"` | 4 |
Also confirmed green and to be kept green: `./gradlew help --warning-mode all` and
`./gradlew compileJava compileTestJava --warning-mode all` both succeed with no deprecation or
`-Werror` output. Java compilation already runs `-Werror -Xlint:deprecation -Xlint:unchecked`
(`src/build.gradle:353`).
---
## Task 1: Install meter filters before the registry has meters
**Files:**
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/metrics/MetricsContractConfig.java`
- Modify: `src/sample-portfolio/src/main/java/dev/caskeleton/sample/portfolio/bootstrap/metrics/SampleMetricsContractConfig.java`
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/metrics/MeterFilterOrderingTest.java`
**Context:** `MetricsContractConfig` fetches the registry in `@PostConstruct` and installs filters
then — by which point meters registered during earlier bean construction already exist, and Micrometer
warns that the filter cannot apply to them. The filter is not merely noisy; it is **partially
ineffective**, which is the real defect. A naming or tag policy that skips the meters registered before
it produces inconsistent metric names in production.
The same late-filter assembly is duplicated in `SampleMetricsContractConfig.java:18-41`. Fixing only
the app-bootstrap copy leaves the warning reproducible from the sample composition root, so both change
together.
The fix is `MeterRegistryCustomizer<MeterRegistry>` beans, which Boot applies at registry creation, with
explicit `@Order` where filters must compose in a defined sequence.
- [ ] **Step 1:** Write `MeterFilterOrderingTest` — a context asserting (a) zero Micrometer warnings
via `StartupWarningRecorder`, and (b) that a meter registered by the earliest-constructed bean
still carries the filter's effect, which is the assertion that proves the fix rather than the
silence.
- [ ] **Step 2:** Run to verify it fails.
- [ ] **Step 3:** Convert both configs to `MeterRegistryCustomizer`.
- [ ] **Step 4:** Run to verify it passes.
- [ ] **Step 5:** Run `./gradlew :app-bootstrap:test :sample-portfolio:test --console=plain --no-daemon`.
- [ ] **Step 6:** Commit.
---
## Task 2: Remove the authorization early-instantiation chain
**Files:**
- Create: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/security/AuthorizationBeanGraphTest.java`
- Modify: whichever configuration the reproduction identifies
**Context:** `RolePermissionPolicy`, `RolePermissionRegistry`, and `AuthorizationAdapter` are
instantiated before all `BeanPostProcessor`s are ready, so they are not eligible for post-processing —
meaning AOP, `@Transactional`, and metrics decoration may silently not apply to them. That is the harm;
the log line is only how it is visible.
**Diagnose before fixing.** Build a minimal context that reproduces the chain and identify which
consumer resolves `AuthorizationPort` eagerly — spec §9.2 points at an infrastructure advisor and a
Spring Data projection post-processor as the likely candidates, but *likely* is not a diagnosis. Only
then choose between deferring the lookup through `ObjectProvider`/`Supplier` and excluding an
unnecessary slice auto-configuration.
Forbidden: marking the beans `ROLE_INFRASTRUCTURE`. It silences the checker and leaves the beans
un-post-processed, which is the actual problem.
- [ ] **Steps 17:** minimal reproduction → named diagnosis recorded in the evidence log → fix →
assert zero `BeanPostProcessorChecker` records for these three types → full suite → commit.
---
## Task 3: Diagnose and fix the Flyway converter warning
**Files:**
- Create: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/migration/FlywayConfigurationOrderingTest.java`
- Modify: as the diagnosis dictates
**Context:** Appears on `dev` only. Pin the Boot/Flyway configuration creation order in a minimal
reproduction, then decide: a framework bug is reported upstream and quarantined with an owner and
expiry; an application eager dependency is fixed here. The two answers are opposite, so guessing costs
more than reproducing.
Note that Wave 1 Task 9 moved migration under the JPA capability root, so this warning now appears only
in JPA-on contexts — reproduce it there.
- [ ] **Steps 16:** reproduce → diagnose → fix or quarantine with owner/issue/expiry → assert → commit.
---
## Task 4: Make the log profile field agree with the active profile
**Files:**
- Modify: `src/app-bootstrap/src/main/resources/logback-spring.xml:8-9`
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/logging/LogProfileAgreementTest.java`
**Context:** Verified at HEAD: logback reads `SPRING_PROFILES_ACTIVE` with `defaultValue="local"`,
independently of Spring's resolved profile. Overriding the profile on the CLI to `prod` while a stale
`local` value sits in the environment produces production logs stamped `local` — the field that exists
precisely so somebody can tell which environment a log line came from, lying about it.
Wave 3 Task 1 removed the profile default from `application.yml`, so the remaining fallback here is the
last one. Replace the `springProperty` read with `spring.profiles.active` resolved by Spring, and remove
the `defaultValue` entirely — with Wave 3's validator, an absent profile can no longer reach a running
application, so a default would only mask a contradiction.
- [ ] **Steps 16:** TDD cycle; the test asserts the emitted `profile` field equals
`Environment#getActiveProfiles()[0]` for each of `local`, `dev`, `prod`.
---
## Task 5: Narrow the IDE suppressions
**Files:**
- Modify: `.vscode/settings.json`
- Modify: `.vscode/jdt-compiler.prefs`
**Context:** Two blanket suppressions, both currently global:
1. `spring-boot.ls.problem.boot2.MISSING_CONFIGURATION_ANNOTATION: "IGNORE"` — its own comment names
the cause: two stereotype-free legacy shims in `adapter:outbound:httpclient`
(`OutboundHttpClientConfig`, `OutboundHttpResilienceConfig`) that cannot take `@Configuration`
because both composition roots component-scan `dev.caskeleton.adapter`. **Wave 1 Task 3 narrowed
those scans**, which removes the reason: convert both shims to structural imports under their
capability root, then restore the setting to `WARNING`.
2. `.vscode/jdt-compiler.prefs:21-24` ignores three JDT warning categories. Build-versus-JDT
divergence is real and these are documented, so keep them — but confirm each is still needed by
flipping it back and observing the diagnostics, and record what each currently suppresses.
Note `.vscode/` is listed in `.gitignore`, so these files are local. Record the reviewed settings in
`docs/ide/vscode-baseline.md` so the human's IDE-zero confirmation is reproducible against a named
configuration rather than against whatever their editor happens to hold.
- [ ] **Steps 16:** convert the two shims to structural imports → restore the Spring LS setting to
`WARNING` → confirm zero new diagnostics → review the three JDT entries and document them →
commit.
---
## Task 6: Promote the warning recorder to a blocking gate
**Files:**
- Modify: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/StartupWarningZeroTest.java`
- Modify: `scripts/run-compose-runtime-smoke.sh`
- Create: `src/config/runtime/warning-allowlist.json`
**Context:** Extend `StartupWarningZeroTest` from the one all-off `local` case to all three
environments and to each one-on activation combination.
**Wave 0 recorded this test as green, and that is a measurement gap, not good news.** It boots
`WebApplicationType.NONE` with every adapter off, while the warnings in the baseline table were
observed under `bootRun` — a **web** application with JPA active. The extension must therefore use
`WebApplicationType.SERVLET` and JPA-on combinations, or the gate will keep passing while every
warning it exists to catch is still emitted. See
`docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md`, "Deviations", item 1. The allowlist file ships **empty**, with a
schema requiring `owner`, `upstreamIssue`, and `expiry` on any entry, and a check that fails an entry
whose expiry has passed — so a temporary quarantine cannot become permanent by being forgotten.
Wave 3's runtime-smoke wrapper already writes a warning/error summary per lane. Make a non-empty
summary fail the lane, so the gate covers real container startups and not only in-JVM tests.
- [ ] **Steps 17:** extend the test → add the allowlist schema and expiry check → make the wrapper
fail on non-empty → run the full matrix → confirm zero → commit.
---
## Task 7: Separate the resource-leak question from the warning question
**Files:**
- Create: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/resource/ConnectionLeakTest.java`
- Create: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/resource/ThreadLifecycleTest.java`
**Context:** Spec §9.2 item 5 is explicit that a Hikari leak-detection message is not a memory leak and
must not be treated as one. These two tests answer the question the message raises, on their own terms:
a connection acquired and not returned is detected as a leak; every executor, scheduler, and ThreadLocal
created by an adapter is released when its context closes.
The thread half reuses `AdapterActivationInventory.liveThreadNamesMatching` (Wave 0 Task 2) — start a
context with one adapter on, close it, and assert the adapter's threads are gone.
- [ ] **Steps 16:** TDD cycle for both.
---
## Wave 4 Exit Criteria
- [ ] `cd src && ./gradlew clean compileJava compileTestJava --warning-mode=fail --no-daemon --console=plain` — green.
- [ ] `cd src && ./gradlew test --warning-mode=fail --no-daemon --console=plain` — green.
- [ ] `StartupWarningZeroTest` green for `local`, `dev`, `prod`, all-off and each one-on combination.
- [ ] `src/config/runtime/warning-allowlist.json` contains **zero** entries.
- [ ] `./scripts/run-compose-runtime-smoke.sh --matrix src/config/runtime/compose-profile-contracts.json`
— every lane's warning/error summary is empty.
- [ ] The structured-log `profile` field equals `Environment#getActiveProfiles()[0]` in every smoke.
- [ ] `./gradlew wave0RedReport` — empty.
- [ ] A human has confirmed IDE Problems zero against the configuration recorded in
`docs/ide/vscode-baseline.md`, and that confirmation is recorded with the JDK and extension
versions used. **This is a human step; no Gradle task may claim it.**
## What Wave 4 explicitly does not do
- No log-level lowering, logger exclusion, or `ROLE_INFRASTRUCTURE` marking to reach silence.
- No allowlist entry without owner, upstream issue, and expiry — and none surviving to the exit check.
- No claim that a Gradle gate verified the IDE.
- No build-logic extraction (Wave 5).
@@ -0,0 +1,239 @@
# Wave 5 — Gradle Build Logic Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
> (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
> **Read [`2026-08-15-five-adapter-runtime-remediation-index.md`](2026-08-15-five-adapter-runtime-remediation-index.md)
> first.**
> **Entry criterion:** Wave 4 complete — a fully green, warning-zero baseline whose task graph,
> dependency graph, test selection, and evidence output have been captured as a comparison artifact.
**Goal:** Extract the duplicated source-set, test-lane, testkit, API-surface, evidence, registry, and
dependency machinery into eight TestKit-tested convention plugins in a `build-logic` included build,
so that root, settings, and leaf build files each hold only their own responsibility — with the task
graph, dependency graph, test selection, and evidence semantics provably unchanged.
**Architecture:** A `build-logic` included build holds precompiled convention plugins. Settings and
root stop re-implementing registry parsing against each other by sharing one typed parser/validator.
Every extraction is one step, and every step is verified by diffing the captured baseline artifacts —
because the failure mode this wave uniquely risks is a task quietly ceasing to exist and the build
reporting green for work it no longer does.
**Tech Stack:** Gradle 9 included builds, precompiled script plugins (`build-logic/src/main/groovy`),
Gradle TestKit.
**Spec:** [`2026-08-15-five-adapter-runtime-remediation-review-design.md`](../specs/2026-08-15-five-adapter-runtime-remediation-review-design.md)
(§10 in full, §11 Wave 5, §14 closing paragraph)
---
## Global Constraints
Inherited from the index. Wave 5 adds:
- **Never share a diff with a runtime change.** Spec §14 is explicit: build refactoring on top of a
red or unverified baseline produces false evidence, because a task that stops existing looks the
same as a task that passes.
- **LOC is not the goal.** Do not hide logic to move a number. Completion is judged by whether the
same source-set/`Test`/API-surface machinery is still copied into two or more leaves, and whether
root, settings, and leaf hold only their stated responsibilities.
- **Each extraction step is verified by artifact diff, not by "the build still works".**
- **Provider semantics stay per-module.** Image, scenario, security requirement, and promotion meaning
differ per provider and stay in each module's registry. Do not merge them into one over-general DSL —
spec §8.2 names that as a pattern to avoid.
- **Do not remove a dependency a provider actually uses.** The exclusion convention exists to make
declared exclusions real, not to strip dependencies centrally.
---
## Measured baseline
| File | Lines |
| --- | ---: |
| `src/build.gradle` | 2,767 |
| `src/settings.gradle` | 185 |
| `src/app-bootstrap/build.gradle` | 273 |
| `src/adapter/outbound/persistence-jpa/build.gradle` | 352 |
| `src/adapter/outbound/persistence-mongo/build.gradle` | 346 |
| `src/adapter/outbound/messaging/build.gradle` | 105 |
| `src/adapter/outbound/notification/build.gradle` | 39 |
| `src/adapter/inbound/graphql/build.gradle` | 241 |
| `src/gradle/jpa-evidence.gradle` | 930 |
The duplication that matters, not the size:
- custom source set + `extendsFrom` + `Test` task + `failOnNoDiscoveredTests`, repeated per lane;
- strict qualification lane registration and result-directory wiring, repeated;
- testkit artifact/source-set wiring, repeated;
- API-surface snapshot render/update/verify machinery, repeated;
- registry parsing and validation implemented **twice**, once in settings and once in root
verification;
- release-evidence manifest and output handling, repeated;
- dependency exclusion intent that drifts from the resolved graph — e.g. the notification build claims
a YAML exclusion (`adapter/outbound/notification/build.gradle:21-33`) while SnakeYAML remains in its
lockfile (`gradle.lockfile:165`).
And one policy violation: `settings.gradle:37-44` hardcodes `expectedModuleCount = 44`, with a comment
arguing the count should be a deliberate decision — but the project policy (`AGENTS.md:52-55`) makes the
registry the count's SSOT and `verifyDocumentedLeafCount` enforces it against documents. A number in
the build file is the same drift the policy forbids in prose. Meanwhile `verifyDocumentedLeafCount`
inspects only some root documents, so a stale count in a module `CLAUDE.md` — for example
`src/app-bootstrap/CLAUDE.md`'s "19-leaf dependency list" — is not caught.
---
## Task 0: Capture the comparison baseline
**Files:**
- Create: `scripts/capture-build-baseline.sh`
- Create: `docs/superpowers/plans/evidence/2026-08-15-wave5-baseline/`
**Context:** This is the instrument the whole wave is judged by. Capture, from the green Wave 4 state:
1. `./gradlew tasks --all` — the full task list;
2. per-module `./gradlew <path>:dependencies --configuration runtimeClasspath`;
3. every lane's JUnit XML **class list** (not timings);
4. every evidence directory's file list and manifest schema;
5. `./gradlew <every architecture-wide verify task>` output.
Normalize timestamps, durations, and absolute paths, so a diff shows semantic change only.
- [ ] **Steps 14:** write the script, run it, commit the baseline, and prove the script is
deterministic by running it twice and diffing (must be identical).
---
## Task 1: `ca.architecture-registry` — one parser for settings and root
**Files:**
- Create: `build-logic/settings.gradle`, `build-logic/build.gradle`
- Create: `build-logic/src/main/groovy/ca.architecture-registry.gradle`
- Create: `build-logic/src/main/java/dev/caskeleton/buildlogic/registry/ModuleRegistry.java`
- Create: `build-logic/src/test/java/dev/caskeleton/buildlogic/registry/ModuleRegistryTest.java`
- Modify: `src/settings.gradle`, `src/build.gradle`
**Context:** First extraction because everything else reads the registry. One typed parser/validator,
fail-closed on: a project directory with a `build.gradle` that the registry does not list; a
`source_path` that does not exist; a duplicate ID or path; and drift between the resolved runtime
project closure and declared memberships (Wave 1 Task 13 already made the closure the comparison input).
Remove `expectedModuleCount`. The registry is the count.
Extend `verifyDocumentedLeafCount` to cover tracked root `AGENTS.md`, root `CLAUDE.md`, and **all**
`src/**/CLAUDE.md`, and wire it into `check` and CI. Prefer removing the duplicated number from each
document over asserting it — a count that is not written cannot drift. Where a document genuinely needs
the number, it must be generated.
- [ ] **Steps 18:** TestKit tests first (malformed registry, missing path, duplicate ID, membership
drift), then the plugin, then delete both re-implementations, then diff against the baseline.
## Task 2: `ca.strict-test-lane`
**Files:** `build-logic/src/main/groovy/ca.strict-test-lane.gradle` + TestKit tests; then apply to the
JPA, Mongo, GraphQL, messaging, and app-bootstrap leaves one at a time.
**Context:** The single largest duplication. The convention owns source set creation, configuration
`extendsFrom`, the `Test` task, `failOnNoDiscoveredTests`, stale-XML deletion, required-class
enforcement, and the results directory — the shape
`src/gradle/graphql-platform-conventions.gradle:55-100` implements by hand today.
Semantics that must survive verbatim, because each was written against a real failure: stale JUnit XML
is deleted before the lane runs (a deleted class would otherwise report as executed), and a lane that
executes no test case for a required class fails with a message saying the lane is green only because
the class is gone.
TestKit cases: empty lane fails; duplicate task registration fails; a required class with no executed
test case fails; stale XML is removed.
- [ ] **Steps 19:** one leaf per step, diffing lane task names and JUnit XML class lists against the
baseline after each.
## Task 3: `ca.api-surface`
Read-only `verify` plus an explicitly-named approved `update` task. The two must not be the same task
with a flag — an update that runs by default silently blesses a surface change.
- [ ] **Steps 16:** TestKit tests, extraction, per-leaf application, diff.
## Task 4: `ca.testkit-publisher`
Testkit source set and consumable artifact, currently repeated. `app-bootstrap` consumes
`project(path: ':adapter:outbound:persistence-jpa', configuration: 'jpaTestkit')`; the convention must
keep that consumer contract byte-identical.
- [ ] **Steps 16.**
## Task 5: `ca.evidence`
Manifest and result schema, deterministic output ordering, and a no-empty-evidence rule. Applies to
`src/gradle/jpa-evidence.gradle` (930 lines) and the Mongo/GraphQL equivalents.
Provider-specific promotion meaning stays in each module registry.
- [ ] **Steps 17.**
## Task 6: `ca.dependency-policy`
Common exclusions and constraints, plus verification that each configuration's **resolved graph and
lockfile** match the declared intent. The notification/SnakeYAML case is the acceptance test: a build
that declares an exclusion while the lockfile still carries the dependency must fail.
Guard rail: a dependency a provider genuinely uses directly is never centrally removed.
- [ ] **Steps 17.**
## Task 7: `ca.java-leaf`
Java 21 toolchain, encoding, compiler flags (`-Werror -Xlint:deprecation -Xlint:unchecked`, currently
`src/build.gradle:353`), Error Prone, and the baseline test task.
- [ ] **Steps 16.**
## Task 8: `ca.optional-adapter`
Activation metadata plus disabled/on composition-contract wiring for the five adapters. This is the
convention that makes Wave 1's off-invariant testing a build-level default rather than something each
leaf remembers.
- [ ] **Steps 16.**
---
## Task 9: Reduce the three build files to their responsibilities
**Files:** `src/settings.gradle`, `src/build.gradle`, each leaf `build.gradle`
Target responsibilities:
- `settings.gradle`: plugin management, root project name, and applying the registry settings plugin.
Nothing else.
- root `build.gradle`: shared plugin and version declarations plus architecture-wide lifecycle tasks.
- leaf `build.gradle`: plugins, project and external dependencies, and that leaf's own semantic
lane/matrix.
- [ ] **Steps 15:** reduce, run the full verification set, diff against the baseline, commit.
---
## Wave 5 Exit Criteria
- [ ] `./scripts/capture-build-baseline.sh` output **diffs clean** against the Wave 4 baseline for the
task list, dependency graphs, JUnit XML class lists, and evidence manifests. A task that
disappeared is a failure even if everything green stayed green.
- [ ] `cd src && ./gradlew clean check --warning-mode=fail --no-daemon --console=plain` — green.
- [ ] `./gradlew verifyCleanArchitectureDependencies verifyEnvKeys verifyRuntimeModuleMembership
verifyPublicPathSnapshot verifyDocumentedLeafCount --console=plain` — green.
- [ ] `build-logic`'s own TestKit suite is green and covers malformed registry, empty lane, duplicate
task, and membership drift.
- [ ] `expectedModuleCount` is gone from `src/settings.gradle`.
- [ ] `verifyDocumentedLeafCount` covers root `AGENTS.md`, root `CLAUDE.md`, and every
`src/**/CLAUDE.md`, and is wired into `check` and CI.
- [ ] No source-set/`Test`/API-surface machinery is copied into two or more leaves.
- [ ] `./scripts/run-compose-runtime-smoke.sh --matrix ...` — still green, proving the refactor did not
change what actually runs.
## What Wave 5 explicitly does not do
- No runtime, configuration, or test-behaviour change in the same diff.
- No LOC-driven relocation that hides logic.
- No merging of provider-specific release matrices into one generic DSL.
- No central removal of a dependency a provider uses directly.
@@ -0,0 +1,237 @@
# Wave 6 — Final Qualification and Documentation Sync Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (this wave is
> verification-heavy and benefits from one session holding the whole evidence set).
> **Read [`2026-08-15-five-adapter-runtime-remediation-index.md`](2026-08-15-five-adapter-runtime-remediation-index.md)
> first.**
> **Entry criterion:** Wave 5 complete — build logic extracted with a clean baseline diff.
**Goal:** Run every blocking gate, the full activation matrix, and every environment smoke; reconcile
generated metadata and documentation with the code; and produce the evidence set that lets each
Definition-of-Done checkbox in spec §13 be ticked with a command and its output attached.
**Architecture:** Wave 6 adds no capability. It executes, records, and reconciles. Every claim is
backed by a command and its output stored under
`docs/superpowers/plans/evidence/2026-08-15-wave6-final/`. A checkbox without attached evidence stays
unticked, and a gate that could not run is reported as not-run with its reason — never as passing.
**Spec:** [`2026-08-15-five-adapter-runtime-remediation-review-design.md`](../specs/2026-08-15-five-adapter-runtime-remediation-review-design.md)
(§12 in full, §13, §11 Wave 6)
---
## Global Constraints
Inherited from the index. Wave 6 adds:
- **Use `superpowers:verification-before-completion` before any completion claim.** Evidence precedes
assertion, always.
- **A not-run gate is reported as not-run.** Spec HARD-STOP 6 (`AGENTS.md:22`) makes claiming
completion without the verification, or without naming why it could not run, a stop condition.
- **No conclusion broader than its evidence** (HARD-STOP 7). "The matrix passed" requires the matrix,
not a representative lane.
- **The wave ends with the LLM Wiki capture** required by `AGENTS.md:79-90`, or with an explicit
reported reason it was blocked.
---
## Task 1: Build and architecture gates
- [ ] Run and capture each:
```bash
cd src
./gradlew clean compileJava compileTestJava --warning-mode=fail --no-daemon --console=plain
./gradlew test --warning-mode=fail --no-daemon --console=plain
./gradlew check --warning-mode=fail --no-daemon --console=plain
./gradlew verifyCleanArchitectureDependencies verifyEnvKeys \
verifyRuntimeModuleMembership verifyPublicPathSnapshot \
verifyDocumentedLeafCount --console=plain
```
- [ ] Confirm `./gradlew wave0RedReport --console=plain --no-daemon` reports an **empty** red set,
then delete the `wave0Red` lanes and the aggregate — the characterizations they tracked are now
ordinary tests, and a permanent lane for an empty set is a lane that stops being read.
## Task 2: Focused module gates
Gradle paths are read from `src/config/architecture/modules.json`, never from memory.
- [ ] Run and capture:
```bash
cd src
./gradlew :adapter:outbound:persistence-jpa:test --console=plain
./gradlew :adapter:outbound:persistence-mongo:test --console=plain
./gradlew :adapter:outbound:messaging:test --console=plain
./gradlew :adapter:outbound:notification:test --console=plain
./gradlew :adapter:inbound:graphql:test --console=plain
./gradlew :adapter:inbound:graphql:graphqlStableTest \
:app-bootstrap:graphqlRuntimeQualification \
conditionalTransportQualification --console=plain
```
- [ ] Run the messaging platform's Stable facade focused tests and its live-broker lane separately.
Record explicitly that ordinary `test` does **not** substitute for the Docker-backed
qualification.
- [ ] Persistence blocking lanes:
```bash
cd src
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=16 --console=plain
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=17 --console=plain
./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=18 --console=plain
./gradlew :adapter:outbound:persistence-mongo:mongoStableContractTest \
:adapter:outbound:persistence-mongo:mongoReplicaSetTest \
:adapter:outbound:persistence-mongo:mongoFailoverTest \
:adapter:outbound:persistence-mongo:mongoMigrationTest \
:adapter:outbound:persistence-mongo:mongoCompatibilityTest \
:adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest \
:adapter:outbound:persistence-mongo:mongoPerformanceTest --console=plain
```
- [ ] Confirm Wave 2 Task B5's decision is reflected: either the three lanes exist and run, or their
Stable blocking claim is gone from `src/config/mongodb/release-contracts.json`,
`docs/mongodb/advanced/sharding.md`, and `scripts/verify-mongodb-advanced.sh`. Attach the
`ReleaseManifestTaskExistenceTest` result.
## Task 3: The activation matrix
Run each row and capture the resolved activation report, health, exit code, WARN/ERROR count, and the
bean/thread inventory.
| # | Matrix | Expected |
| --- | --- | --- |
| 1 | five off | boots with no external infrastructure; health OK; adapter beans, resources, threads, endpoints all zero |
| 2 | JPA only | PostgreSQL + Flyway + Hibernate `validate` succeed; no Mongo, GraphQL, broker, or provider |
| 3 | Mongo only | replica set, client, topology, security succeed; no JPA entity, repository, or pool |
| 4 | Messaging only | broker publish/consume succeeds; with relay off, no database needed |
| 5 | Notification + JPA, `INGEST_ONLY` | durable accept on a frozen non-empty versioned route; provider credentials, calls, and workers all zero; after a `SERVING` restart, exactly one delivery on the same route |
| 6 | Notification + JPA, `SERVING` | reference provider delivery and receipt succeed |
| 7 | GraphQL only | schema endpoint plus security/policy pipeline succeed; no persistence resolver |
| 8 | relay on, dependency missing | startup rejects, **naming the exact missing switch or provider** |
| 9 | JPA + Mongo | distinct ports succeed; two implementations of one port is a startup rejection, not a `@Primary` pick |
| 10 | five on | every dependency and endpoint ready; no silent fallback and no duplicate authority |
- [ ] Row 8 must be checked for the *name*, not merely for a failure. A generic "misconfiguration" is a
fail.
- [ ] Row 9's conflict case must fail; a bean-ordering or `@Primary` resolution is a fail.
## Task 4: Environment and runtime gates
- [ ] Compose artifacts, statically then dynamically:
```bash
./scripts/verify-compose-profile-contracts.sh
./scripts/run-compose-runtime-smoke.sh --matrix src/config/runtime/compose-profile-contracts.json
```
- [ ] Confirm the matrix owned every lane: `off-local`, `off-dev`, `off-prod`, each local adapter lane,
`shared-infra-local`, `shared-infra-dev`, `prod-smoke`, `all-adapters`.
- [ ] Confirm `prod-smoke` actually **started** TLS PostgreSQL, the app, Keycloak, and MinIO and ran
both one-shots — a `config`-only pass is a fail.
- [ ] Confirm each run stored exit code, active profile, resolved activation report, and WARN/ERROR
count as artifacts.
- [ ] Confirm no dev or prod lane was made to pass with a local override. Spec §12.4 does not accept
that as evidence for the profile.
- [ ] Confirm teardown left no stray project or volume: `docker ps -a`, `docker volume ls`.
## Task 5: Documentation and metadata reconciliation
- [ ] Regenerate Spring configuration metadata and diff against the env registry and the YAMLs; any
drift is a fail.
- [ ] Confirm every registry env key appears in `src/.env.example` and that no example carries a real
secret.
- [ ] Confirm `docs/registries/env-keys.yaml` lists the five masters with `false` defaults and the two
subordinate selectors with their `required-when` conditions.
- [ ] Confirm no demoted key (`APP_MESSAGING_BROKER`, `APP_NOTIFICATION_SLACK_PROVIDER`,
`APP_NOTIFICATION_EMAIL_PROVIDER`, `app.jpa-platform.enabled`) is documented anywhere as an
activation switch.
- [ ] Update `README.md` with the five switches, the Compose minimum version, and the two script entry
points.
- [ ] Update `src/app-bootstrap/CLAUDE.md`, replacing its "19-leaf dependency list" phrasing with a
pointer to the registry — a count in prose is the drift `AGENTS.md:52-55` forbids.
- [ ] Confirm the capability docs do not label as Stable anything the index's scope boundaries exclude:
Mongo reactive, change streams, sharding, Atlas, KMS; Notification before NTF-INT-007 is closed.
## Task 6: Definition of Done
Walk spec §13 and tick each box **only** with attached evidence. Reproduced here as the checklist:
- [ ] Five runtime facades on one bootJar runtime classpath.
- [ ] Five canonical master switches in registry, YAML, metadata, and docs, all defaulting `false`.
- [ ] All-off `local`, `dev`, `prod` smoke succeeds with no external resources.
- [ ] Each adapter's off invariant and on fail-closed contract pinned by full-context tests.
- [ ] Mongo, GraphQL, and Messaging Stable facades' resolved runtime membership matches the registry.
- [ ] The JPA switch controls the whole DataSource/Hikari/entity/repository/Hibernate/Flyway/DB
health-and-metrics graph.
- [ ] Messaging has a real broker bridge and a consistent relay dependency.
- [ ] Notification `SERVING` works through production assemblers; `INGEST_ONLY` starts no worker.
- [ ] Notification handoff proves, in one project and DB volume, `INGEST_ONLY` accept → restart →
`SERVING` delivery exactly once on the same frozen route, with zero duplicates.
- [ ] GraphQL policy and JWT context execute on the real `/graphql` request path.
- [ ] GraphQL blocking qualification runs the bootJar JWT composition exactly once and uses neither
class-existence nor test-only Basic Auth as release evidence.
- [ ] `SPRING_PROFILES_ACTIVE` is exactly one of `local|dev|prod`; profileless, multiple, and unknown
deployments are rejected.
- [ ] GraphQL on requires one environment-permitted `APP_GRAPHQL_DEPLOYMENT_MODE`; the legacy
boolean/enum split-brain is rejected.
- [ ] Profiles and env example/secret sources are separated; real secret files are excluded from
tracking, rendering, and evidence.
- [ ] Compose minimum version, per-profile exact service sets, the whole merged model, and mount-target
uniqueness are verified by the canonical script.
- [ ] The runtime-smoke wrapper performs create, `up --wait`, required one-shots, sanitized evidence,
and unique-project teardown with zero skips across `local`, `dev`, and `prod` blocking lanes.
- [ ] PostgreSQL, Mongo, broker, MinIO, and Keycloak/realm smoke evidence exists.
- [ ] The Keycloak realm provides a client-credentials-only service account, audience, and role claims,
and real JWT-protected REST and GraphQL requests succeed against the same issuer URL per lane.
- [ ] Full `test` and `check`, plus architecture, env, public-path, and strict qualification, all pass.
- [ ] Zero Gradle, javac, Checkstyle, SpotBugs, and runtime-startup errors and warnings; zero allowlist
entries; IDE Problems zero confirmed separately on the same toolchain.
- [ ] The real active profile and the structured-log profile field agree; zero late-MeterFilter
warnings.
- [ ] After convention-plugin extraction, task selection, dependency graph, and evidence semantics are
unchanged.
- [ ] Every P0 blocker on a runtime path from the detailed module reviews is either closed or its
capability is explicitly inactive/experimental.
- [ ] Changed files, commands, results, not-run/blocked items, and evidence grades are captured in the
LLM Wiki branch-note.
## Task 7: LLM Wiki capture
Per `AGENTS.md:79-90`:
- [ ] Create or update
`/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/<branch-name>.md` with the
implementation, changed files, decisions, verification commands, failures/blocks, and evidence
grades.
- [ ] Create derived documents where genuine material exists: `raw/errors/`, `raw/interviews/`,
`raw/blog-topics/`. Each links upward via `## Parent`; the branch-note's `## Cluster / 묶음`
links back.
- [ ] Where no derived document is warranted, record that judgement explicitly ("추출할 별도 글감
없음") rather than omitting the section.
- [ ] Do not create `wiki/blog/`, `wiki/interview/`, `wiki/portfolio/`, `wiki/concepts/`, or
`wiki/projects/` without an explicit canonical extraction request.
## Task 8: Final report
Per `AGENTS.md:239-251`, the closing response states: changed files; the core changes; verification
commands run; verifications that failed or could not run, with reasons; the Wiki capture result; and
remaining risks or follow-ups.
- [ ] Explicitly restate what remains **out of scope and not production-ready**, from the index's scope
boundaries: Mongo reactive, change streams, sharding, Atlas, KMS; Notification's at-rest decision
if the threat-model branch was chosen; object storage's runtime inclusion; Fileserver internals.
---
## Wave 6 Exit Criteria
- [ ] Every §13 checkbox above is ticked **with attached evidence**, or is explicitly reported as
not-met with its reason.
- [ ] `docs/superpowers/plans/evidence/2026-08-15-wave6-final/` holds the output of every command in
Tasks 14.
- [ ] The LLM Wiki branch-note exists and links its derived documents.
- [ ] No completion, "all passing", or "production-ready" claim appears anywhere without the command
output that supports it.
@@ -0,0 +1,709 @@
# 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.
File diff suppressed because it is too large Load Diff