83 KiB
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. Read2026-08-15-five-adapter-runtime-remediation-index.mdfirst — its Global Constraints are implicitly part of every task here. Entry criterion: Wave 0 complete, with the red set recorded indocs/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-bootstrapdependency. 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.gradledependency and itsShippedRuntimeFacadePresenceTestcase. A registry entry without the dependency, or the reverse, failsverifyCleanArchitectureDependencies. - 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.
verifyEnvKeysmust pass after every task that touchesdocs/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 }withString property(),String environmentVariable(),List<String> legacyProperties()sealed interface MasterSwitchParseResult permits Off, On, Invalid, Ambiguous, LegacyOnlystatic MasterSwitchParseResult MasterSwitchParser.parse(MasterSwitch, String canonicalRaw, Map<String,String> legacyRaw)
Task 2 consumes
parse. Tasks 4–9 consumeMasterSwitch.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-modein Wave 2 E1), not to the master. Onlyapp.jpa-platform.enabledis a genuine former master alias, so it is the only entry inlegacyProperties().Also note:
MasterSwitchcannot hold aListinstance field — Error Prone'sImmutableEnumCheckerrejects it and this repository compiles with-Werror. Keep the two scalar names as fields and put the superseded names in a staticEnumMap.
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,MasterSwitchParseResultfrom Task 1. - Produces:
MasterSwitchEnvironmentPostProcessor implements EnvironmentPostProcessor. Tasks 4–9's conditions may then assume the property, when present, is exactlytrueorfalse.
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-daemonExpected: FAIL — no post-processor, so a typo binds asfalseand 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_PACKAGESconstant, extended to cover the five adapters. Tasks 4–9 depend on this: without it, their root's@Importis 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.autoconfigureplus 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 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, andDataSourceRequirement.reasons(Environment)→List<String>naming each capability that requires it. Task 10 (CapabilityDependencyValidator) and Task 11 (readiness) both consume it.PersistenceJpaRootAutoConfiguration— the only placeca-skeleton.persistence-jpa.enabledis read.
Context: This closes JPA-INT-001 and the composition half of JPA-INT-004. Three facts drive the design:
JpaPlatformRuntimeAutoConfigurationtoday readsapp.jpa-platformwithmatchIfMissing = trueand gates only add-on beans. That property becomesapp.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.PostgreSqlPersistenceConfigimportsPersistenceJpaConfig, which scans entity and repository packages, and it activates when no vendor is set. It must become reachable only via the root.- A
DataSourceis not JPA's alone. Outbox, JDBC idempotency, the distributed lock, and the notification store all need one. So the off-filter asksDataSourceRequirement, 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 runtimeClasspathand inspect thespring-boot-*-autoconfigurejars'AutoConfiguration.imports, exactly asMongoOptInAutoConfigurationImportFilterdid 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@AutoConfigurationannotation 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.JpaPlatformRuntimeAutoConfigurationfromAutoConfiguration.importsand adddev.caskeleton.bootstrap.autoconfigure.persistencejpa.PersistenceJpaRootAutoConfiguration. -
Create
AutoConfigurationImportFilter.importscontainingdev.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-daemonExpected: 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 foradapter-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_dependenciesbecomes["domain-core", "application-core", "shared-contract"];runtime_membershipsbecomes["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
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 foradapter-inbound-graphql.
Context (GQL-INT-001): GraphQL differs from Mongo in two ways that matter.
- It is an inbound adapter, so "off" additionally means no route and no schema — its off test
must use
WebEnvironment.RANDOM_PORTand assert that/graphqlreturns 404, not merely that beans are absent. A bean-only assertion would pass while Spring GraphQL's own auto-configuration published the endpoint. GraphQlPlatformAutoConfigurationhas 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). ConvertGraphQlPlatformAutoConfigurationto@Configuration(proxyBeanMethods = false). - Step 4: Write the off-filter and register it in the new
AutoConfigurationImportFilter.imports. ReplaceAutoConfiguration.importswith 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:runtimeClasspathManifestthen./gradlew :app-bootstrap:test --tests '*GraphQlShippedAndGatedTest*' --tests '*ShippedRuntimeFacadePresenceTest*' --console=plain --no-daemon. Expected: PASS; remove@Tag("wave0-red")fromgraphQlFacadeIsShipped. - 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.importsand...AutoConfigurationImportFilter.importsin the messaging adapter - Test:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/MessagingBridgeGateTest.java
Interfaces:
- Consumes:
MasterSwitch.MESSAGING. - Produces:
MessagingBridgeRootAutoConfiguration, owningapp.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
MessagePublisherresolves toDisabledMessagePublisher, and that calling it throwsAdapterDisabledExceptionwithadapterName == "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.KafkaAutoConfigurationandorg.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")frommessagingOffHoldsNothing. - 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,
NotificationPlatformSettingsnot bound, zero worker threads, and that a malformedca-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")fromFiveAdapterOffInventoryTest.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.enabledas the capability root property;relay-enableddemoted 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→ addca-skeleton.outbox.enabled: falseabove it and setrelay-enabled: false. Document in the comment thatrelay-enablednow only starts the scheduler and that the capability switch is the one above it. -
ca-skeleton.idempotency.provider: ${APP_IDEMPOTENCY_PROVIDER:jdbc}→ defaultdisabled. -
Step 1–7: TDD cycle. The test asserts: with
outbox.enabled=false, no outbox bean, no Flyway strategy, and no migration validator exists; withoutbox.enabled=trueand messaging off, startup fails namingapp.messaging.enabled; withoutbox.enabled=true, messaging on, and JPA off, startup fails namingca-skeleton.persistence-jpa.enabled. -
Remove
@Tag("wave0-red")fromDefaultProfileBootCharacterizationTest.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, anInitializingBeanregistered byAdapterActivationAutoConfiguration(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
@Testper 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.shasserts 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
readinessStateand startup succeeds; JPA on ⇒ group containsdb; 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, nowave0-redtag 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" doneEach must reach a started context and answer/actuator/healthwith 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_ACTIVEfail-closed change, no.envsplit, no Compose work (Wave 3). - No warning removal (Wave 4) and no build-logic extraction (Wave 5).