Files

35 KiB
Raw Permalink Blame History

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 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 (§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 DataSourceHikariDataSource#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:

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).

  • 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.

  • Steps 17: done. GraphQlPlatformEnvironmentGraphQlDeploymentMode; 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:

  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.