Files
clean-architecture-backend-…/docs/superpowers/plans/2026-08-15-wave1-activation-ssot.md
T

83 KiB
Raw Blame History

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 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 (§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<String> legacyProperties()
    • sealed interface MasterSwitchParseResult permits Off, On, Invalid, Ambiguous, LegacyOnly
    • static MasterSwitchParseResult MasterSwitchParser.parse(MasterSwitch, String canonicalRaw, Map<String,String> legacyRaw)

    Task 2 consumes parse. Tasks 49 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:

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.
 *
 * <p>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:

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.
 *
 * <p>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<String> legacyProperties;

  MasterSwitch(String property, String environmentVariable, List<String> 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<String> legacyProperties() {
    return legacyProperties;
  }
}

MasterSwitchParseResult.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:

package dev.caskeleton.shared.activation;

import java.util.Locale;
import java.util.Map;

/**
 * Reads one master switch, strictly.
 *
 * <p>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<String, String> 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 49'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
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.
 *
 * <p>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

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.
 *
 * <p>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<String, String> 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):

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 49 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
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.
 *
 * <p>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:

  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:

@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 49. 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<String> 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
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.
 *
 * <p>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

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.
 *
 * <p>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<String> reasons(Environment environment) {
    List<String> 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
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.
 *
 * <p>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<String> 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:

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.
 *
 * <p>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:

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 14), membership last (Step 5). Adding the dependency before the root exists ships an ungated adapter.

  • Step 1: Write the failing test
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.
 *
 * <p>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

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.
 *
 * <p>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:

    // 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:

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
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.
 *
 * <p>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 17: 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 17: 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 15: 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<String, Boolean> switches, List<String> 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 17: 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 16: 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 15: 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).