Files
clean-architecture-backend-…/docs/superpowers/plans/2026-08-15-five-adapter-runtime-remediation-index.md
T

282 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.