feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링
This commit is contained in:
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
File diff suppressed because it is too large
Load Diff
+3943
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4560
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 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.
|
||||
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 1–6:** 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 2–6:** 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 1–6:** 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 1–6:** 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 1–5:** 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 1–5:** 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 1–8:** 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 1–7:** 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 1–5:** 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 1–7:** 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 1–6:** 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 1–6:** 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 1–9:** 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 1–6:** 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 1–7:** 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 1–6:** 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 1–9:** 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 5–8 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 1–4.
|
||||
`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 1–8:** 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 1–6:** 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 1–7:** 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 1–8:** 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 1–4:** 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 1–7:** 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 1–6:** 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 1–6:** 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 1–6:** 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 1–7:** 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 1–6:** 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 1–4:** 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 1–8:** 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 1–9:** 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 1–6:** 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 1–6.**
|
||||
|
||||
## 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 1–7.**
|
||||
|
||||
## 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 1–7.**
|
||||
|
||||
## 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 1–6.**
|
||||
|
||||
## 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 1–6.**
|
||||
|
||||
---
|
||||
|
||||
## 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 1–5:** 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 1–4.
|
||||
- [ ] 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
Reference in New Issue
Block a user