Files
clean-architecture-backend-…/docs/superpowers/plans/2026-08-15-wave2-module-on-path.md
T

613 lines
35 KiB
Markdown
Raw Blame History

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