feat: jpa, messaging, notification, mongo, graphql 어댑터터 리펙토링

This commit is contained in:
DongHyeonka
2026-08-18 10:59:56 +09:00
parent 2f5d2fc219
commit e98b56eb03
372 changed files with 25131 additions and 20357 deletions
@@ -0,0 +1,612 @@
# Wave 2 — Module On-Path Blockers Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
> (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
> **Read [`2026-08-15-five-adapter-runtime-remediation-index.md`](2026-08-15-five-adapter-runtime-remediation-index.md)
> first.**
> **Entry criterion:** Wave 1 complete — all-off boots on `local`, `dev`, and `prod`, and every
> `FiveAdapterOffInventoryTest` case is green.
**Goal:** Close every per-module blocker that stands between "the switch turns it on" and "the thing
it turns on actually works against real infrastructure", for all five adapters.
**Architecture:** Wave 1 decided whether a bean exists; Wave 2 decides what it does. Each adapter
gets one publication/settings/assembly authority to match the one activation authority it now has:
JPA validates the *resolved* `DataSource` rather than a parallel property namespace; Mongo builds
exactly one sync client from the active profile's credential; Messaging replaces the fake sender with
a real transport bridge and gains its Stable facade membership only in the change that proves a live
broker round trip; Notification gains production provider assemblers and a frozen-route ingest
contract; GraphQL collapses its two contradictory safety axes into one deployment mode carried
through the real request path.
**Tech Stack:** Testcontainers (PostgreSQL 16/17/18, MongoDB replica set, Kafka, RabbitMQ, Mailpit),
Spring GraphQL, Spring Security OAuth2 resource server, Flyway, Micrometer.
**Spec:** [`2026-08-15-five-adapter-runtime-remediation-review-design.md`](../specs/2026-08-15-five-adapter-runtime-remediation-review-design.md)
(§6 in full, §11 Wave 2, §12.2, §12.3)
---
## Global Constraints
Inherited from the index. Wave 2 adds:
- **Membership is earned, never granted in advance.** A Stable facade gains `runtime_memberships`
and an `app-bootstrap` dependency **in the same change unit that proves it works against real
infrastructure**. Wiring first and qualifying later ships a known-broken path.
- **No fake in production.** A fixture, in-memory implementation, or test double must not be
reachable from a production runtime path. Where the production implementation is absent, the
capability stays off and its promotion claim is removed from the registry.
- **Each finding is re-reproduced at current HEAD before it is fixed.** The five detailed module
reviews below remain authoritative inputs, but they were written against a different HEAD; do not
copy a failure forward without reproducing it.
- `docs/reviews/2026-08-14-mongodb-module-code-review.md`
- `docs/reviews/2026-08-14-messaging-module-code-review.md`
- `docs/reviews/2026-08-14-notification-module-code-review.md`
- `docs/reviews/2026-08-14-jpa-module-code-review.md`
- `docs/reviews/2026-08-14-graphql-module-code-review.md`
- **A P0 correctness or security finding on a runtime path is a prerequisite, not a follow-up.**
Connecting wiring over a known data-loss path is forbidden (spec §11 Wave 2 closing note).
---
## Section A — JPA
### Task A1: Validate the resolved DataSource, not a parallel namespace (JPA-INT-002)
**Files:**
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceSettings.java`
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaDataSourceProfileValidator.java`
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/PersistenceJpaRootAutoConfiguration.java`
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaResolvedDataSourceValidationTest.java`
**Interfaces:**
- Produces: `JpaDataSourceProfileValidator.validateResolved(DataSource)` — replaces
`validateStable()`. Task A2 and Wave 3's `local-jpa` lane both consume it.
**Context:** Two defects, both verified in the spec against HEAD.
1. `JpaDataSourceSettings` binds `app.jpa-platform.datasource.*` while the pool that actually gets
built comes from `spring.datasource.hikari.*` (`application.yml:25-50`). Two namespaces describing
one pool means the validator can pass while the pool it validated is not the pool in use.
2. `JpaDataSourceProfileValidator` is created as a bean but `validateStable` is never invoked at
startup (`JpaPlatformRuntimeAutoConfiguration.java:93-99,159-165`). A validator nobody calls is a
comment.
The fix removes the duplicate namespace entirely and validates the injected `DataSource`
`HikariDataSource#getMaximumPoolSize`, `getConnectionTimeout`, the resolved JDBC URL, and the product
name and version read from `DatabaseMetaData`. Invocation moves into an `InitializingBean` inside the
JPA root, so it runs exactly when JPA is on and never when it is off.
- [ ] **Step 1:** Write `JpaResolvedDataSourceValidationTest` — a Testcontainers PostgreSQL context
asserting that (a) a pool whose `maximum-pool-size` violates the REQUIRES_NEW lower bound
documented at `application.yml:33-37` fails startup naming `spring.datasource.hikari.maximum-pool-size`;
(b) removing every `app.jpa-platform.datasource.*` property changes nothing, proving the
namespace is dead; (c) an unreachable database fails startup rather than at first query.
- [ ] **Step 2:** Run to verify it fails.
- [ ] **Step 3:** Delete the `app.jpa-platform.datasource` binding from `JpaDataSourceSettings`;
rewrite `JpaDataSourceProfileValidator` to take a `DataSource`; register the invocation in
`PersistenceJpaRootAutoConfiguration`.
- [ ] **Step 4:** Run to verify it passes.
- [ ] **Step 5:** Run `./gradlew :adapter:outbound:persistence-jpa:test :app-bootstrap:test --console=plain --no-daemon`.
- [ ] **Step 6:** Remove the now-dead `app.jpa-platform.datasource.*` rows from
`docs/registries/env-keys.yaml`; run `./gradlew verifyEnvKeys`.
- [ ] **Step 7:** Commit.
### Task A2: Separate local H2 from the default-off contract (JPA-INT-003)
**Files:**
- Modify: `src/app-bootstrap/src/main/resources/application-local.yml`
- Create: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/jpa/LocalJpaVendorParityTest.java`
- Modify: `src/app-bootstrap/build.gradle` (a named non-release H2 developer task)
**Context:** H2 becomes repository/unit-test scope plus one explicitly-named developer convenience
task. The user-facing completion criterion — "local JPA runtime smoke" — uses PostgreSQL, Flyway, and
`ddl-auto=validate`, so local and dev share vendor semantics. Consequences to implement:
- `local` all-off boots with **no database at all** (Wave 1 already delivers this).
- `local` + `APP_PERSISTENCE_JPA_ENABLED=true` requires the Compose PostgreSQL of Wave 3's
`local-jpa` lane.
- Profile absence must never resolve to H2/`create-drop`; Wave 3 Task 1 makes profile absence a
startup error, and this task removes the H2 default that made absence dangerous.
- [ ] **Steps 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.