# Wave 1 — Activation SSOT and Classpath 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** — its Global Constraints are implicitly part of every task here. > **Entry criterion:** Wave 0 complete, with the red set recorded in > `docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md`. **Goal:** Make one activation authority per adapter real: five canonical master switches defaulting to `false`, all five facades on one runtime classpath, structural gating that makes "off" a structural fact, and dependency-closure validators that fail closed at startup — so that an all-off `local`, `dev`, and `prod` boot succeeds with no external infrastructure. **Architecture:** Each adapter gets exactly one root `@AutoConfiguration` registered in `AutoConfiguration.imports`. That root owns the master condition and `@EnableConfigurationProperties` for the adapter's detail settings; every child configuration is reached only by the root's `@Import`. The composition root's component scan and `@ConfigurationPropertiesScan` are narrowed so no leaf stereotype or `@ConfigurationProperties` is discoverable outside its root. Vendor Boot auto-configuration is excluded in the off state by one `AutoConfigurationImportFilter` per adapter, following the mechanism `MongoOptInAutoConfigurationImportFilter` already establishes. A single `MasterSwitchEnvironmentPostProcessor` parses the five scalars strictly *before* any detail binding and rejects typos, ambiguity, and legacy keys. Subordinate capabilities (outbox relay, JDBC idempotency, distributed lock, notification store, DB readiness, migration) are moved under capability roots whose conditions are computed from the same dependency closure. **Tech Stack:** Spring Boot 4.0.0 auto-configuration (`@AutoConfiguration`, `AutoConfigurationImportFilter`, `EnvironmentPostProcessor`), Gradle 9 project dependencies, the architecture registry, JUnit 5 + `ApplicationContextRunner` + `@SpringBootTest`. **Spec:** [`2026-08-15-five-adapter-runtime-remediation-review-design.md`](../specs/2026-08-15-five-adapter-runtime-remediation-review-design.md) (§4, §5, §6.1 JPA-INT-001/004, §6.2 MNG-INT-001/005, §6.3 MSG-INT-001, §6.4 NTF-INT-005, §6.5 GQL-INT-001, §11 Wave 1) --- ## Global Constraints Inherited in full from the index. Wave 1 adds: - **Wave 1 changes activation, never behaviour on the on-path.** If a fix requires changing what an adapter *does* once enabled, it belongs to Wave 2. The line: Wave 1 may decide whether a bean exists; Wave 2 decides what it does. - **No adapter may be given runtime membership before it can be off.** For each adapter the order is strictly: structural gating first, off-invariant test green, *then* the registry edge and the `app-bootstrap` dependency. Adding the dependency first ships an ungated adapter, which is exactly the state spec §4.2 describes. - Every registry edit is one change unit with its matching `build.gradle` dependency and its `ShippedRuntimeFacadePresenceTest` case. A registry entry without the dependency, or the reverse, fails `verifyCleanArchitectureDependencies`. - Messaging platform leaves get **no** runtime membership in this wave. Wave 1 fixes only the legacy bridge's gating; MSG-INT-002/003 (real production bridge and starter membership) are Wave 2. - `verifyEnvKeys` must pass after every task that touches `docs/registries/env-keys.yaml`. --- ## File Structure ### Created | File | Responsibility | | --- | --- | | `src/shared-contract/src/main/java/dev/caskeleton/shared/activation/MasterSwitch.java` | The five switches as an enum: canonical property, canonical env, legacy keys it supersedes. One place that knows the names. | | `src/shared-contract/src/main/java/dev/caskeleton/shared/activation/MasterSwitchParseResult.java` | Sealed result of parsing one scalar: `Off`, `On`, `Invalid`, `Ambiguous`, `LegacyOnly`. | | `src/shared-contract/src/main/java/dev/caskeleton/shared/activation/MasterSwitchParser.java` | Pure parser. No Spring types, so it unit-tests without a context. | | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/MasterSwitchEnvironmentPostProcessor.java` | Runs the parser over the real `Environment` before any detail binding; throws on anything but `Off`/`On`. | | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationReport.java` | The resolved on/off state of all five switches, plus the profile. Consumed by the actuator endpoint and by Wave 3's Compose evidence. | | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationEndpoint.java` | `@ReadOperation` actuator endpoint serving the report. | | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationAutoConfiguration.java` | Registers the report and endpoint. Unconditional — the report must exist precisely when everything is off. | | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/PersistenceJpaRootAutoConfiguration.java` | The single JPA activation authority. Owns `ca-skeleton.persistence-jpa.enabled`; imports the existing JPA configurations. | | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/JpaOffAutoConfigurationImportFilter.java` | Excludes Boot's JPA/Hibernate/Hikari/Flyway/DataSource auto-configurations while JPA is off **and** no other capability requires a `DataSource`. | | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/DataSourceRequirement.java` | Computes whether any active capability needs a `DataSource`. The one place the JPA/outbox/idempotency/lock/notification-store closure is expressed. | | `src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoRootAutoConfiguration.java` | The single Mongo activation authority; imports persistence + platform children. | | `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingBridgeRootAutoConfiguration.java` | The single legacy-bridge activation authority. Owns `app.messaging.enabled`. | | `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingOffAutoConfigurationImportFilter.java` | Excludes Boot Kafka/AMQP auto-configuration while messaging is off. | | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationRootAutoConfiguration.java` | The single Notification activation authority; imports runtime + secrets + registries + callbacks. | | `src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlRootAutoConfiguration.java` | The single GraphQL activation authority. | | `src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlOffAutoConfigurationImportFilter.java` | Excludes Boot GraphQL auto-configuration while GraphQL is off. | | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/CapabilityDependencyValidator.java` | Startup validator for the on-path dependency closure in spec §5.3. Names the exact missing switch. | | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/ReadinessGroupContributor.java` | Derives readiness group membership from active capabilities instead of a static `db`. | ### Modified | File | Change | | --- | --- | | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java` | Narrow `@ConfigurationPropertiesScan` with the same exclusion the component scan already has, and extend `AUTO_CONFIGURED_PACKAGES` to cover the five adapters' packages. | | `src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | Add the JPA, Notification, and activation roots. | | `src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfigurationImportFilter.imports` | **Create.** Register the JPA/messaging/GraphQL off-filters. | | `src/adapter/outbound/persistence-mongo/src/main/resources/META-INF/spring/...AutoConfiguration.imports` | Replace the two entries with the single `MongoRootAutoConfiguration`. | | `src/adapter/inbound/graphql/src/main/resources/META-INF/spring/...AutoConfiguration.imports` | Replace with `GraphQlRootAutoConfiguration`. | | `src/adapter/outbound/messaging/src/main/resources/META-INF/spring/...AutoConfiguration.imports` | **Create** with `MessagingBridgeRootAutoConfiguration`. | | `src/config/architecture/modules.json` | Add `adapter-outbound-persistence-mongo` and `adapter-inbound-graphql` allowed dependencies + `app-bootstrap` membership; add both to `app-bootstrap.allowed_dependencies`. | | `src/app-bootstrap/build.gradle` | Add the mongo and graphql project dependencies. | | `src/app-bootstrap/src/main/resources/application.yml` | Five master switches at `false`; `relay-enabled: false`; `idempotency.provider: disabled`; readiness group without a static `db`. | | `src/app-bootstrap/src/main/resources/application-local.yml` | Remove implicit JPA/H2 activation. | | `docs/registries/env-keys.yaml` | Register the five canonical keys + the two subordinate selectors; demote `APP_MESSAGING_BROKER` and the two notification provider selectors. | | `src/build.gradle` | Replace the direct-dependency membership gate with the resolved-closure gate from Wave 0 Task 8. | | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupConfig.java` | Move under the JPA root; no unconditional Flyway strategy or validators. | | `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java` | Move under an `outbox.enabled` capability root requiring JPA + messaging. | --- ## Task 1: The master-switch parser **Files:** - Create: `src/shared-contract/src/main/java/dev/caskeleton/shared/activation/MasterSwitch.java` - Create: `src/shared-contract/src/main/java/dev/caskeleton/shared/activation/MasterSwitchParseResult.java` - Create: `src/shared-contract/src/main/java/dev/caskeleton/shared/activation/MasterSwitchParser.java` - Test: `src/shared-contract/src/test/java/dev/caskeleton/shared/activation/MasterSwitchParserTest.java` **Interfaces:** - Produces: - `enum MasterSwitch { PERSISTENCE_JPA, PERSISTENCE_MONGO, MESSAGING, NOTIFICATION_PLATFORM, GRAPHQL }` with `String property()`, `String environmentVariable()`, `List legacyProperties()` - `sealed interface MasterSwitchParseResult permits Off, On, Invalid, Ambiguous, LegacyOnly` - `static MasterSwitchParseResult MasterSwitchParser.parse(MasterSwitch, String canonicalRaw, Map legacyRaw)` Task 2 consumes `parse`. Tasks 4–9 consume `MasterSwitch.property()` for their conditions — never a string literal. **Context:** `shared-contract` may depend only on the Java standard library (registry `allowed_dependencies: []` for `shared-contract`). No Spring types here; that is what makes the rule testable without a context and reusable by the `EnvironmentPostProcessor`, which runs before the context exists. > **Correction applied during execution.** An earlier draft of this task listed the notification > provider selectors (`app.notification.slack.provider`, `app.notification.email.provider`) and the > two GraphQL safety flags (`backend.graphql.production`, `backend.graphql.environment`) as *legacy > keys of their master switches*. That is wrong, and it fails immediately: a provider selector is > present in the shipped default configuration, so every startup that legitimately set the > notification master was rejected as ambiguous. Those keys are not alternative spellings of a master > switch — spec §5.1 demotes them to **subordinate settings that stay meaningful while the adapter is > on**. Their migrations belong to the settings that replace them (the delivery-platform runtime in > Wave 2 D4; `backend.graphql.deployment-mode` in Wave 2 E1), not to the master. Only > `app.jpa-platform.enabled` is a genuine former master alias, so it is the only entry in > `legacyProperties()`. > > Also note: `MasterSwitch` cannot hold a `List` instance field — Error Prone's > `ImmutableEnumChecker` rejects it and this repository compiles with `-Werror`. Keep the two scalar > names as fields and put the superseded names in a static `EnumMap`. The rules, verbatim from index §Master scalar parsing rule: unset ⇒ `Off`; exactly `true`/`false` case-insensitively with no surrounding whitespace ⇒ `On`/`Off`; anything else ⇒ `Invalid`; canonical and legacy both present ⇒ `Ambiguous` even when values agree; legacy alone ⇒ `LegacyOnly` naming the replacement. - [ ] **Step 1: Write the failing test** Create `src/shared-contract/src/test/java/dev/caskeleton/shared/activation/MasterSwitchParserTest.java`: ```java package dev.caskeleton.shared.activation; import static org.assertj.core.api.Assertions.assertThat; import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; /** * A master switch is the one value an operator must be able to trust absolutely. * *

Every rule here exists because the permissive alternative fails silently. {@code yes} parsed as * true is a deployment that turned something on by accident; {@code yes} parsed as false is a * deployment that thought it turned something on and did not. Rejecting it is the only answer that * cannot be wrong in a way nobody notices. */ class MasterSwitchParserTest { @Test @DisplayName("an unset switch is off") void unsetIsOff() { assertThat(MasterSwitchParser.parse(MasterSwitch.GRAPHQL, null, Map.of())) .isInstanceOf(MasterSwitchParseResult.Off.class); } @Test @DisplayName("true and false parse case-insensitively") void booleansParse() { assertThat(MasterSwitchParser.parse(MasterSwitch.GRAPHQL, "TRUE", Map.of())) .isInstanceOf(MasterSwitchParseResult.On.class); assertThat(MasterSwitchParser.parse(MasterSwitch.GRAPHQL, "False", Map.of())) .isInstanceOf(MasterSwitchParseResult.Off.class); } @Test @DisplayName("yes, 1, on, blank, and whitespace are configuration errors rather than off") void truthyLookalikesAreRejected() { for (String raw : new String[] {"yes", "1", "on", "", " ", "true ", " false", "ture"}) { assertThat(MasterSwitchParser.parse(MasterSwitch.MESSAGING, raw, Map.of())) .as("%s must be rejected; a silent off here is a deployment that thinks it is on", raw) .isInstanceOf(MasterSwitchParseResult.Invalid.class); } } @Test @DisplayName("canonical and legacy together are ambiguous even when they agree") void agreeingDuplicatesAreStillAmbiguous() { MasterSwitchParseResult result = MasterSwitchParser.parse( MasterSwitch.PERSISTENCE_JPA, "true", Map.of("app.jpa-platform.enabled", "true")); assertThat(result) .as( "two keys that agree today diverge the first time somebody edits one of them; the " + "ambiguity is the defect, not the disagreement") .isInstanceOf(MasterSwitchParseResult.Ambiguous.class); } @Test @DisplayName("a legacy key alone names its replacement") void legacyAloneNamesTheReplacement() { MasterSwitchParseResult result = MasterSwitchParser.parse( MasterSwitch.PERSISTENCE_JPA, null, Map.of("app.jpa-platform.enabled", "true")); assertThat(result).isInstanceOf(MasterSwitchParseResult.LegacyOnly.class); assertThat(((MasterSwitchParseResult.LegacyOnly) result).replacementProperty()) .isEqualTo("ca-skeleton.persistence-jpa.enabled"); } @Test @DisplayName("each switch carries its canonical env name") void environmentNamesAreCanonical() { assertThat(MasterSwitch.PERSISTENCE_JPA.environmentVariable()) .isEqualTo("APP_PERSISTENCE_JPA_ENABLED"); assertThat(MasterSwitch.PERSISTENCE_MONGO.environmentVariable()) .isEqualTo("APP_PERSISTENCE_MONGO_ENABLED"); assertThat(MasterSwitch.MESSAGING.environmentVariable()).isEqualTo("APP_MESSAGING_ENABLED"); assertThat(MasterSwitch.NOTIFICATION_PLATFORM.environmentVariable()) .isEqualTo("APP_NOTIFICATION_PLATFORM_ENABLED"); assertThat(MasterSwitch.GRAPHQL.environmentVariable()).isEqualTo("APP_GRAPHQL_ENABLED"); } } ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd src && ./gradlew :shared-contract:test --tests '*MasterSwitchParserTest*' --console=plain --no-daemon` Expected: FAIL — `MasterSwitch` does not exist. - [ ] **Step 3: Write the three production types** `MasterSwitch.java`: ```java package dev.caskeleton.shared.activation; import java.util.List; /** * The five adapters this skeleton ships behind an explicit switch, and the names that address them. * *

One place knows the names. Spread across conditions as string literals, a rename becomes a * silent activation change: the condition stops matching, the adapter stops assembling, and nothing * reports it. */ public enum MasterSwitch { PERSISTENCE_JPA( "ca-skeleton.persistence-jpa.enabled", "APP_PERSISTENCE_JPA_ENABLED", List.of("app.jpa-platform.enabled")), PERSISTENCE_MONGO( "ca-skeleton.persistence-mongo.enabled", "APP_PERSISTENCE_MONGO_ENABLED", List.of()), MESSAGING("app.messaging.enabled", "APP_MESSAGING_ENABLED", List.of()), NOTIFICATION_PLATFORM( "ca-skeleton.notification.platform.enabled", "APP_NOTIFICATION_PLATFORM_ENABLED", List.of("app.notification.slack.provider", "app.notification.email.provider")), GRAPHQL( "backend.graphql.enabled", "APP_GRAPHQL_ENABLED", List.of("backend.graphql.production", "backend.graphql.environment")); private final String property; private final String environmentVariable; private final List legacyProperties; MasterSwitch(String property, String environmentVariable, List legacyProperties) { this.property = property; this.environmentVariable = environmentVariable; this.legacyProperties = List.copyOf(legacyProperties); } /** @return the canonical Spring property name */ public String property() { return property; } /** @return the canonical environment variable name */ public String environmentVariable() { return environmentVariable; } /** @return property names this switch supersedes, which are now migration errors */ public List legacyProperties() { return legacyProperties; } } ``` `MasterSwitchParseResult.java`: ```java package dev.caskeleton.shared.activation; /** The outcome of reading one master switch. Only {@link Off} and {@link On} may start a context. */ public sealed interface MasterSwitchParseResult { /** The switch is off, either explicitly or because nobody set it. */ record Off(MasterSwitch owner) implements MasterSwitchParseResult {} /** The switch is on. */ record On(MasterSwitch owner) implements MasterSwitchParseResult {} /** The value is neither {@code true} nor {@code false}. */ record Invalid(MasterSwitch owner, String raw) implements MasterSwitchParseResult {} /** Canonical and legacy keys are both set. */ record Ambiguous(MasterSwitch owner, String legacyProperty) implements MasterSwitchParseResult {} /** Only a superseded key is set. */ record LegacyOnly(MasterSwitch owner, String legacyProperty, String replacementProperty) implements MasterSwitchParseResult {} } ``` `MasterSwitchParser.java`: ```java package dev.caskeleton.shared.activation; import java.util.Locale; import java.util.Map; /** * Reads one master switch, strictly. * *

No Spring types, so the rule is testable without a context — and usable by the environment * post-processor, which has to run before there is one. */ public final class MasterSwitchParser { private MasterSwitchParser() {} /** * Parses a switch from its canonical raw value and whatever legacy keys are present. * * @param owner the switch being read * @param canonicalRaw the raw canonical value, or null when unset * @param legacyRaw legacy property names to their raw values; only present keys belong here * @return the parse outcome */ public static MasterSwitchParseResult parse( MasterSwitch owner, String canonicalRaw, Map legacyRaw) { String legacyPresent = owner.legacyProperties().stream().filter(legacyRaw::containsKey).findFirst().orElse(null); if (canonicalRaw != null && legacyPresent != null) { return new MasterSwitchParseResult.Ambiguous(owner, legacyPresent); } if (canonicalRaw == null && legacyPresent != null) { return new MasterSwitchParseResult.LegacyOnly(owner, legacyPresent, owner.property()); } if (canonicalRaw == null) { return new MasterSwitchParseResult.Off(owner); } String normalized = canonicalRaw.toLowerCase(Locale.ROOT); if ("true".equals(normalized)) { return new MasterSwitchParseResult.On(owner); } if ("false".equals(normalized)) { return new MasterSwitchParseResult.Off(owner); } return new MasterSwitchParseResult.Invalid(owner, canonicalRaw); } } ``` - [ ] **Step 4: Run the test to verify it passes** Run: `cd src && ./gradlew :shared-contract:test --tests '*MasterSwitchParserTest*' --console=plain --no-daemon` Expected: PASS, 6 tests. - [ ] **Step 5: Commit** — report to the human: ``` git add src/shared-contract/src/main/java/dev/caskeleton/shared/activation/ \ src/shared-contract/src/test/java/dev/caskeleton/shared/activation/ git commit -m "feat(shared-contract): add the master-switch parser One place knows the five names, and the parse is strict on purpose: yes parsed as true turns something on by accident, and yes parsed as false is a deployment that thinks it is on and is not. Rejecting is the only answer that cannot be wrong unnoticed." ``` --- ## Task 2: Enforce the parse before any detail binding **Files:** - Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/MasterSwitchEnvironmentPostProcessor.java` - Create: `src/app-bootstrap/src/main/resources/META-INF/spring.factories` (or append if present) - Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/MasterSwitchEnvironmentPostProcessorTest.java` **Interfaces:** - Consumes: `MasterSwitch`, `MasterSwitchParser`, `MasterSwitchParseResult` from Task 1. - Produces: `MasterSwitchEnvironmentPostProcessor implements EnvironmentPostProcessor`. Tasks 4–9's conditions may then assume the property, when present, is exactly `true` or `false`. **Context:** An `EnvironmentPostProcessor` runs after the `Environment` is prepared and before the context refreshes, which is the only window where the switch can be checked without binding the detail namespace. Binding detail settings here would break off-invariant item 8 (an invalid detail setting must not block an off startup), so the post-processor reads *raw* property values via `Environment#getProperty` and never calls a binder. Registration goes in `META-INF/spring.factories` under `org.springframework.boot.env.EnvironmentPostProcessor`, because `AutoConfiguration.imports` is too late — auto-configuration runs during refresh. - [ ] **Step 1: Write the failing test** ```java package dev.caskeleton.bootstrap.activation; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import dev.caskeleton.bootstrap.CaSkeletonApplication; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.WebApplicationType; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.context.ConfigurableApplicationContext; /** * The switch is checked before anything reads a detail setting. * *

Checking it later would require binding the namespace it guards, and an adapter that is off * must be able to start beside an environment full of its own malformed configuration — otherwise * "off" is only off for deployments that got the off-path settings right. */ class MasterSwitchEnvironmentPostProcessorTest { @Test @DisplayName("a typo in a master switch fails startup by name") void aTypoFailsByName() { assertThatThrownBy(() -> start("ca-skeleton.persistence-mongo.enabled=ture")) .hasMessageContaining("ca-skeleton.persistence-mongo.enabled") .hasMessageContaining("ture"); } @Test @DisplayName("a legacy key alone fails and names its replacement") void aLegacyKeyNamesItsReplacement() { assertThatThrownBy(() -> start("app.jpa-platform.enabled=true")) .hasMessageContaining("ca-skeleton.persistence-jpa.enabled"); } @Test @DisplayName("an off adapter starts beside its own malformed detail settings") void offSurvivesMalformedDetail() { try (ConfigurableApplicationContext context = start( "ca-skeleton.persistence-mongo.enabled=false", "ca-skeleton.persistence-mongo.active-profile=", "ca-skeleton.persistence-mongo.profiles.primary.uri=not a uri")) { assertThat(context.isRunning()) .as("a malformed detail setting for an adapter nobody enabled must not block startup") .isTrue(); } } private static ConfigurableApplicationContext start(String... properties) { return new SpringApplicationBuilder(CaSkeletonApplication.class) .web(WebApplicationType.NONE) .profiles("local") .properties(properties) .properties(allOffBaseline()) .run(); } private static String[] allOffBaseline() { return new String[] { "ca-skeleton.persistence-jpa.enabled=false", "app.messaging.enabled=false", "ca-skeleton.notification.platform.enabled=false", "backend.graphql.enabled=false", "ca-skeleton.outbox.relay-enabled=false", "ca-skeleton.idempotency.provider=disabled" }; } } ``` - [ ] **Step 2: Run to verify it fails.** Run: `cd src && ./gradlew :app-bootstrap:test --tests '*MasterSwitchEnvironmentPostProcessorTest*' --console=plain --no-daemon` Expected: FAIL — no post-processor, so a typo binds as `false` and no exception is thrown. - [ ] **Step 3: Write the post-processor** ```java package dev.caskeleton.bootstrap.activation; import dev.caskeleton.shared.activation.MasterSwitch; import dev.caskeleton.shared.activation.MasterSwitchParseResult; import dev.caskeleton.shared.activation.MasterSwitchParser; import java.util.LinkedHashMap; import java.util.Map; import org.springframework.boot.SpringApplication; import org.springframework.boot.env.EnvironmentPostProcessor; import org.springframework.core.Ordered; import org.springframework.core.env.ConfigurableEnvironment; /** * Rejects an unreadable master switch before anything binds a detail namespace. * *

Reads raw property values rather than binding, deliberately. An adapter that is off has to be * able to start next to an environment full of its own malformed configuration — a validator that * bound the namespace to check the switch would fail exactly the deployments the switch exists to * protect. */ public class MasterSwitchEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { @Override public void postProcessEnvironment( ConfigurableEnvironment environment, SpringApplication application) { for (MasterSwitch masterSwitch : MasterSwitch.values()) { Map legacy = new LinkedHashMap<>(); for (String legacyProperty : masterSwitch.legacyProperties()) { String value = environment.getProperty(legacyProperty); if (value != null) { legacy.put(legacyProperty, value); } } reject( MasterSwitchParser.parse( masterSwitch, environment.getProperty(masterSwitch.property()), legacy)); } } private static void reject(MasterSwitchParseResult result) { if (result instanceof MasterSwitchParseResult.Invalid invalid) { throw new IllegalStateException( "%s must be exactly true or false, but was \"%s\". A value this close to a boolean is a " + "deployment that believes it set the switch; it is rejected rather than read as off." .formatted(invalid.owner().property(), invalid.raw())); } if (result instanceof MasterSwitchParseResult.Ambiguous ambiguous) { throw new IllegalStateException( "%s and %s are both set. They agree today and diverge the first time somebody edits one; " + "remove %s." .formatted( ambiguous.owner().property(), ambiguous.legacyProperty(), ambiguous.legacyProperty())); } if (result instanceof MasterSwitchParseResult.LegacyOnly legacy) { throw new IllegalStateException( "%s has been replaced by %s. Set the replacement; the old key is no longer read." .formatted(legacy.legacyProperty(), legacy.replacementProperty())); } } @Override public int getOrder() { // After config data has been loaded, so profile-specific YAML is visible; before anything that // binds a detail namespace. return Ordered.LOWEST_PRECEDENCE; } } ``` - [ ] **Step 4: Register it** Create `src/app-bootstrap/src/main/resources/META-INF/spring.factories` (append the key if the file exists): ```properties org.springframework.boot.env.EnvironmentPostProcessor=\ dev.caskeleton.bootstrap.activation.MasterSwitchEnvironmentPostProcessor ``` - [ ] **Step 5: Run the test to verify it passes.** Expected: PASS, 3 tests. - [ ] **Step 6: Commit** ``` git add src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/MasterSwitchEnvironmentPostProcessor.java \ src/app-bootstrap/src/main/resources/META-INF/spring.factories \ src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/MasterSwitchEnvironmentPostProcessorTest.java git commit -m "feat(bootstrap): reject an unreadable master switch before detail binding Reads raw values rather than binding: an adapter that is off has to start beside an environment full of its own malformed configuration, and a validator that bound the namespace to check the switch would fail exactly the deployments the switch protects." ``` --- ## Task 3: Narrow the composition root's scans **Files:** - Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java` - Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/CompositionScanNarrownessTest.java` **Interfaces:** - Consumes: nothing. - Produces: the narrowed `AUTO_CONFIGURED_PACKAGES` constant, extended to cover the five adapters. Tasks 4–9 depend on this: without it, their root's `@Import` is redundant because the component scan finds the children anyway. **Context:** At HEAD the class already excludes `dev\.caskeleton\.bootstrap\.autoconfigure\..*` from `@ComponentScan` — and the same exclusion is **absent** from `@ConfigurationPropertiesScan`, which is the specific defect behind NTF-INT-005. Fixing that is half this task. The other half is extending the pattern to the five adapters' own packages so a leaf's `@Component`/`@ConfigurationProperties` cannot be discovered outside its root. Do not delete the two custom filters (`TypeExcludeFilter`, `AutoConfigurationExcludeFilter`) — they are what `@SpringBootApplication` contributes and removing them breaks slice tests. - [ ] **Step 1: Write the failing test** ```java package dev.caskeleton.bootstrap.activation; import static org.assertj.core.api.Assertions.assertThat; import java.util.regex.Pattern; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.ComponentScan; /** * A capability's configuration is reachable only through its own root. * *

The component scan already carried this exclusion; the configuration-properties scan did not, * which is why a notification settings object bound itself with the notification master off. Both * scans have to agree, or "structurally gated" describes only the half of the capability that * happens to be beans. */ class CompositionScanNarrownessTest { private static final Class APPLICATION = dev.caskeleton.bootstrap.CaSkeletonApplication.class; @Test @DisplayName("both scans exclude every auto-configured package") void bothScansCarryTheSameExclusion() { String componentPattern = regexExcludeOf(APPLICATION.getAnnotation(ComponentScan.class).excludeFilters()); ConfigurationPropertiesScan propertiesScan = APPLICATION.getAnnotation(ConfigurationPropertiesScan.class); assertThat(propertiesScan.excludeFilters()) .as( "the configuration-properties scan must carry the same exclusion as the component scan; " + "without it a leaf's @ConfigurationProperties binds with its master off") .isNotEmpty(); assertThat(regexExcludeOf(propertiesScan.excludeFilters())) .as("the two scans must exclude the same packages, or gating covers only beans") .isEqualTo(componentPattern); } @Test @DisplayName("the exclusion covers all five optional adapters") void theExclusionCoversTheFiveAdapters() { Pattern excluded = Pattern.compile(regexExcludeOf(APPLICATION.getAnnotation(ComponentScan.class).excludeFilters())); for (String type : new String[] { "dev.caskeleton.bootstrap.autoconfigure.persistencejpa.PersistenceJpaRootAutoConfiguration", "dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig", "dev.caskeleton.adapter.outbound.messaging.MessagingSettings", "dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings", "dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformProperties" }) { assertThat(excluded.matcher(type).matches()) .as("%s must be reachable only through its capability root", type) .isTrue(); } } private static String regexExcludeOf(ComponentScan.Filter[] filters) { for (ComponentScan.Filter filter : filters) { if (filter.type() == org.springframework.context.annotation.FilterType.REGEX) { return filter.pattern()[0]; } } throw new AssertionError("no REGEX exclude filter is declared"); } } ``` - [ ] **Step 2: Run to verify it fails.** Expected: both cases fail — the properties scan has no filters, and the pattern covers only `bootstrap.autoconfigure` plus the fileserver admin package. - [ ] **Step 3: Modify `CaSkeletonApplication`** Replace the `AUTO_CONFIGURED_PACKAGES` constant and add the exclusion to `@ConfigurationPropertiesScan`: ```java static final String AUTO_CONFIGURED_PACKAGES = "dev\\.caskeleton\\.bootstrap\\.autoconfigure\\..*" + "|dev\\.caskeleton\\.bootstrap\\.notification\\..*" + "|dev\\.caskeleton\\.adapter\\.inbound\\.web\\.fileserver\\.admin\\..*" + "|dev\\.caskeleton\\.adapter\\.outbound\\.mongo\\..*" + "|dev\\.caskeleton\\.adapter\\.outbound\\.messaging\\..*" + "|dev\\.caskeleton\\.adapter\\.outbound\\.notification\\..*" + "|dev\\.caskeleton\\.adapter\\.outbound\\.persistence\\..*" + "|dev\\.caskeleton\\.adapter\\.inbound\\.graphql\\..*" + "|dev\\.caskeleton\\.messaging\\..*"; ``` and change the annotation to: ```java @ConfigurationPropertiesScan( basePackages = { "dev.caskeleton.bootstrap", "dev.caskeleton.adapter", "dev.caskeleton.application", "dev.caskeleton.domain", "dev.caskeleton.shared" }, excludeFilters = { @ComponentScan.Filter( type = FilterType.REGEX, pattern = CaSkeletonApplication.AUTO_CONFIGURED_PACKAGES) }) ``` Extend the class Javadoc with one paragraph explaining that the properties scan now carries the same exclusion, and why: a capability whose beans are gated but whose settings still bind is gated only where somebody remembered. - [ ] **Step 4: Run the test to verify it passes.** - [ ] **Step 5: Run the full app-bootstrap suite to find what the narrowing broke** Run: `cd src && ./gradlew :app-bootstrap:test --console=plain --no-daemon --continue` Expected: failures in tests that relied on a scanned bean or a scanned `@ConfigurationProperties` which the roots do not yet import — the roots arrive in Tasks 4–9. Record the full list in `docs/superpowers/plans/evidence/2026-08-15-wave1-progress.md`; each entry must be closed by a root import, never by re-widening the scan. - [ ] **Step 6: Commit** ``` git add src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java \ src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/CompositionScanNarrownessTest.java \ docs/superpowers/plans/evidence/2026-08-15-wave1-progress.md git commit -m "refactor(bootstrap): give both scans the same exclusion The component scan already excluded auto-configured packages; the configuration-properties scan did not, which is why notification settings bound themselves with the notification master off. A capability whose beans are gated but whose settings still bind is gated only where somebody remembered." ``` --- ## Task 4: The JPA activation authority and the DataSource requirement closure **Files:** - Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/DataSourceRequirement.java` - Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/PersistenceJpaRootAutoConfiguration.java` - Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/JpaOffAutoConfigurationImportFilter.java` - Create: `src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfigurationImportFilter.imports` - Modify: `src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` - Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java` - Modify: `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java` - Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/JpaMasterGateTest.java` **Interfaces:** - Consumes: `MasterSwitch.PERSISTENCE_JPA` (Task 1); `AdapterActivationInventory` (Wave 0 Task 2). - Produces: - `DataSourceRequirement.isRequired(Environment)` → `boolean`, and `DataSourceRequirement.reasons(Environment)` → `List` naming each capability that requires it. Task 10 (`CapabilityDependencyValidator`) and Task 11 (readiness) both consume it. - `PersistenceJpaRootAutoConfiguration` — the only place `ca-skeleton.persistence-jpa.enabled` is read. **Context:** This closes JPA-INT-001 and the composition half of JPA-INT-004. Three facts drive the design: 1. `JpaPlatformRuntimeAutoConfiguration` today reads `app.jpa-platform` with `matchIfMissing = true` and gates only add-on beans. That property becomes `app.jpa-platform.addons-enabled` (spec §5.1 says rename or remove; renaming keeps the add-on distinction that genuinely exists) and the *whole-adapter* decision moves to the new root. 2. `PostgreSqlPersistenceConfig` imports `PersistenceJpaConfig`, which scans entity and repository packages, and it activates when no vendor is set. It must become reachable only via the root. 3. A `DataSource` is not JPA's alone. Outbox, JDBC idempotency, the distributed lock, and the notification store all need one. So the off-filter asks `DataSourceRequirement`, not "is JPA off" — otherwise turning JPA off silently breaks a capability that was legitimately using the pool, and spec §4.3 requires that to be a named dependency error instead. - [ ] **Step 1: Write the failing test** ```java package dev.caskeleton.bootstrap.autoconfigure.persistencejpa; import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.bootstrap.activation.AdapterActivationInventory; import dev.caskeleton.bootstrap.activation.AdapterActivationInventory.Adapter; import javax.sql.DataSource; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.ApplicationContext; import org.springframework.test.context.ActiveProfiles; /** * JPA off means no pool, no entity manager, and no migration — not merely no add-on bean. * *

The switch that was supposed to control this gated three convenience beans and defaulted to on, * while the vendor configuration imported the entity and repository scan unconditionally. So a * deployment that set the switch to false still opened a connection pool, ran a migration, and * reported a database health contributor. */ @SpringBootTest( classes = dev.caskeleton.bootstrap.CaSkeletonApplication.class, webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = { "ca-skeleton.persistence-jpa.enabled=false", "ca-skeleton.persistence-mongo.enabled=false", "app.messaging.enabled=false", "ca-skeleton.notification.platform.enabled=false", "backend.graphql.enabled=false", "ca-skeleton.outbox.enabled=false", "ca-skeleton.idempotency.provider=disabled" }) @ActiveProfiles("local") class JpaMasterGateTest { @Autowired private ApplicationContext context; @Test @DisplayName("no DataSource exists when nothing requires one") void noDataSourceWhenNothingRequiresOne() { assertThat(context.getBeanNamesForType(DataSource.class)) .as("a pool with no consumer is an open connection nobody asked for") .isEmpty(); } @Test @DisplayName("the JPA inventory is empty") void jpaInventoryIsEmpty() { assertThat(AdapterActivationInventory.ownedBeanTypeNames(context, Adapter.JPA)) .as("JPA off must hold nothing:%n%s", AdapterActivationInventory.describe(context, Adapter.JPA)) .isEmpty(); } @Test @DisplayName("no Flyway migration bean is registered") void noMigrationBean() { assertThat(context.getBeanNamesForType(org.flywaydb.core.Flyway.class)) .as("migration belongs to the capability that owns the schema, not to every startup") .isEmpty(); } } ``` - [ ] **Step 2: Run to verify it fails.** Expected: all three fail — a pool, an entity manager, and a Flyway bean all exist. - [ ] **Step 3: Write `DataSourceRequirement`** ```java package dev.caskeleton.bootstrap.autoconfigure.persistencejpa; import dev.caskeleton.shared.activation.MasterSwitch; import java.util.ArrayList; import java.util.List; import org.springframework.core.env.Environment; /** * Which active capabilities need a relational connection. * *

A pool is not JPA's private property. The outbox, the JDBC idempotency store, the multi-instance * lock, and the notification store all need one, and asking only "is JPA off?" would either open a * pool nobody uses or silently break a capability that was legitimately using it. Naming the * consumers here turns that into a dependency error an operator can read. */ public final class DataSourceRequirement { private DataSourceRequirement() {} /** * Names every active capability that requires a relational connection. * * @param environment the resolved environment * @return one human-readable reason per requiring capability; empty when none does */ public static List reasons(Environment environment) { List reasons = new ArrayList<>(); if (isOn(environment, MasterSwitch.PERSISTENCE_JPA.property())) { reasons.add(MasterSwitch.PERSISTENCE_JPA.property() + "=true"); } if (isOn(environment, "ca-skeleton.outbox.enabled")) { reasons.add("ca-skeleton.outbox.enabled=true"); } if ("jdbc".equalsIgnoreCase(environment.getProperty("ca-skeleton.idempotency.provider", "disabled"))) { reasons.add("ca-skeleton.idempotency.provider=jdbc"); } if (isOn(environment, "ca-skeleton.lock.multi-instance-enabled")) { reasons.add("ca-skeleton.lock.multi-instance-enabled=true"); } if (isOn(environment, MasterSwitch.NOTIFICATION_PLATFORM.property()) && !"none".equalsIgnoreCase( environment.getProperty("ca-skeleton.notification.platform.persistence", "jpa"))) { reasons.add("ca-skeleton.notification.platform.persistence requires a relational store"); } // Fileserver's TransactionPort path is a relational consumer too. Not redesigning Fileserver // here — only naming it, because an all-off deployment that quietly opened a pool for it would // break the invariant this class exists to state. if (isOn(environment, "app.fileserver.enabled") && !"none".equalsIgnoreCase( environment.getProperty("app.fileserver.transaction-provider", "jpa"))) { reasons.add("app.fileserver.enabled=true with a relational transaction provider"); } return List.copyOf(reasons); } /** * @param environment the resolved environment * @return true when at least one active capability requires a relational connection */ public static boolean isRequired(Environment environment) { return !reasons(environment).isEmpty(); } private static boolean isOn(Environment environment, String property) { return "true".equalsIgnoreCase(environment.getProperty(property, "false")); } } ``` - [ ] **Step 4: Write the off-filter** ```java package dev.caskeleton.bootstrap.autoconfigure.persistencejpa; import java.util.Set; import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter; import org.springframework.boot.autoconfigure.AutoConfigurationMetadata; import org.springframework.context.EnvironmentAware; import org.springframework.core.env.Environment; /** * Keeps Boot's relational auto-configurations out of the candidate set while nothing needs them. * *

Conditioning only this repository's own configurations is not enough: the JPA and Flyway * starters contribute theirs through Boot's import metadata, so an ordinary * {@code @EnableAutoConfiguration} application opens a pool and runs a migration regardless of what * any project condition says. The requirement is asked as a question about capabilities rather than * about JPA, because the pool has several legitimate consumers. */ public final class JpaOffAutoConfigurationImportFilter implements AutoConfigurationImportFilter, EnvironmentAware { private static final Set RELATIONAL_AUTO_CONFIGURATIONS = Set.of( "org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration", "org.springframework.boot.jdbc.autoconfigure.JdbcTemplateAutoConfiguration", "org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration", "org.springframework.boot.jdbc.autoconfigure.health.DataSourceHealthContributorAutoConfiguration", "org.springframework.boot.jdbc.autoconfigure.metrics.DataSourcePoolMetricsAutoConfiguration", "org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration", "org.springframework.boot.data.jpa.autoconfigure.JpaRepositoriesAutoConfiguration", "org.springframework.boot.jpa.autoconfigure.JpaBaseConfiguration", "org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration"); private Environment environment; @Override public boolean[] match(String[] candidates, AutoConfigurationMetadata metadata) { boolean required = environment != null && DataSourceRequirement.isRequired(environment); boolean[] matches = new boolean[candidates.length]; for (int index = 0; index < candidates.length; index++) { matches[index] = required || candidates[index] == null || !RELATIONAL_AUTO_CONFIGURATIONS.contains(candidates[index]); } return matches; } @Override public void setEnvironment(Environment environment) { this.environment = environment; } } ``` > **Implementer note:** the Boot 4 auto-configuration class names above must be verified against the > resolved dependencies before this is considered done. Run > `cd src && ./gradlew :app-bootstrap:dependencies --configuration runtimeClasspath` and inspect the > `spring-boot-*-autoconfigure` jars' `AutoConfiguration.imports`, exactly as > `MongoOptInAutoConfigurationImportFilter` did for the Mongo names. A misspelled entry here fails > open silently — the filter simply never matches — so Step 6's test asserts on bean absence rather > than on the filter's own return value. - [ ] **Step 5: Write the root and rewire the existing configurations** Create `PersistenceJpaRootAutoConfiguration`: ```java package dev.caskeleton.bootstrap.autoconfigure.persistencejpa; import dev.caskeleton.bootstrap.autoconfigure.jpa.JpaPlatformRuntimeAutoConfiguration; import dev.caskeleton.bootstrap.autoconfigure.jpa.JpaSafetySettings; import dev.caskeleton.bootstrap.runtime.startup.MigrationStartupConfig; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Import; /** * The one place that decides whether this application has relational persistence. * *

Every JPA configuration is reached through this import rather than through the component scan, * so a bean added to any of them next month is gated without anyone remembering to repeat a * condition. That is the difference between a capability that is off and a capability whose known * beans are individually conditioned. */ @AutoConfiguration @ConditionalOnProperty(prefix = "ca-skeleton.persistence-jpa", name = "enabled", havingValue = "true") @EnableConfigurationProperties(JpaSafetySettings.class) @Import({ dev.caskeleton.adapter.outbound.persistence.config.PersistenceJpaConfig.class, JpaPlatformRuntimeAutoConfiguration.class, MigrationStartupConfig.class }) public class PersistenceJpaRootAutoConfiguration {} ``` Then: - In `JpaPlatformRuntimeAutoConfiguration`, change `@ConditionalOnProperty(prefix = "app.jpa-platform", name = "enabled", matchIfMissing = true)` to `@ConditionalOnProperty(prefix = "app.jpa-platform", name = "addons-enabled", matchIfMissing = true)` and update its Javadoc to say the whole-adapter decision now belongs to the root. Remove its `@AutoConfiguration` annotation in favour of `@Configuration(proxyBeanMethods = false)`, because it is now imported rather than auto-configured. - In `PostgreSqlPersistenceConfig`, remove the `@Import(PersistenceJpaConfig.class)` — the root now owns that import — and keep the vendor-specific beans. - Remove `dev.caskeleton.bootstrap.autoconfigure.jpa.JpaPlatformRuntimeAutoConfiguration` from `AutoConfiguration.imports` and add `dev.caskeleton.bootstrap.autoconfigure.persistencejpa.PersistenceJpaRootAutoConfiguration`. - Create `AutoConfigurationImportFilter.imports` containing `dev.caskeleton.bootstrap.autoconfigure.persistencejpa.JpaOffAutoConfigurationImportFilter`. - [ ] **Step 6: Run the test to verify it passes.** Run: `cd src && ./gradlew :app-bootstrap:test --tests '*JpaMasterGateTest*' --console=plain --no-daemon` Expected: PASS, 3 tests. - [ ] **Step 7: Run the JPA leaf's focused test and the off inventory** Run: ```bash cd src ./gradlew :adapter:outbound:persistence-jpa:test --console=plain --no-daemon ./gradlew :app-bootstrap:test --tests '*FiveAdapterOffInventoryTest*' --console=plain --no-daemon ``` Expected: the JPA leaf stays green; `FiveAdapterOffInventoryTest.jpaOffHoldsNothing` now passes — remove its `@Tag("wave0-red")` in this commit. - [ ] **Step 8: Commit** ``` git add src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/ \ src/app-bootstrap/src/main/resources/META-INF/spring/ \ src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java \ src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java \ src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/ \ src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/FiveAdapterOffInventoryTest.java git commit -m "feat(bootstrap): give JPA one activation authority (JPA-INT-001) The switch that was supposed to control JPA gated three convenience beans and defaulted to on, while the vendor config imported the entity scan unconditionally. The pool is asked for by capability rather than by JPA, because outbox, idempotency, the lock, and the notification store are all legitimate consumers and turning JPA off must name them rather than break them." ``` --- ## Task 5: Ship Mongo and give it one authority **Files:** - Create: `src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoRootAutoConfiguration.java` - Modify: `src/adapter/outbound/persistence-mongo/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` - Modify: `src/config/architecture/modules.json` - Modify: `src/app-bootstrap/build.gradle` - Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/MongoShippedAndGatedTest.java` **Interfaces:** - Consumes: `MasterSwitch.PERSISTENCE_MONGO`; `MongoOptInAutoConfigurationImportFilter` (existing, unchanged — it stays as the vendor-blocking mechanism, demoted from activation authority per MNG-INT-005). - Produces: `MongoRootAutoConfiguration`, the sole entry; registry membership for `adapter-outbound-persistence-mongo`. **Context:** MNG-INT-001 and MNG-INT-005. Today three things each behave like the Mongo master: the import filter, the component-scanned `MongoPersistenceConfig`, and the auto-imported `MongoPlatformAutoConfiguration`. After this task the import filter keeps its job (blocking Boot's vendor auto-configuration) but is no longer an authority; the root is. Registry edits, exact: - `adapter-outbound-persistence-mongo`: `allowed_dependencies` becomes `["domain-core", "application-core", "shared-contract"]`; `runtime_memberships` becomes `["app-bootstrap"]`. - `app-bootstrap.allowed_dependencies`: add `"adapter-outbound-persistence-mongo"`. **Order matters:** gating first (Steps 1–4), membership last (Step 5). Adding the dependency before the root exists ships an ungated adapter. - [ ] **Step 1: Write the failing test** ```java package dev.caskeleton.bootstrap.activation; import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.bootstrap.activation.AdapterActivationInventory.Adapter; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.ApplicationContext; import org.springframework.test.context.ActiveProfiles; /** * Mongo ships in the jar and is off because a switch says so. * *

Before this, Mongo was "off" by not being built — a state an operator cannot reverse with an * environment variable, and one that hides every gating defect because absent code holds no beans * whatever its conditions say. */ @SpringBootTest( classes = dev.caskeleton.bootstrap.CaSkeletonApplication.class, webEnvironment = SpringBootTest.WebEnvironment.NONE, properties = { "ca-skeleton.persistence-jpa.enabled=false", "ca-skeleton.persistence-mongo.enabled=false", "app.messaging.enabled=false", "ca-skeleton.notification.platform.enabled=false", "backend.graphql.enabled=false", "ca-skeleton.outbox.enabled=false", "ca-skeleton.idempotency.provider=disabled", "ca-skeleton.persistence-mongo.profiles.primary.uri=mongodb://nonexistent:27017/x" }) @ActiveProfiles("local") class MongoShippedAndGatedTest { @Autowired private ApplicationContext context; @Test @DisplayName("the Mongo facade is on the classpath") void facadeIsShipped() throws ClassNotFoundException { assertThat( Class.forName( "dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoRootAutoConfiguration")) .isNotNull(); } @Test @DisplayName("Mongo off holds no client, no template, and no repository") void mongoOffHoldsNothing() { assertThat(AdapterActivationInventory.ownedBeanTypeNames(context, Adapter.MONGO)) .as( "off with a URI present must still hold nothing; a client built from a detail setting " + "nobody enabled is a socket opened by accident:%n%s", AdapterActivationInventory.describe(context, Adapter.MONGO)) .isEmpty(); } @Test @DisplayName("no reactive Mongo inventory exists in the Stable runtime") void noReactiveInventory() { assertThat(context.getBeanDefinitionNames()) .as("reactive Mongo is out of the shipped Stable scope and must not assemble at all") .noneMatch(name -> { Class type = context.getType(name); return type != null && type.getName().contains("ReactiveMongo"); }); } } ``` - [ ] **Step 2: Run to verify it fails** (`ClassNotFoundException`). - [ ] **Step 3: Write the root** ```java package dev.caskeleton.adapter.outbound.mongo.autoconfigure; import dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig; import dev.caskeleton.adapter.outbound.mongo.MongoPersistenceProperties; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Import; /** * The one place that decides whether this application talks to MongoDB. * *

Three things behaved like the master before this: the auto-configuration import filter, a * component-scanned persistence configuration, and the platform auto-configuration — each reading * the same property and each able to assemble something the others thought was off. The filter keeps * its job of holding Boot's own Mongo auto-configurations out of the candidate set, because that * happens before any project condition is evaluated; what it no longer is, is an authority. */ @AutoConfiguration @ConditionalOnProperty(prefix = "ca-skeleton.persistence-mongo", name = "enabled", havingValue = "true") @EnableConfigurationProperties(MongoPersistenceProperties.class) @Import({MongoPersistenceConfig.class, MongoPlatformAutoConfiguration.class}) public class MongoRootAutoConfiguration {} ``` Replace the contents of the leaf's `AutoConfiguration.imports` with the single line `dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoRootAutoConfiguration`. Keep `MongoDriverObservabilityAutoConfiguration` reachable by adding it to the root's `@Import` list rather than leaving it as an independent entry. Convert `MongoPlatformAutoConfiguration` from `@AutoConfiguration` to `@Configuration(proxyBeanMethods = false)` and remove any master condition it carries — the root owns it now. - [ ] **Step 4: Verify the off contract without the dependency yet** Run: `cd src && ./gradlew :adapter:outbound:persistence-mongo:test --console=plain --no-daemon` Expected: the leaf's own suite stays green. - [ ] **Step 5: Add the registry edge and the dependency, together** In `src/config/architecture/modules.json` apply both edits described in the Context block. In `src/app-bootstrap/build.gradle`, add next to the other adapter dependencies: ```groovy // Shipped for env-switch activation (spec §1): present in the jar, off unless // APP_PERSISTENCE_MONGO_ENABLED says otherwise. An adapter that is absent cannot be turned on // by an operator, which is a different contract from one that is off. implementation project(':adapter:outbound:persistence-mongo') ``` - [ ] **Step 6: Run the gate and the test** Run: ```bash cd src ./gradlew verifyCleanArchitectureDependencies --console=plain --no-daemon ./gradlew :app-bootstrap:runtimeClasspathManifest --console=plain --no-daemon ./gradlew :app-bootstrap:test --tests '*MongoShippedAndGatedTest*' \ --tests '*RuntimeMembershipClasspathAgreementTest*' \ --tests '*ShippedRuntimeFacadePresenceTest*' --console=plain --no-daemon ``` Expected: all pass. Remove `@Tag("wave0-red")` from `ShippedRuntimeFacadePresenceTest.mongoFacadeIsShipped` in this commit. - [ ] **Step 7: Commit** ``` git add src/adapter/outbound/persistence-mongo/ src/config/architecture/modules.json \ src/app-bootstrap/build.gradle src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/ git commit -m "feat(mongo): ship the adapter and give it one authority (MNG-INT-001/005) Mongo was off by not being built, which an operator cannot reverse with an environment variable and which hides every gating defect, because absent code holds no beans whatever its conditions say. The import filter keeps blocking Boot's vendor auto-configurations — that has to happen before any project condition — but it is no longer an authority." ``` --- ## Task 6: Ship GraphQL and gate it Same shape as Task 5. Differences called out below; every step otherwise mirrors it. **Files:** - Create: `src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlRootAutoConfiguration.java` - Create: `src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlOffAutoConfigurationImportFilter.java` - Create: `src/adapter/inbound/graphql/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfigurationImportFilter.imports` - Modify: `src/adapter/inbound/graphql/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` - Modify: `src/config/architecture/modules.json`, `src/app-bootstrap/build.gradle` - Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/GraphQlShippedAndGatedTest.java` **Interfaces:** - Consumes: `MasterSwitch.GRAPHQL`. - Produces: `GraphQlRootAutoConfiguration`; registry membership for `adapter-inbound-graphql`. **Context (GQL-INT-001):** GraphQL differs from Mongo in two ways that matter. 1. It is an **inbound** adapter, so "off" additionally means no route and no schema — its off test must use `WebEnvironment.RANDOM_PORT` and assert that `/graphql` returns 404, not merely that beans are absent. A bean-only assertion would pass while Spring GraphQL's own auto-configuration published the endpoint. 2. `GraphQlPlatformAutoConfiguration` has **no** master condition at all today (spec §4.2), so putting it on the classpath without this task's root would activate it. Registry edits: `adapter-inbound-graphql.runtime_memberships` becomes `["app-bootstrap"]`; `app-bootstrap.allowed_dependencies` gains `"adapter-inbound-graphql"`. `GraphQlOffAutoConfigurationImportFilter` excludes, subject to the same verify-the-names note as Task 4 Step 4: `org.springframework.boot.graphql.autoconfigure.GraphQlAutoConfiguration`, `org.springframework.boot.graphql.autoconfigure.servlet.GraphQlWebMvcAutoConfiguration`, `org.springframework.boot.graphql.autoconfigure.reactive.GraphQlWebFluxAutoConfiguration`, `org.springframework.boot.graphql.autoconfigure.rsocket.GraphQlRSocketAutoConfiguration`, `org.springframework.boot.graphql.autoconfigure.security.GraphQlWebMvcSecurityAutoConfiguration`. - [ ] **Step 1: Write the failing test** ```java package dev.caskeleton.bootstrap.activation; import static org.assertj.core.api.Assertions.assertThat; import dev.caskeleton.bootstrap.activation.AdapterActivationInventory.Adapter; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.context.ApplicationContext; import org.springframework.http.HttpStatus; import org.springframework.test.context.ActiveProfiles; /** * GraphQL ships in the jar and exposes nothing until it is switched on. * *

Asserted through the HTTP port rather than through the bean registry, because the failure this * guards against is exactly the one a bean assertion cannot see: Spring GraphQL's own * auto-configuration publishing {@code /graphql} from the classpath, with no project bean involved. */ @SpringBootTest( classes = dev.caskeleton.bootstrap.CaSkeletonApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { "ca-skeleton.persistence-jpa.enabled=false", "ca-skeleton.persistence-mongo.enabled=false", "app.messaging.enabled=false", "ca-skeleton.notification.platform.enabled=false", "backend.graphql.enabled=false", "ca-skeleton.outbox.enabled=false", "ca-skeleton.idempotency.provider=disabled" }) @ActiveProfiles("local") class GraphQlShippedAndGatedTest { @Autowired private ApplicationContext context; @Autowired private TestRestTemplate restTemplate; @Test @DisplayName("the GraphQL facade is on the classpath") void facadeIsShipped() throws ClassNotFoundException { assertThat( Class.forName( "dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRootAutoConfiguration")) .isNotNull(); } @Test @DisplayName("GraphQL off publishes no endpoint") void graphQlOffPublishesNoEndpoint() { assertThat(restTemplate.postForEntity("/graphql", "{\"query\":\"{__typename}\"}", String.class).getStatusCode()) .as("an unswitched transport must not answer; a bean assertion cannot see a route Boot published") .isEqualTo(HttpStatus.NOT_FOUND); } @Test @DisplayName("GraphQL off holds no schema and no execution bean") void graphQlOffHoldsNothing() { assertThat(AdapterActivationInventory.ownedBeanTypeNames(context, Adapter.GRAPHQL)) .as("%s", AdapterActivationInventory.describe(context, Adapter.GRAPHQL)) .isEmpty(); } } ``` - [ ] **Step 2: Run to verify it fails.** - [ ] **Step 3: Write `GraphQlRootAutoConfiguration`** — `@AutoConfiguration`, `@ConditionalOnProperty(prefix = "backend.graphql", name = "enabled", havingValue = "true")`, `@EnableConfigurationProperties(GraphQlPlatformProperties.class)`, `@Import(GraphQlPlatformAutoConfiguration.class)`. Convert `GraphQlPlatformAutoConfiguration` to `@Configuration(proxyBeanMethods = false)`. - [ ] **Step 4: Write the off-filter and register it** in the new `AutoConfigurationImportFilter.imports`. Replace `AutoConfiguration.imports` with the root. - [ ] **Step 5: Add the registry edge and the `implementation project(':adapter:inbound:graphql')` dependency together**, with the same comment shape as Task 5. - [ ] **Step 6: Run** `./gradlew verifyCleanArchitectureDependencies :app-bootstrap:runtimeClasspathManifest` then `./gradlew :app-bootstrap:test --tests '*GraphQlShippedAndGatedTest*' --tests '*ShippedRuntimeFacadePresenceTest*' --console=plain --no-daemon`. Expected: PASS; remove `@Tag("wave0-red")` from `graphQlFacadeIsShipped`. - [ ] **Step 7: Commit** ``` git commit -m "feat(graphql): ship the adapter and gate it (GQL-INT-001) The platform auto-configuration had no master condition at all, so putting it on the classpath would have activated it. Asserted through the HTTP port rather than the bean registry, because the failure this guards against is Spring GraphQL publishing /graphql from the classpath with no project bean involved — which no bean assertion can see." ``` --- ## Task 7: Gate the legacy messaging bridge **Files:** - Create: `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingBridgeRootAutoConfiguration.java` - Create: `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingOffAutoConfigurationImportFilter.java` - Create both `META-INF/spring/...AutoConfiguration.imports` and `...AutoConfigurationImportFilter.imports` in the messaging adapter - Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/MessagingBridgeGateTest.java` **Interfaces:** - Consumes: `MasterSwitch.MESSAGING`. - Produces: `MessagingBridgeRootAutoConfiguration`, owning `app.messaging.enabled`. **Context (MSG-INT-001, partial):** The messaging *platform* leaves stay build-only in this wave — Wave 2 owns MSG-INT-002/003. What Wave 1 fixes is that `app.messaging.broker` being blank is currently the de-facto switch, and that the bridge's beans are component-scanned. After Task 3's scan narrowing they are no longer discoverable, so this task supplies the root that imports them. `APP_MESSAGING_BROKER` is demoted here from activation to selection: it is read only when `app.messaging.enabled=true`, and blank-while-on becomes a startup error rather than a silent off. The `DisabledMessagePublisher` sentinel already exists (`dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher`, asserted by `DisabledAdapterSentinelTest`). Off-invariant item 9 requires the **composition root** to supply it, not the adapter — so it is registered by a `@Bean` in `app-bootstrap` under `@ConditionalOnMissingBean(MessagePublisher.class)`, and the adapter's own registration of it is removed. - [ ] **Step 1: Write the failing test** asserting, under all-off: zero messaging-owned beans, zero Kafka/Rabbit client threads, that `MessagePublisher` resolves to `DisabledMessagePublisher`, and that calling it throws `AdapterDisabledException` with `adapterName == "messaging"`. - [ ] **Step 2: Run to verify it fails.** - [ ] **Step 3: Write the root**, `@ConditionalOnProperty(prefix = "app.messaging", name = "enabled", havingValue = "true")`, `@EnableConfigurationProperties(MessagingSettings.class)`, importing the bridge's existing configurations. - [ ] **Step 4: Write the off-filter** excluding `org.springframework.boot.kafka.autoconfigure.KafkaAutoConfiguration` and `org.springframework.boot.amqp.autoconfigure.RabbitAutoConfiguration` (verify names per Task 4 Step 4's note). - [ ] **Step 5: Move the disabled sentinel registration into `app-bootstrap`.** - [ ] **Step 6: Run** `./gradlew :adapter:outbound:messaging:test :app-bootstrap:test --tests '*MessagingBridgeGateTest*' --tests '*FiveAdapterOffInventoryTest*' --tests '*DisabledAdapterSentinelTest*' --console=plain --no-daemon`. Expected: PASS; remove `@Tag("wave0-red")` from `messagingOffHoldsNothing`. - [ ] **Step 7: Commit.** --- ## Task 8: Gate Notification and scope its secrets to the active graph **Files:** - Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationRootAutoConfiguration.java` - Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationPlatformRuntimeConfig.java` - Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationPlatformSecretsConfig.java` - Modify: `src/app-bootstrap/src/main/resources/META-INF/spring/...AutoConfiguration.imports` - Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/notification/NotificationMasterGateTest.java` **Interfaces:** - Consumes: `MasterSwitch.NOTIFICATION_PLATFORM`. - Produces: `NotificationRootAutoConfiguration`. Wave 2's NTF-INT-001/002/003/006 build on it. **Context (NTF-INT-005):** The root must import what today relies on the broad scan: auto-configuration, secrets, and registries — `NotificationPlatformRuntimeConfig` currently imports only codec/observability/provider/policy/dispatch/worker. The owner graph, from spec §6.4, is the acceptance shape: | capability | gate | | --- | --- | | outbound codec, secrets, registries, provider, policy, dispatch, worker | notification root + master | | callback MVC/WebFlux/security | inbound web root + notification master + `callbacks.enabled` | | notification JPA facade/store | notification master + JPA master + selected persistence mode | | Boot/vendor auto-configuration | only where the active graph needs it | The secrets half: `NotificationPlatformSecretsConfig` today demands one keyring holding several providers' keys. It must compute the required set from the *selected* provider capability graph, so an unused provider's blank secret cannot block startup and a needed one's absence cannot hide until first send. The minimum graph per state is spec §6.4's table; implement `SERVING`/callbacks-off and `SERVING`/callbacks-on now, and reject `INGEST_ONLY + callbacks=true` as an invalid combination. `INGEST_ONLY`'s route-metadata requirement is Wave 2 (NTF-INT-006). - [ ] **Step 1–7:** same TDD cycle as Task 4. The test asserts, under master-off: zero notification-owned beans, `NotificationPlatformSettings` not bound, zero worker threads, and that a malformed `ca-skeleton.notification.platform.*` value does not block startup. Then, under master-on + `SERVING` + callbacks-off: no callback signing/fingerprint/payload-protection secret is required, and no unselected provider's secret is required. - [ ] Remove `@Tag("wave0-red")` from `FiveAdapterOffInventoryTest.notificationOffHoldsNothing`. --- ## Task 9: Move migration and outbox under capability roots **Files:** - Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupConfig.java` - Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java` - Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxCapabilityRootAutoConfiguration.java` - Modify: `src/app-bootstrap/src/main/resources/application.yml` - Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxCapabilityGateTest.java` **Interfaces:** - Consumes: `DataSourceRequirement` (Task 4), `MasterSwitch.MESSAGING` (Task 1). - Produces: `ca-skeleton.outbox.enabled` as the capability root property; `relay-enabled` demoted to "run the scheduler", meaningful only when the capability is on. **Context (JPA-INT-004, MSG-INT-001):** Verified at HEAD, `MigrationStartupConfig` unconditionally creates a `FlywayMigrationStrategy`, a datasource env validator, and a prod Flyway validator; and `OutboxConfig` assembles settings, a validator, an RNG, a leader token, and `OutboxMetrics(OutboxStorePort)` even with `relay-enabled=false`. The two YAML changes this task makes: - `ca-skeleton.outbox.relay-enabled: true` → add `ca-skeleton.outbox.enabled: false` above it and set `relay-enabled: false`. Document in the comment that `relay-enabled` now only starts the scheduler and that the capability switch is the one above it. - `ca-skeleton.idempotency.provider: ${APP_IDEMPOTENCY_PROVIDER:jdbc}` → default `disabled`. - [ ] **Step 1–7:** TDD cycle. The test asserts: with `outbox.enabled=false`, no outbox bean, no Flyway strategy, and no migration validator exists; with `outbox.enabled=true` and messaging off, startup fails naming `app.messaging.enabled`; with `outbox.enabled=true`, messaging on, and JPA off, startup fails naming `ca-skeleton.persistence-jpa.enabled`. - [ ] Remove `@Tag("wave0-red")` from `DefaultProfileBootCharacterizationTest.localProfileStartsWithShippedDefaults`. --- ## Task 10: The capability dependency validator **Files:** - Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/CapabilityDependencyValidator.java` - Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/CapabilityDependencyValidatorTest.java` **Interfaces:** - Consumes: `MasterSwitch`, `DataSourceRequirement`. - Produces: `CapabilityDependencyValidator`, an `InitializingBean` registered by `AdapterActivationAutoConfiguration` (Task 11). **Context:** Spec §5.3's table is the specification. Each row becomes one rule, and each rule's failure message names the **exact** switch or provider that is missing — spec §12.3's `relay on, dependency 누락` matrix row requires the name, not a generic "misconfiguration". Rules to implement, one test case each: | rule | failure names | | --- | --- | | outbox on ⇒ JPA on **and** messaging on **and** broker non-blank | whichever is missing | | `idempotency.provider=jdbc` ⇒ JPA on | `ca-skeleton.persistence-jpa.enabled` | | `lock.multi-instance-enabled` ⇒ JPA on | `ca-skeleton.persistence-jpa.enabled` | | notification on with a relational store ⇒ JPA on | `ca-skeleton.persistence-jpa.enabled` | | messaging on ⇒ `app.messaging.broker` non-blank | `app.messaging.broker` | | Mongo on ⇒ `active-profile` non-blank and present in `profiles` | `ca-skeleton.persistence-mongo.active-profile` | | GraphQL on ⇒ `deployment-mode` set and permitted for the active runtime | `backend.graphql.deployment-mode` | | two implementations of one application port both active | both bean names, refusing to pick | - [ ] **Steps 1–5:** TDD cycle with one `@Test` per rule. --- ## Task 11: Derive readiness membership and publish the activation report **Files:** - Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationReport.java` - Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationEndpoint.java` - Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationAutoConfiguration.java` - Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/ReadinessGroupContributor.java` - Modify: `src/app-bootstrap/src/main/resources/application.yml` - Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/ReadinessGroupDerivationTest.java` **Interfaces:** - Produces: - `record AdapterActivationReport(String activeProfile, Map switches, List requiredDependencies)` - actuator endpoint id `adapteractivation`, read operation returning the report Wave 3's `run-compose-runtime-smoke.sh` asserts the rendered profile against this endpoint, and Wave 6's activation matrix reads it as evidence. Do not rename the endpoint id. **Context (spec §4.3):** `management.endpoint.health.group.readiness.include: readinessState,db` is static while `validate-group-membership: true`. With JPA off there is no `db` contributor and startup fails on a name that cannot resolve. The repository already solves this shape once — `RedisReadinessGroupPostProcessor` appends `redisRequired` from the same predicate that creates the bean, and `application.yml:248-278` explains at length why naming a conditional contributor statically is wrong. `ReadinessGroupContributor` follows that precedent for `db`. The YAML change: `readiness.include` becomes `readinessState`, and `db` is appended by the contributor when `DataSourceRequirement.isRequired` is true. A misspelled name must still fail startup — so the contributor appends a name it has verified exists, never a literal. - [ ] **Steps 1–7:** TDD cycle. Cases: all-off ⇒ readiness group is exactly `readinessState` and startup succeeds; JPA on ⇒ group contains `db`; a deliberately misspelled static entry ⇒ startup still fails. --- ## Task 12: Register the env keys and align the YAML defaults **Files:** - Modify: `docs/registries/env-keys.yaml` - Modify: `src/app-bootstrap/src/main/resources/application.yml` - Modify: `src/app-bootstrap/src/main/resources/application-local.yml` - Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/MasterSwitchRegistryContractTest.java` **Interfaces:** - Consumes: `MasterSwitch` (Task 1). - Produces: seven registry rows (five masters + two subordinate selectors) and the YAML defaults. **Context:** `docs/registries/env-keys.yaml` is the SSOT for name, type, default, required-when, classification, restart policy, and required tests. Read an existing row before writing new ones and match its field set exactly — do not invent fields. Rows to add: the five masters (`type: boolean`, `default: false`, `classification: public`), plus `APP_PERSISTENCE_MONGO_ACTIVE_PROFILE` (`required-when: APP_PERSISTENCE_MONGO_ENABLED=true`) and `APP_GRAPHQL_DEPLOYMENT_MODE` (enum, `required-when: APP_GRAPHQL_ENABLED=true`). Rows to demote from activation to selection, keeping the key but changing its documented role: `APP_MESSAGING_BROKER`, `APP_NOTIFICATION_SLACK_PROVIDER`, `APP_NOTIFICATION_EMAIL_PROVIDER`. The contract test asserts, for each `MasterSwitch`: a registry row exists under its `environmentVariable()`; its default is exactly `false`; `application.yml` binds the canonical property to that env var with a `false` inline default; and — the negative assertion spec §6.4 requires — no demoted key is documented as an activation switch. - [ ] **Steps 1–6:** TDD cycle, ending with `./gradlew verifyEnvKeys --console=plain --no-daemon`. --- ## Task 13: Replace the membership gate with the resolved-closure gate **Files:** - Modify: `src/build.gradle` - Test: reuse `RuntimeMembershipClasspathAgreementTest` (Wave 0 Task 8) **Context (MSG-INT-002):** The existing `verifyRuntimeModuleMembership` compares direct project dependencies. Replace its comparison input with `app-bootstrap/build/architecture/runtime-project-closure.txt` produced by `:app-bootstrap:runtimeClasspathManifest`, and make the verification task depend on it. Keep the task name — CI and `AGENTS.md` reference it. - [ ] **Steps 1–5:** modify, run `./gradlew verifyRuntimeModuleMembership verifyCleanArchitectureDependencies --console=plain --no-daemon`, confirm both green, commit. --- ## Wave 1 Exit Criteria - [ ] `./gradlew :app-bootstrap:test --tests '*FiveAdapterOffInventoryTest*' --console=plain --no-daemon` — all five cases green, no `wave0-red` tag remaining on the class. - [ ] `./gradlew :app-bootstrap:test --tests '*ShippedRuntimeFacadePresenceTest*'` — Mongo and GraphQL green (messaging platform stays red; Wave 2 owns it). - [ ] All-off boots on each environment with no external infrastructure: ```bash cd src for p in local dev prod; do SPRING_PROFILES_ACTIVE=$p ./gradlew :app-bootstrap:bootRun --console=plain \ -Dspring-boot.run.arguments="--ca-skeleton.persistence-jpa.enabled=false --ca-skeleton.persistence-mongo.enabled=false --app.messaging.enabled=false --ca-skeleton.notification.platform.enabled=false --backend.graphql.enabled=false --ca-skeleton.outbox.enabled=false --ca-skeleton.idempotency.provider=disabled" done ``` Each must reach a started context and answer `/actuator/health` with 200. - [ ] `./gradlew verifyCleanArchitectureDependencies verifyRuntimeModuleMembership verifyEnvKeys --console=plain --no-daemon` — green. - [ ] `./gradlew wave0RedReport --console=plain --no-daemon` — the remaining red set is exactly the Wave 2/3/4 entries from the Wave 0 exit table. - [ ] `./gradlew :app-bootstrap:test --console=plain --no-daemon` — green, with any test broken by Task 3's scan narrowing fixed by a root import rather than by re-widening a scan. ## What Wave 1 explicitly does not do - No messaging platform runtime membership (Wave 2, MSG-INT-002/003). - No production provider assembler for Notification (Wave 2, NTF-INT-001). - No Mongo client/profile/secret wiring (Wave 2, MNG-INT-002/003/004). - No GraphQL deployment-mode collapse or endpoint pipeline (Wave 2, GQL-INT-002/003/004). - No secret-scanner fix (Wave 2, MSG-INT-005). - No `SPRING_PROFILES_ACTIVE` fail-closed change, no `.env` split, no Compose work (Wave 3). - No warning removal (Wave 4) and no build-logic extraction (Wave 5).