282 lines
19 KiB
Markdown
282 lines
19 KiB
Markdown
# 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 16–18, 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 4–8 |
|
||
| 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 4–8 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.
|