613 lines
35 KiB
Markdown
613 lines
35 KiB
Markdown
# 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.
|