35 KiB
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. Read2026-08-15-five-adapter-runtime-remediation-index.mdfirst. Entry criterion: Wave 1 complete — all-off boots onlocal,dev, andprod, and everyFiveAdapterOffInventoryTestcase 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_membershipsand anapp-bootstrapdependency 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.mddocs/reviews/2026-08-14-messaging-module-code-review.mddocs/reviews/2026-08-14-notification-module-code-review.mddocs/reviews/2026-08-14-jpa-module-code-review.mddocs/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)— replacesvalidateStable(). Task A2 and Wave 3'slocal-jpalane both consume it.
Context: Two defects, both verified in the spec against HEAD.
JpaDataSourceSettingsbindsapp.jpa-platform.datasource.*while the pool that actually gets built comes fromspring.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.JpaDataSourceProfileValidatoris created as a bean butvalidateStableis 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 whosemaximum-pool-sizeviolates the REQUIRES_NEW lower bound documented atapplication.yml:33-37fails startup namingspring.datasource.hikari.maximum-pool-size; (b) removing everyapp.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.datasourcebinding fromJpaDataSourceSettings; rewriteJpaDataSourceProfileValidatorto take aDataSource; register the invocation inPersistenceJpaRootAutoConfiguration. - 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 fromdocs/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:
-
localall-off boots with no database at all (Wave 1 already delivers this). -
local+APP_PERSISTENCE_JPA_ENABLED=truerequires the Compose PostgreSQL of Wave 3'slocal-jpalane. -
Profile absence must never resolve to H2/
create-drop; Wave 3 Task 1 makes profile absence a startup error, and this task removes the H2 default that made absence dangerous. -
Steps 1–6: TDD cycle. The parity test asserts that
local+ JPA-on resolves the same vendor, migration mode, and schema policy asdev+ 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 existingSecretLeakStaticScanTest. - Steps 2–6: migrate, run
./gradlew :adapter:outbound:persistence-mongo:test, update the Javadoc, commit.
Task B2: One SSOT from active profile to the real MongoClientSettings (MNG-INT-002, part 2)
Files:
- Create:
src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/client/MongoClientSettingsFactory.java - Modify:
MongoRootAutoConfigurationto register it as aMongoClientSettingsBuilderCustomizer - Test:
.../client/MongoClientSettingsFactoryTest.java
Context: Typed profile, credential resolver, TLS, Stable API, and pool/timeout policy exist but
are not connected to the builder Boot actually uses. One factory consumes the active profile and the
secret reference and produces the real settings; the test asserts on the built
MongoClientSettings, not on the intermediate typed objects.
Cardinality contract, from index §Scope boundaries: exactly one sync client and one pool; zero reactive inventory; secrets resolved only for the active profile — a profile present in the map but not selected must not have its secret read or its client built.
- Steps 1–6: TDD cycle asserting: one client, one pool, zero reactive beans, and that a non-active profile's deliberately-invalid secret reference is never resolved.
Task B3: Startup validation that cannot fail open (MNG-INT-003)
Files:
- Modify:
src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformAutoConfiguration.java - Test:
.../autoconfigure/MongoStartupValidationTest.java
Context: The startup check is created only when a MongoTopologyProbe bean is present
(MongoPlatformAutoConfiguration.java:137-183), so a missing probe silently skips validation
entirely. Additionally, transactions and change streams are hardcoded true, true in the capability
flags, and absent admin credentials are treated as "no validation needed".
The fix, per index §Scope boundaries:
-
Mongo on ⇒ a probe is built from the live data-plane client, or the absence is a startup error.
-
Capability flags come from typed settings, never literals.
-
Admin credential/gateway belongs to a migration or deployment job's composition; the shipped application neither binds nor requires it.
-
transactionsis a subordinate switch defaultingfalse; when on, the real replica-set capability of the data-plane credential is verified. -
change-streamsis experimental, alwaysfalse, zero beans and zero threads. A replica-set qualification observing server capability is not evidence of shipped support. -
Steps 1–6: TDD cycle with one case per bullet.
Task B4: Separate role from credential identity (MNG-INT-004)
Files:
- Modify: the credential identity hash implementation
- Test:
.../MongoCredentialIdentityTest.java
Context: The identity hash includes the role, so the same secret reference used under two roles looks like two different credentials, defeating the separation it was meant to enforce.
- Steps 1–5: TDD cycle; the regression case is one secret reference under two roles, which must be detected as the same credential.
Task B5: Resolve the three ghost release lanes
Files:
- Modify:
src/config/mongodb/release-contracts.jsonorsrc/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, updatedocs/mongodb/advanced/sharding.mdto stop describing an unrunnable gate, and makeverify-mongodb-advanced.shfail with a clear "not promoted" message rather than invoking a task that does not exist.
Recommendation: (b). Sharding, Atlas, and KMS each need a protected environment this repository does not have, and index §Scope boundaries already places them outside the shipped Stable runtime. Implementing them to satisfy a manifest entry would be the tail wagging the dog.
- Steps 1–5: apply the chosen outcome, run
./gradlew :app-bootstrap:test --tests '*ReleaseManifestTaskExistenceTest*'to green, remove its@Tag("wave0-red"), commit.
Section C — Messaging
Task C1: Fix the secret scanner without weakening it (MSG-INT-005)
Files:
- Create:
src/messaging/messaging-observability/src/main/java/dev/caskeleton/messaging/observation/SecretConcatenationClassifier.java - Modify:
src/messaging/messaging-observability/src/test/java/dev/caskeleton/messaging/observation/SecretLeakStaticScanTest.java - Modify:
src/messaging/messaging-observability/src/test/java/dev/caskeleton/messaging/observation/SecretLeakScannerCharacterizationTest.java
Interfaces:
- Produces:
SecretConcatenationClassifier.leaksASensitiveValue(String line)— extracted from the test so the characterization can call the real thing.
Context: Root cause, confirmed by reading the scanner (index §The one reproduced red test):
CONCATENATION_OPERAND captures a method call including its trailing (), while
DESCRIBES_RATHER_THAN_REVEALS anchors with $. The safe-suffix exemption is therefore dead for
every method call. Second offender: existing.leaseToken() + 1 is arithmetic.
The two fixes:
- Strip a trailing
()fromtailbefore matching the safe-suffix pattern. Not: loosen the anchor — an unanchoredIdwould exemptcredentialIdentity, which does carry the value. - 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/javaunchanged, and point both tests at it. Run — the same 2 failures, now against the real class. - Step 2: Apply fix 1. Run —
methodCallWithSafeSuffixIsNotALeakgreen, true positives still green. - Step 3: Apply fix 2. Run —
numericFencingIsNotALeakgreen. - Step 4: Delete the duplicated classifier from
SecretLeakScannerCharacterizationTestand 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, includingSecretLeakStaticScanTest. - 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.
MessagingAuthorityContractTestrecords the two owners ofapp.messagingby name and asserts that exactly one production type implementsOutboxMessagePublishPort, plus that the adapter-localMessagePublisherdoes not reachapplication-coreordomain-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
MessagingSettingsmeans the adapter stops selecting a broker and becomes a port bridge over the platform's publish contract; it can only do that once the platform has a production publisher. Removing the binding first would leave the adapter unable to select anything, which is a worse state than the split it fixes. The guard above is what keeps the split honest until then, and it fails the moment a third owner appears or a second publisher starts emitting.
Task C3: A real production sender, and membership earned by a live round trip (MSG-INT-003)
Files:
- Create: the production Kafka and RabbitMQ sender implementations in the selected platform leaves
- Modify:
src/config/architecture/modules.json,src/app-bootstrap/build.gradle - Test:
src/app-bootstrap/src/test/java/.../MessagingLiveRoundTripQualificationTest.java(Testcontainers Kafka)
Context: The legacy Kafka config requires a project-supplied KafkaSender that exists only as a
test fake. A production app therefore has no sender at all, and every "messaging works" signal comes
from a fixture.
Membership rule, enforced here: the starter and every internal leaf that actually resolves onto
the runtime classpath gain app-bootstrap membership in the same change unit that turns
MessagingLiveRoundTripQualificationTest green against a real broker. Leaves that are unsupported or
unqualified are excluded from both the starter's dependencies and the registry.
Because Wave 1 Task 13 made the membership gate closure-based, adding the starter will surface every transitive leaf at once — that is intended, and each must be either recorded as a member or removed from the starter's dependency graph.
- Steps 1–8: TDD cycle ending with
./gradlew verifyRuntimeModuleMembershipgreen andShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShippedgreen with its tag removed.
Task C4: One master-gated starter root (MSG-INT-004)
Files:
- Create:
src/messaging/messaging-spring-boot-starter/src/main/java/dev/caskeleton/messaging/autoconfigure/MessagingPlatformRootAutoConfiguration.java - Modify:
src/messaging/messaging-spring-boot-starter/src/main/resources/META-INF/spring/...AutoConfiguration.imports - Test:
.../MessagingStarterOffContractTest.java
Context: Verified at HEAD, the starter registers five independent auto-configurations
(MessagingCoreAutoConfiguration, KafkaMessagingAutoConfiguration,
RabbitMessagingAutoConfiguration, MessagingReliabilityAutoConfiguration,
MessagingAdminAutoConfiguration) and none carries a messaging master condition.
After this task imports holds exactly one entry — the root — which imports the selected provider and
reliability children. Kafka and Rabbit must never assemble together merely because both client
libraries are on the classpath; provider selection is a closed descriptor + registry, and an unknown
or duplicate selection is a startup error.
The off test is full-context and includes starter imports and vendor Boot auto-configuration: zero beans, zero clients, zero threads.
- Steps 1–7: TDD cycle.
Section D — Notification
Task D1: Fix the mode SSOT drift (NTF-INT-002)
Files:
- Modify:
docs/registries/env-keys.yaml:4266-4277 - Modify: every YAML, doc, and test using
ACCEPT_ONLY - Test:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationModeSsotTest.java
Context: The Java enum is SERVING|INGEST_ONLY
(NotificationPlatformMode.java:11-23); the env registry declares SERVING|ACCEPT_ONLY. Canonical
name is INGEST_ONLY. The test asserts that the registry's enum values equal
NotificationPlatformMode.values() — derived, so it cannot drift again.
- Steps 1–5: TDD cycle; smallest task in this wave, do it first so later tasks use one name.
Task D2: Production provider assemblers (NTF-INT-001)
Files:
- Create: production
ProviderRuntimeAssemblerimplementations undersrc/adapter/outbound/notification/src/main/java/.../platform/provider/ - Modify:
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationPlatformProviderConfig.java - Test:
.../NotificationServingAssemblyTest.java
Context: Configuration assembles List<ProviderRuntimeAssembler> but production main source has
no implementation (NotificationPlatformProviderConfig.java:78-99). SERVING therefore cannot work
in production regardless of settings.
SERVING turns on only when the selected provider family has a real assembler, secret resolver,
timeout/rate/permit policy, and readiness contributor. A provider without one is not documented as
Stable — update the capability docs in the same change.
Reference provider for the Wave 3 local-notification-serving lane: SMTP via Mailpit.
- Steps 1–7: TDD cycle.
Task D3: Mode-scoped worker lifecycle (NTF-INT-003)
Files:
- Modify:
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationPlatformWorkerConfig.java - Test:
.../NotificationWorkerLifecycleTest.java
Context: The worker config checks only master-enabled and unconditionally start()s background
workers and the scheduler (NotificationPlatformWorkerConfig.java:135-166). INGEST_ONLY must start
zero dispatch, recovery, and reconciliation threads.
Asserted on live thread count, via AdapterActivationInventory.liveThreadNamesMatching, not on
bean absence — a worker bean that exists but was never started is acceptable; a thread is not.
- Steps 1–6: TDD cycle.
Task D4: Retire the legacy selector namespace (NTF-INT-004)
Files:
- Modify:
docs/registries/env-keys.yaml, the notification YAML, and the optional-bean tests - Test:
.../NotificationLegacyNamespaceRetirementTest.java
Context: The registry and YAML use provider selectors while the actual bean conditions mix
slack-webhook.enabled, google-email.enabled, and routes.*. The delivery platform becomes the
canonical runtime; the legacy R0 selector is isolated behind a migration shim that raises a naming
migration error, then removed.
- Steps 1–6: TDD cycle; the negative assertion — that no legacy selector is documented as an
activation key — is required by spec §6.4 and belongs in
MasterSwitchRegistryContractTest(Wave 1 Task 12).
Task D5: A frozen, non-empty route plan for INGEST_ONLY (NTF-INT-006)
Files:
- Modify:
src/adapter/outbound/notification/src/main/java/.../CanonicalNotificationPlanWriter.java - Modify:
.../PolicyRoutePlanner.java - Create: versioned route-metadata loading in the notification root
- Test:
src/app-bootstrap/src/test/java/.../NotificationIngestHandoffQualificationTest.java
Context: The most consequential blocker in this section, and the one most easily mistaken for
working. Today the plan writer freezes a provider-specific routing plan at accept time and stores an
empty plan when the route catalog is empty (CanonicalNotificationPlanWriter.java:82-128,
PolicyRoutePlanner.java:50-76). Dispatch consumes the stored snapshot verbatim
(NotificationDispatchService.java:298-307). So a row accepted in INGEST_ONLY with no routes never
becomes deliverable, no matter how the application is later restarted.
The contract:
INGEST_ONLYstartup 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.
SERVINGstartup verifies the production assembler registry supports every stored provider ID/version; a mismatched row is never silently reinterpreted.- During
INGEST_ONLY: zero provider credentials, zero provider runtime beans, zero workers.
Qualification, which is also Wave 3's local-notification-handoff lane:
INGEST_ONLY accept → process stop → SERVING restart → exactly one delivery on the same frozen route, against a real database and provider fixture.
- Steps 1–9: TDD cycle, ending with the handoff qualification green against Testcontainers PostgreSQL + Mailpit.
Task D6: Decide the at-rest payload sensitivity contract (NTF-INT-007)
Files:
- Either: create the encryption codec/port, ciphertext envelope, key ID, rotation/history, row migration, and decryption-failure contract
- Or: create
docs/notification/at-rest-threat-model.mdplus a static variable-type restriction - Test:
.../NotificationPayloadAtRestContractTest.java
Context: The accept path stores encoded.variablesPayload() into the request row in plaintext
(CanonicalNotificationPlanWriter.java:60-79). The PAYLOAD_ENCRYPTION key is consumed only by
callback raw-payload protection (AesGcmCallbackPayloadProtection.java:88-97), so requiring it in
INGEST_ONLY would demand a secret that protects nothing — do not paper over the gap that way.
Per index §Scope boundaries, Notification is not promoted to Stable until one of the two branches is complete. Neither branch is optional; pick one, implement it fully, and record the decision.
Recommendation: the threat-model branch, if and only if the variable types can genuinely be restricted to non-sensitive values. Application-level encryption without rotation and migration designed in is a larger commitment than this wave can honour, and a half-built envelope is worse than a documented restriction.
- Steps 1–6: implement the chosen branch fully; a partial implementation of either is a fail.
Section E — GraphQL
Task E1: Collapse the two safety axes into one deployment mode (GQL-INT-002)
Files:
- Modify:
src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlPlatformProperties.java - Modify:
.../GraphQlPlatformStartupValidator.java,.../GraphQlPlatformAutoConfiguration.java - Test:
.../GraphQlDeploymentModeContractTest.java
Context: GraphQlPlatformProperties defaults to production=false and
environment=PRODUCTION_PUBLIC simultaneously (GraphQlPlatformProperties.java:32-54). This is not a
display inconsistency: anonymous principal handling, allow-by-default authorization, and part of
request protection read only the boolean (GraphQlPlatformStartupValidator.java:33-45,
GraphQlPlatformAutoConfiguration.java:302-343,441-459), so the enum can say production while the
protections behave as if it is not — a configuration split-brain that bypasses production safety.
Both collapse into backend.graphql.deployment-mode, with the environment constraint from index
§Global Constraints. Using either legacy key while GraphQL is on, alone or alongside the new one, is a
migration error naming APP_GRAPHQL_DEPLOYMENT_MODE. With the master off, the detail namespace is
neither bound nor validated.
- Steps 1–7: done.
GraphQlPlatformEnvironment→GraphQlDeploymentMode;productionandenvironmentare gone from the record andproduction()is derived from the mode;GraphQlActivationEnvironmentPostProcessorrefuses both retired keys;GraphQlDeploymentModeContractTest(22 cases) andGraphQlDeploymentModeRegistryParityTestare green, andlocal-graphqlpasses end to end.
Three amendments made while implementing, all recorded in
evidence/2026-08-15-wave2-decisions.md:
- Four modes, not six.
TESTandSTAGINGwere 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. - Boot's introspection default contradicted the platform's — with the switch on and nothing else
set,
spring.graphql.schema.introspection.enabled=trueagainst a platform console default offalse, 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. - The Keycloak realm artifact could never have imported. Keycloak rejects unknown fields, so the
_commentand_flowCommentannotation keys failed the whole import. Removed, rationale moved toinfra/keycloak/README.md, andverify-compose-profile-contracts.shnow fails on any_-prefixed key in that artifact. The smoke client also could not read the 0600 secret (uid mismatch) and now runs as root in-container.
Task E2: Prove the policy pipeline on the real request path (GQL-INT-003)
Files:
- Create:
src/adapter/inbound/graphql/src/test/java/.../GraphQlPolicyRequestPathTest.java
Context: Auto-configuration and a startup validator existing is not evidence that cost, authz,
cursor, and idempotency policies apply. The test uses a random-port /graphql and asserts a policy
violation is rejected before the resolver or use case is invoked — verified with a spy on the use
case that must record zero invocations — and that JWT actor/tenant context reaches the resolver.
- Steps 1–6: TDD cycle, one case per policy.
Task E3: One blocking JWT composition lane (GQL-INT-004)
Files:
- Create:
src/app-bootstrap/src/graphqlRuntimeQualificationTest/java/dev/caskeleton/bootstrap/graphql/GraphQlJwtRuntimeQualificationTest.java - Modify:
src/app-bootstrap/build.gradle(registergraphqlRuntimeQualification) - 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:
ConditionalTransportCompositionContractTestasserts only that GraphQL classes exist (:15-46) — class existence is not composition evidence.GraphqlHttpBoundaryQualificationTestauthenticates with test-only Basic Auth (:40-59,189-229) — not the shipped JWT composition.- CI runs
check verifyPublicPathSnapshot verifyDependencyLocksandconditionalTransportQualification, and nevergraphqlStableTest(.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.GraphQlJwtRuntimeQualificationTestin thegraphqlRuntimeQualificationTestsource 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/graphqlRuntimeQualificationand sanitized process/claim/startup logs toapp-bootstrap/build/evidence/graphql-runtime/; - rejects zero-discovery, any skip, and stale XML.
Then: replace the GraphQL Basic Auth leg of root conditionalTransportQualification with this task,
leaving the gRPC and WebSocket legs untouched; keep GraphqlHttpBoundaryQualificationTest as a module
contract test but stop aggregating it into release evidence; and change the CI quality job to run
:adapter:inbound:graphql:graphqlStableTest :app-bootstrap:graphqlRuntimeQualification conditionalTransportQualification with a single dependency edge so the GraphQL task cannot execute
twice.
Ordering note: this task depends on Wave 3 Task 4 (the Keycloak realm artifact). Either run Wave 3 Task 4 early, or defer E3 to immediately after it. Record which you chose.
- Steps 1–9: TDD cycle ending with the lane green and the CI workflow updated.
Wave 2 Exit Criteria
./gradlew test --console=plain --no-daemon— the full ordinary suite green (the single pre-existing failure closed by Task C1)../gradlew wave0RedReport --console=plain --no-daemon— only Wave 3 and Wave 4 entries remain.- Each adapter's one-on lane passes against real infrastructure:
bash cd src ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=16 --console=plain ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=17 --console=plain ./gradlew :adapter:outbound:persistence-jpa:jpaPlatformReleaseGate -Pjpa.matrix.versions=18 --console=plain ./gradlew :adapter:outbound:persistence-mongo:mongoStableContractTest \ :adapter:outbound:persistence-mongo:mongoReplicaSetTest \ :adapter:outbound:persistence-mongo:mongoFailoverTest \ :adapter:outbound:persistence-mongo:mongoMigrationTest \ :adapter:outbound:persistence-mongo:mongoCompatibilityTest \ :adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest \ :adapter:outbound:persistence-mongo:mongoPerformanceTest --console=plain ./gradlew :adapter:inbound:graphql:graphqlStableTest \ :app-bootstrap:graphqlRuntimeQualification conditionalTransportQualification --console=plain ./gradlew verifyRuntimeModuleMembership verifyCleanArchitectureDependencies verifyEnvKeys --console=plain— green.- Every Section D and Section B decision (B5, D6) is recorded with its rationale in
docs/superpowers/plans/evidence/2026-08-15-wave2-decisions.md.
What Wave 2 explicitly does not do
- No
SPRING_PROFILES_ACTIVEchange,.envsplit, 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.