# 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` 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.