- CI 단계 분리 계획 추가 (docs/superpowers/plans/2026-09-16-ci-stage-separation.md). 빌드·CI 레이어 전수 리뷰 133건의 결론과 Track A/B/C 작업 순서를 담는다. - public-path 보안 기준선을 실제 배포 기본값(/v1/healthcheck)으로 재생성. 이전 값은 gitignore 된 src/.env 에서 유래해 재현이 불가능했다. - 진행 중이던 ADR·리뷰·테스트 전략 문서 반영, 대체된 grpc 계획 문서 제거. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1681 lines
83 KiB
Markdown
1681 lines
83 KiB
Markdown
# Wave 1 — Activation SSOT and Classpath Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
|
||
> (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||
> **Read [`2026-08-15-five-adapter-runtime-remediation-index.md`](2026-08-15-five-adapter-runtime-remediation-index.md)
|
||
> first** — its Global Constraints are implicitly part of every task here.
|
||
> **Entry criterion:** Wave 0 complete, with the red set recorded in
|
||
> `docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md`.
|
||
|
||
**Goal:** Make one activation authority per adapter real: five canonical master switches defaulting
|
||
to `false`, all five facades on one runtime classpath, structural gating that makes "off" a
|
||
structural fact, and dependency-closure validators that fail closed at startup — so that an all-off
|
||
`local`, `dev`, and `prod` boot succeeds with no external infrastructure.
|
||
|
||
**Architecture:** Each adapter gets exactly one root `@AutoConfiguration` registered in
|
||
`AutoConfiguration.imports`. That root owns the master condition and `@EnableConfigurationProperties`
|
||
for the adapter's detail settings; every child configuration is reached only by the root's
|
||
`@Import`. The composition root's component scan and `@ConfigurationPropertiesScan` are narrowed so
|
||
no leaf stereotype or `@ConfigurationProperties` is discoverable outside its root. Vendor Boot
|
||
auto-configuration is excluded in the off state by one `AutoConfigurationImportFilter` per adapter,
|
||
following the mechanism `MongoOptInAutoConfigurationImportFilter` already establishes. A single
|
||
`MasterSwitchEnvironmentPostProcessor` parses the five scalars strictly *before* any detail binding
|
||
and rejects typos, ambiguity, and legacy keys. Subordinate capabilities (outbox relay, JDBC
|
||
idempotency, distributed lock, notification store, DB readiness, migration) are moved under
|
||
capability roots whose conditions are computed from the same dependency closure.
|
||
|
||
**Tech Stack:** Spring Boot 4.0.0 auto-configuration (`@AutoConfiguration`,
|
||
`AutoConfigurationImportFilter`, `EnvironmentPostProcessor`), Gradle 9 project dependencies, the
|
||
architecture registry, JUnit 5 + `ApplicationContextRunner` + `@SpringBootTest`.
|
||
|
||
**Spec:** [`2026-08-15-five-adapter-runtime-remediation-review-design.md`](../specs/2026-08-15-five-adapter-runtime-remediation-review-design.md)
|
||
(§4, §5, §6.1 JPA-INT-001/004, §6.2 MNG-INT-001/005, §6.3 MSG-INT-001, §6.4 NTF-INT-005, §6.5
|
||
GQL-INT-001, §11 Wave 1)
|
||
|
||
---
|
||
|
||
## Global Constraints
|
||
|
||
Inherited in full from the index. Wave 1 adds:
|
||
|
||
- **Wave 1 changes activation, never behaviour on the on-path.** If a fix requires changing what an
|
||
adapter *does* once enabled, it belongs to Wave 2. The line: Wave 1 may decide whether a bean
|
||
exists; Wave 2 decides what it does.
|
||
- **No adapter may be given runtime membership before it can be off.** For each adapter the order is
|
||
strictly: structural gating first, off-invariant test green, *then* the registry edge and the
|
||
`app-bootstrap` dependency. Adding the dependency first ships an ungated adapter, which is exactly
|
||
the state spec §4.2 describes.
|
||
- Every registry edit is one change unit with its matching `build.gradle` dependency and its
|
||
`ShippedRuntimeFacadePresenceTest` case. A registry entry without the dependency, or the reverse,
|
||
fails `verifyCleanArchitectureDependencies`.
|
||
- Messaging platform leaves get **no** runtime membership in this wave. Wave 1 fixes only the legacy
|
||
bridge's gating; MSG-INT-002/003 (real production bridge and starter membership) are Wave 2.
|
||
- `verifyEnvKeys` must pass after every task that touches `docs/registries/env-keys.yaml`.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### Created
|
||
|
||
| File | Responsibility |
|
||
| --- | --- |
|
||
| `src/shared-contract/src/main/java/dev/caskeleton/shared/activation/MasterSwitch.java` | The five switches as an enum: canonical property, canonical env, legacy keys it supersedes. One place that knows the names. |
|
||
| `src/shared-contract/src/main/java/dev/caskeleton/shared/activation/MasterSwitchParseResult.java` | Sealed result of parsing one scalar: `Off`, `On`, `Invalid`, `Ambiguous`, `LegacyOnly`. |
|
||
| `src/shared-contract/src/main/java/dev/caskeleton/shared/activation/MasterSwitchParser.java` | Pure parser. No Spring types, so it unit-tests without a context. |
|
||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/MasterSwitchEnvironmentPostProcessor.java` | Runs the parser over the real `Environment` before any detail binding; throws on anything but `Off`/`On`. |
|
||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationReport.java` | The resolved on/off state of all five switches, plus the profile. Consumed by the actuator endpoint and by Wave 3's Compose evidence. |
|
||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationEndpoint.java` | `@ReadOperation` actuator endpoint serving the report. |
|
||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationAutoConfiguration.java` | Registers the report and endpoint. Unconditional — the report must exist precisely when everything is off. |
|
||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/PersistenceJpaRootAutoConfiguration.java` | The single JPA activation authority. Owns `ca-skeleton.persistence-jpa.enabled`; imports the existing JPA configurations. |
|
||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/JpaOffAutoConfigurationImportFilter.java` | Excludes Boot's JPA/Hibernate/Hikari/Flyway/DataSource auto-configurations while JPA is off **and** no other capability requires a `DataSource`. |
|
||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/DataSourceRequirement.java` | Computes whether any active capability needs a `DataSource`. The one place the JPA/outbox/idempotency/lock/notification-store closure is expressed. |
|
||
| `src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoRootAutoConfiguration.java` | The single Mongo activation authority; imports persistence + platform children. |
|
||
| `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingBridgeRootAutoConfiguration.java` | The single legacy-bridge activation authority. Owns `app.messaging.enabled`. |
|
||
| `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/autoconfigure/MessagingOffAutoConfigurationImportFilter.java` | Excludes Boot Kafka/AMQP auto-configuration while messaging is off. |
|
||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/notification/NotificationRootAutoConfiguration.java` | The single Notification activation authority; imports runtime + secrets + registries + callbacks. |
|
||
| `src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlRootAutoConfiguration.java` | The single GraphQL activation authority. |
|
||
| `src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlOffAutoConfigurationImportFilter.java` | Excludes Boot GraphQL auto-configuration while GraphQL is off. |
|
||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/CapabilityDependencyValidator.java` | Startup validator for the on-path dependency closure in spec §5.3. Names the exact missing switch. |
|
||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/ReadinessGroupContributor.java` | Derives readiness group membership from active capabilities instead of a static `db`. |
|
||
|
||
### Modified
|
||
|
||
| File | Change |
|
||
| --- | --- |
|
||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java` | Narrow `@ConfigurationPropertiesScan` with the same exclusion the component scan already has, and extend `AUTO_CONFIGURED_PACKAGES` to cover the five adapters' packages. |
|
||
| `src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | Add the JPA, Notification, and activation roots. |
|
||
| `src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfigurationImportFilter.imports` | **Create.** Register the JPA/messaging/GraphQL off-filters. |
|
||
| `src/adapter/outbound/persistence-mongo/src/main/resources/META-INF/spring/...AutoConfiguration.imports` | Replace the two entries with the single `MongoRootAutoConfiguration`. |
|
||
| `src/adapter/inbound/graphql/src/main/resources/META-INF/spring/...AutoConfiguration.imports` | Replace with `GraphQlRootAutoConfiguration`. |
|
||
| `src/adapter/outbound/messaging/src/main/resources/META-INF/spring/...AutoConfiguration.imports` | **Create** with `MessagingBridgeRootAutoConfiguration`. |
|
||
| `src/config/architecture/modules.json` | Add `adapter-outbound-persistence-mongo` and `adapter-inbound-graphql` allowed dependencies + `app-bootstrap` membership; add both to `app-bootstrap.allowed_dependencies`. |
|
||
| `src/app-bootstrap/build.gradle` | Add the mongo and graphql project dependencies. |
|
||
| `src/app-bootstrap/src/main/resources/application.yml` | Five master switches at `false`; `relay-enabled: false`; `idempotency.provider: disabled`; readiness group without a static `db`. |
|
||
| `src/app-bootstrap/src/main/resources/application-local.yml` | Remove implicit JPA/H2 activation. |
|
||
| `docs/registries/env-keys.yaml` | Register the five canonical keys + the two subordinate selectors; demote `APP_MESSAGING_BROKER` and the two notification provider selectors. |
|
||
| `src/build.gradle` | Replace the direct-dependency membership gate with the resolved-closure gate from Wave 0 Task 8. |
|
||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupConfig.java` | Move under the JPA root; no unconditional Flyway strategy or validators. |
|
||
| `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java` | Move under an `outbox.enabled` capability root requiring JPA + messaging. |
|
||
|
||
---
|
||
|
||
## Task 1: The master-switch parser
|
||
|
||
**Files:**
|
||
- Create: `src/shared-contract/src/main/java/dev/caskeleton/shared/activation/MasterSwitch.java`
|
||
- Create: `src/shared-contract/src/main/java/dev/caskeleton/shared/activation/MasterSwitchParseResult.java`
|
||
- Create: `src/shared-contract/src/main/java/dev/caskeleton/shared/activation/MasterSwitchParser.java`
|
||
- Test: `src/shared-contract/src/test/java/dev/caskeleton/shared/activation/MasterSwitchParserTest.java`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `enum MasterSwitch { PERSISTENCE_JPA, PERSISTENCE_MONGO, MESSAGING, NOTIFICATION_PLATFORM, GRAPHQL }`
|
||
with `String property()`, `String environmentVariable()`, `List<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 4–9 consume `MasterSwitch.property()` for their conditions — never a
|
||
string literal.
|
||
|
||
**Context:** `shared-contract` may depend only on the Java standard library (registry
|
||
`allowed_dependencies: []` for `shared-contract`). No Spring types here; that is what makes the rule
|
||
testable without a context and reusable by the `EnvironmentPostProcessor`, which runs before the
|
||
context exists.
|
||
|
||
> **Correction applied during execution.** An earlier draft of this task listed the notification
|
||
> provider selectors (`app.notification.slack.provider`, `app.notification.email.provider`) and the
|
||
> two GraphQL safety flags (`backend.graphql.production`, `backend.graphql.environment`) as *legacy
|
||
> keys of their master switches*. That is wrong, and it fails immediately: a provider selector is
|
||
> present in the shipped default configuration, so every startup that legitimately set the
|
||
> notification master was rejected as ambiguous. Those keys are not alternative spellings of a master
|
||
> switch — spec §5.1 demotes them to **subordinate settings that stay meaningful while the adapter is
|
||
> on**. Their migrations belong to the settings that replace them (the delivery-platform runtime in
|
||
> Wave 2 D4; `backend.graphql.deployment-mode` in Wave 2 E1), not to the master. Only
|
||
> `app.jpa-platform.enabled` is a genuine former master alias, so it is the only entry in
|
||
> `legacyProperties()`.
|
||
>
|
||
> Also note: `MasterSwitch` cannot hold a `List` instance field — Error Prone's
|
||
> `ImmutableEnumChecker` rejects it and this repository compiles with `-Werror`. Keep the two scalar
|
||
> names as fields and put the superseded names in a static `EnumMap`.
|
||
|
||
The rules, verbatim from index §Master scalar parsing rule: unset ⇒ `Off`; exactly `true`/`false`
|
||
case-insensitively with no surrounding whitespace ⇒ `On`/`Off`; anything else ⇒ `Invalid`; canonical
|
||
and legacy both present ⇒ `Ambiguous` even when values agree; legacy alone ⇒ `LegacyOnly` naming the
|
||
replacement.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/shared-contract/src/test/java/dev/caskeleton/shared/activation/MasterSwitchParserTest.java`:
|
||
|
||
```java
|
||
package dev.caskeleton.shared.activation;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
import java.util.Map;
|
||
import org.junit.jupiter.api.DisplayName;
|
||
import org.junit.jupiter.api.Test;
|
||
|
||
/**
|
||
* A master switch is the one value an operator must be able to trust absolutely.
|
||
*
|
||
* <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`:
|
||
|
||
```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`:
|
||
|
||
```java
|
||
package dev.caskeleton.shared.activation;
|
||
|
||
/** The outcome of reading one master switch. Only {@link Off} and {@link On} may start a context. */
|
||
public sealed interface MasterSwitchParseResult {
|
||
|
||
/** The switch is off, either explicitly or because nobody set it. */
|
||
record Off(MasterSwitch owner) implements MasterSwitchParseResult {}
|
||
|
||
/** The switch is on. */
|
||
record On(MasterSwitch owner) implements MasterSwitchParseResult {}
|
||
|
||
/** The value is neither {@code true} nor {@code false}. */
|
||
record Invalid(MasterSwitch owner, String raw) implements MasterSwitchParseResult {}
|
||
|
||
/** Canonical and legacy keys are both set. */
|
||
record Ambiguous(MasterSwitch owner, String legacyProperty) implements MasterSwitchParseResult {}
|
||
|
||
/** Only a superseded key is set. */
|
||
record LegacyOnly(MasterSwitch owner, String legacyProperty, String replacementProperty)
|
||
implements MasterSwitchParseResult {}
|
||
}
|
||
```
|
||
|
||
`MasterSwitchParser.java`:
|
||
|
||
```java
|
||
package dev.caskeleton.shared.activation;
|
||
|
||
import java.util.Locale;
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* Reads one master switch, strictly.
|
||
*
|
||
* <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 4–9's
|
||
conditions may then assume the property, when present, is exactly `true` or `false`.
|
||
|
||
**Context:** An `EnvironmentPostProcessor` runs after the `Environment` is prepared and before the
|
||
context refreshes, which is the only window where the switch can be checked without binding the
|
||
detail namespace. Binding detail settings here would break off-invariant item 8 (an invalid detail
|
||
setting must not block an off startup), so the post-processor reads *raw* property values via
|
||
`Environment#getProperty` and never calls a binder.
|
||
|
||
Registration goes in `META-INF/spring.factories` under
|
||
`org.springframework.boot.env.EnvironmentPostProcessor`, because `AutoConfiguration.imports` is too
|
||
late — auto-configuration runs during refresh.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```java
|
||
package dev.caskeleton.bootstrap.activation;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
import dev.caskeleton.bootstrap.CaSkeletonApplication;
|
||
import org.junit.jupiter.api.DisplayName;
|
||
import org.junit.jupiter.api.Test;
|
||
import org.springframework.boot.WebApplicationType;
|
||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||
import org.springframework.context.ConfigurableApplicationContext;
|
||
|
||
/**
|
||
* The switch is checked before anything reads a detail setting.
|
||
*
|
||
* <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**
|
||
|
||
```java
|
||
package dev.caskeleton.bootstrap.activation;
|
||
|
||
import dev.caskeleton.shared.activation.MasterSwitch;
|
||
import dev.caskeleton.shared.activation.MasterSwitchParseResult;
|
||
import dev.caskeleton.shared.activation.MasterSwitchParser;
|
||
import java.util.LinkedHashMap;
|
||
import java.util.Map;
|
||
import org.springframework.boot.SpringApplication;
|
||
import org.springframework.boot.env.EnvironmentPostProcessor;
|
||
import org.springframework.core.Ordered;
|
||
import org.springframework.core.env.ConfigurableEnvironment;
|
||
|
||
/**
|
||
* Rejects an unreadable master switch before anything binds a detail namespace.
|
||
*
|
||
* <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):
|
||
|
||
```properties
|
||
org.springframework.boot.env.EnvironmentPostProcessor=\
|
||
dev.caskeleton.bootstrap.activation.MasterSwitchEnvironmentPostProcessor
|
||
```
|
||
|
||
- [ ] **Step 5: Run the test to verify it passes.**
|
||
Expected: PASS, 3 tests.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```
|
||
git add src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/MasterSwitchEnvironmentPostProcessor.java \
|
||
src/app-bootstrap/src/main/resources/META-INF/spring.factories \
|
||
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/MasterSwitchEnvironmentPostProcessorTest.java
|
||
git commit -m "feat(bootstrap): reject an unreadable master switch before detail binding
|
||
|
||
Reads raw values rather than binding: an adapter that is off has to start
|
||
beside an environment full of its own malformed configuration, and a
|
||
validator that bound the namespace to check the switch would fail exactly
|
||
the deployments the switch protects."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Narrow the composition root's scans
|
||
|
||
**Files:**
|
||
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java`
|
||
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/CompositionScanNarrownessTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing.
|
||
- Produces: the narrowed `AUTO_CONFIGURED_PACKAGES` constant, extended to cover the five adapters.
|
||
Tasks 4–9 depend on this: without it, their root's `@Import` is redundant because the component
|
||
scan finds the children anyway.
|
||
|
||
**Context:** At HEAD the class already excludes `dev\.caskeleton\.bootstrap\.autoconfigure\..*` from
|
||
`@ComponentScan` — and the same exclusion is **absent** from `@ConfigurationPropertiesScan`, which is
|
||
the specific defect behind NTF-INT-005. Fixing that is half this task. The other half is extending
|
||
the pattern to the five adapters' own packages so a leaf's `@Component`/`@ConfigurationProperties`
|
||
cannot be discovered outside its root.
|
||
|
||
Do not delete the two custom filters (`TypeExcludeFilter`, `AutoConfigurationExcludeFilter`) — they
|
||
are what `@SpringBootApplication` contributes and removing them breaks slice tests.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```java
|
||
package dev.caskeleton.bootstrap.activation;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
import java.util.regex.Pattern;
|
||
import org.junit.jupiter.api.DisplayName;
|
||
import org.junit.jupiter.api.Test;
|
||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||
import org.springframework.context.annotation.ComponentScan;
|
||
|
||
/**
|
||
* A capability's configuration is reachable only through its own root.
|
||
*
|
||
* <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.jpa.PersistenceJpaRootAutoConfiguration",
|
||
"dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig",
|
||
"dev.caskeleton.adapter.outbound.messaging.MessagingSettings",
|
||
"dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings",
|
||
"dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformProperties"
|
||
}) {
|
||
assertThat(excluded.matcher(type).matches())
|
||
.as("%s must be reachable only through its capability root", type)
|
||
.isTrue();
|
||
}
|
||
}
|
||
|
||
private static String regexExcludeOf(ComponentScan.Filter[] filters) {
|
||
for (ComponentScan.Filter filter : filters) {
|
||
if (filter.type() == org.springframework.context.annotation.FilterType.REGEX) {
|
||
return filter.pattern()[0];
|
||
}
|
||
}
|
||
throw new AssertionError("no REGEX exclude filter is declared");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails.** Expected: both cases fail — the properties scan has no
|
||
filters, and the pattern covers only `bootstrap.autoconfigure` plus the fileserver admin package.
|
||
|
||
- [ ] **Step 3: Modify `CaSkeletonApplication`**
|
||
|
||
Replace the `AUTO_CONFIGURED_PACKAGES` constant and add the exclusion to
|
||
`@ConfigurationPropertiesScan`:
|
||
|
||
```java
|
||
static final String AUTO_CONFIGURED_PACKAGES =
|
||
"dev\\.caskeleton\\.bootstrap\\.autoconfigure\\..*"
|
||
+ "|dev\\.caskeleton\\.bootstrap\\.notification\\..*"
|
||
+ "|dev\\.caskeleton\\.adapter\\.inbound\\.web\\.fileserver\\.admin\\..*"
|
||
+ "|dev\\.caskeleton\\.adapter\\.outbound\\.mongo\\..*"
|
||
+ "|dev\\.caskeleton\\.adapter\\.outbound\\.messaging\\..*"
|
||
+ "|dev\\.caskeleton\\.adapter\\.outbound\\.notification\\..*"
|
||
+ "|dev\\.caskeleton\\.adapter\\.outbound\\.persistence\\..*"
|
||
+ "|dev\\.caskeleton\\.adapter\\.inbound\\.graphql\\..*"
|
||
+ "|dev\\.caskeleton\\.messaging\\..*";
|
||
```
|
||
|
||
and change the annotation to:
|
||
|
||
```java
|
||
@ConfigurationPropertiesScan(
|
||
basePackages = {
|
||
"dev.caskeleton.bootstrap",
|
||
"dev.caskeleton.adapter",
|
||
"dev.caskeleton.application",
|
||
"dev.caskeleton.domain",
|
||
"dev.caskeleton.shared"
|
||
},
|
||
excludeFilters = {
|
||
@ComponentScan.Filter(
|
||
type = FilterType.REGEX,
|
||
pattern = CaSkeletonApplication.AUTO_CONFIGURED_PACKAGES)
|
||
})
|
||
```
|
||
|
||
Extend the class Javadoc with one paragraph explaining that the properties scan now carries the same
|
||
exclusion, and why: a capability whose beans are gated but whose settings still bind is gated only
|
||
where somebody remembered.
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes.**
|
||
|
||
- [ ] **Step 5: Run the full app-bootstrap suite to find what the narrowing broke**
|
||
|
||
Run: `cd src && ./gradlew :app-bootstrap:test --console=plain --no-daemon --continue`
|
||
|
||
Expected: failures in tests that relied on a scanned bean or a scanned `@ConfigurationProperties`
|
||
which the roots do not yet import — the roots arrive in Tasks 4–9. Record the full list in
|
||
`docs/superpowers/plans/evidence/2026-08-15-wave1-progress.md`; each entry must be closed by a root
|
||
import, never by re-widening the scan.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```
|
||
git add src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java \
|
||
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/CompositionScanNarrownessTest.java \
|
||
docs/superpowers/plans/evidence/2026-08-15-wave1-progress.md
|
||
git commit -m "refactor(bootstrap): give both scans the same exclusion
|
||
|
||
The component scan already excluded auto-configured packages; the
|
||
configuration-properties scan did not, which is why notification settings
|
||
bound themselves with the notification master off. A capability whose beans
|
||
are gated but whose settings still bind is gated only where somebody
|
||
remembered."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: The JPA activation authority and the DataSource requirement closure
|
||
|
||
**Files:**
|
||
- Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/DataSourceRequirement.java`
|
||
- Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/PersistenceJpaRootAutoConfiguration.java`
|
||
- Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/JpaOffAutoConfigurationImportFilter.java`
|
||
- Create: `src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfigurationImportFilter.imports`
|
||
- Modify: `src/app-bootstrap/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
|
||
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java`
|
||
- Modify: `src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java`
|
||
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/JpaMasterGateTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MasterSwitch.PERSISTENCE_JPA` (Task 1); `AdapterActivationInventory` (Wave 0 Task 2).
|
||
- Produces:
|
||
- `DataSourceRequirement.isRequired(Environment)` → `boolean`, and
|
||
`DataSourceRequirement.reasons(Environment)` → `List<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**
|
||
|
||
```java
|
||
package dev.caskeleton.bootstrap.autoconfigure.jpa;
|
||
|
||
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`**
|
||
|
||
```java
|
||
package dev.caskeleton.bootstrap.autoconfigure.jpa;
|
||
|
||
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**
|
||
|
||
```java
|
||
package dev.caskeleton.bootstrap.autoconfigure.jpa;
|
||
|
||
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`:
|
||
|
||
```java
|
||
package dev.caskeleton.bootstrap.autoconfigure.jpa;
|
||
|
||
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.jpa.PersistenceJpaRootAutoConfiguration`.
|
||
- Create `AutoConfigurationImportFilter.imports` containing
|
||
`dev.caskeleton.bootstrap.autoconfigure.jpa.JpaOffAutoConfigurationImportFilter`.
|
||
|
||
- [ ] **Step 6: Run the test to verify it passes.**
|
||
Run: `cd src && ./gradlew :app-bootstrap:test --tests '*JpaMasterGateTest*' --console=plain --no-daemon`
|
||
Expected: PASS, 3 tests.
|
||
|
||
- [ ] **Step 7: Run the JPA leaf's focused test and the off inventory**
|
||
|
||
Run:
|
||
```bash
|
||
cd src
|
||
./gradlew :adapter:outbound:persistence-jpa:test --console=plain --no-daemon
|
||
./gradlew :app-bootstrap:test --tests '*FiveAdapterOffInventoryTest*' --console=plain --no-daemon
|
||
```
|
||
Expected: the JPA leaf stays green; `FiveAdapterOffInventoryTest.jpaOffHoldsNothing` now passes —
|
||
remove its `@Tag("wave0-red")` in this commit.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```
|
||
git add src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/ \
|
||
src/app-bootstrap/src/main/resources/META-INF/spring/ \
|
||
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java \
|
||
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql/PostgreSqlPersistenceConfig.java \
|
||
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/ \
|
||
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/FiveAdapterOffInventoryTest.java
|
||
git commit -m "feat(bootstrap): give JPA one activation authority (JPA-INT-001)
|
||
|
||
The switch that was supposed to control JPA gated three convenience beans
|
||
and defaulted to on, while the vendor config imported the entity scan
|
||
unconditionally. The pool is asked for by capability rather than by JPA,
|
||
because outbox, idempotency, the lock, and the notification store are all
|
||
legitimate consumers and turning JPA off must name them rather than break
|
||
them."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Ship Mongo and give it one authority
|
||
|
||
**Files:**
|
||
- Create: `src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoRootAutoConfiguration.java`
|
||
- Modify: `src/adapter/outbound/persistence-mongo/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
|
||
- Modify: `src/config/architecture/modules.json`
|
||
- Modify: `src/app-bootstrap/build.gradle`
|
||
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/MongoShippedAndGatedTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MasterSwitch.PERSISTENCE_MONGO`; `MongoOptInAutoConfigurationImportFilter` (existing,
|
||
unchanged — it stays as the vendor-blocking mechanism, demoted from activation authority per
|
||
MNG-INT-005).
|
||
- Produces: `MongoRootAutoConfiguration`, the sole entry; registry membership for
|
||
`adapter-outbound-persistence-mongo`.
|
||
|
||
**Context:** MNG-INT-001 and MNG-INT-005. Today three things each behave like the Mongo master: the
|
||
import filter, the component-scanned `MongoPersistenceConfig`, and the auto-imported
|
||
`MongoPlatformAutoConfiguration`. After this task the import filter keeps its job (blocking Boot's
|
||
vendor auto-configuration) but is no longer an authority; the root is.
|
||
|
||
Registry edits, exact:
|
||
- `adapter-outbound-persistence-mongo`: `allowed_dependencies` becomes
|
||
`["domain-core", "application-core", "shared-contract"]`; `runtime_memberships` becomes
|
||
`["app-bootstrap"]`.
|
||
- `app-bootstrap.allowed_dependencies`: add `"adapter-outbound-persistence-mongo"`.
|
||
|
||
**Order matters:** gating first (Steps 1–4), membership last (Step 5). Adding the dependency before
|
||
the root exists ships an ungated adapter.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```java
|
||
package dev.caskeleton.bootstrap.activation;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
import dev.caskeleton.bootstrap.activation.AdapterActivationInventory.Adapter;
|
||
import org.junit.jupiter.api.DisplayName;
|
||
import org.junit.jupiter.api.Test;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.boot.test.context.SpringBootTest;
|
||
import org.springframework.context.ApplicationContext;
|
||
import org.springframework.test.context.ActiveProfiles;
|
||
|
||
/**
|
||
* Mongo ships in the jar and is off because a switch says so.
|
||
*
|
||
* <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**
|
||
|
||
```java
|
||
package dev.caskeleton.adapter.outbound.mongo.autoconfigure;
|
||
|
||
import dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig;
|
||
import dev.caskeleton.adapter.outbound.mongo.MongoPersistenceProperties;
|
||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||
import org.springframework.context.annotation.Import;
|
||
|
||
/**
|
||
* The one place that decides whether this application talks to MongoDB.
|
||
*
|
||
* <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:
|
||
|
||
```groovy
|
||
// Shipped for env-switch activation (spec §1): present in the jar, off unless
|
||
// APP_PERSISTENCE_MONGO_ENABLED says otherwise. An adapter that is absent cannot be turned on
|
||
// by an operator, which is a different contract from one that is off.
|
||
implementation project(':adapter:outbound:persistence-mongo')
|
||
```
|
||
|
||
- [ ] **Step 6: Run the gate and the test**
|
||
|
||
Run:
|
||
```bash
|
||
cd src
|
||
./gradlew verifyCleanArchitectureDependencies --console=plain --no-daemon
|
||
./gradlew :app-bootstrap:runtimeClasspathManifest --console=plain --no-daemon
|
||
./gradlew :app-bootstrap:test --tests '*MongoShippedAndGatedTest*' \
|
||
--tests '*RuntimeMembershipClasspathAgreementTest*' \
|
||
--tests '*ShippedRuntimeFacadePresenceTest*' --console=plain --no-daemon
|
||
```
|
||
Expected: all pass. Remove `@Tag("wave0-red")` from
|
||
`ShippedRuntimeFacadePresenceTest.mongoFacadeIsShipped` in this commit.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```
|
||
git add src/adapter/outbound/persistence-mongo/ src/config/architecture/modules.json \
|
||
src/app-bootstrap/build.gradle src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/
|
||
git commit -m "feat(mongo): ship the adapter and give it one authority (MNG-INT-001/005)
|
||
|
||
Mongo was off by not being built, which an operator cannot reverse with an
|
||
environment variable and which hides every gating defect, because absent
|
||
code holds no beans whatever its conditions say. The import filter keeps
|
||
blocking Boot's vendor auto-configurations — that has to happen before any
|
||
project condition — but it is no longer an authority."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Ship GraphQL and gate it
|
||
|
||
Same shape as Task 5. Differences called out below; every step otherwise mirrors it.
|
||
|
||
**Files:**
|
||
- Create: `src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlRootAutoConfiguration.java`
|
||
- Create: `src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/autoconfigure/GraphQlOffAutoConfigurationImportFilter.java`
|
||
- Create: `src/adapter/inbound/graphql/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfigurationImportFilter.imports`
|
||
- Modify: `src/adapter/inbound/graphql/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
|
||
- Modify: `src/config/architecture/modules.json`, `src/app-bootstrap/build.gradle`
|
||
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/GraphQlShippedAndGatedTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MasterSwitch.GRAPHQL`.
|
||
- Produces: `GraphQlRootAutoConfiguration`; registry membership for `adapter-inbound-graphql`.
|
||
|
||
**Context (GQL-INT-001):** GraphQL differs from Mongo in two ways that matter.
|
||
|
||
1. It is an **inbound** adapter, so "off" additionally means no route and no schema — its off test
|
||
must use `WebEnvironment.RANDOM_PORT` and assert that `/graphql` returns 404, not merely that
|
||
beans are absent. A bean-only assertion would pass while Spring GraphQL's own auto-configuration
|
||
published the endpoint.
|
||
2. `GraphQlPlatformAutoConfiguration` has **no** master condition at all today
|
||
(spec §4.2), so putting it on the classpath without this task's root would activate it.
|
||
|
||
Registry edits: `adapter-inbound-graphql.runtime_memberships` becomes `["app-bootstrap"]`;
|
||
`app-bootstrap.allowed_dependencies` gains `"adapter-inbound-graphql"`.
|
||
|
||
`GraphQlOffAutoConfigurationImportFilter` excludes, subject to the same
|
||
verify-the-names note as Task 4 Step 4:
|
||
`org.springframework.boot.graphql.autoconfigure.GraphQlAutoConfiguration`,
|
||
`org.springframework.boot.graphql.autoconfigure.servlet.GraphQlWebMvcAutoConfiguration`,
|
||
`org.springframework.boot.graphql.autoconfigure.reactive.GraphQlWebFluxAutoConfiguration`,
|
||
`org.springframework.boot.graphql.autoconfigure.rsocket.GraphQlRSocketAutoConfiguration`,
|
||
`org.springframework.boot.graphql.autoconfigure.security.GraphQlWebMvcSecurityAutoConfiguration`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```java
|
||
package dev.caskeleton.bootstrap.activation;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
import dev.caskeleton.bootstrap.activation.AdapterActivationInventory.Adapter;
|
||
import org.junit.jupiter.api.DisplayName;
|
||
import org.junit.jupiter.api.Test;
|
||
import org.springframework.beans.factory.annotation.Autowired;
|
||
import org.springframework.boot.test.context.SpringBootTest;
|
||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||
import org.springframework.context.ApplicationContext;
|
||
import org.springframework.http.HttpStatus;
|
||
import org.springframework.test.context.ActiveProfiles;
|
||
|
||
/**
|
||
* GraphQL ships in the jar and exposes nothing until it is switched on.
|
||
*
|
||
* <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 1–7:** same TDD cycle as Task 4. The test asserts, under master-off: zero
|
||
notification-owned beans, `NotificationPlatformSettings` not bound, zero worker threads, and that a
|
||
malformed `ca-skeleton.notification.platform.*` value does not block startup. Then, under
|
||
master-on + `SERVING` + callbacks-off: no callback signing/fingerprint/payload-protection secret is
|
||
required, and no unselected provider's secret is required.
|
||
- [ ] Remove `@Tag("wave0-red")` from `FiveAdapterOffInventoryTest.notificationOffHoldsNothing`.
|
||
|
||
---
|
||
|
||
## Task 9: Move migration and outbox under capability roots
|
||
|
||
**Files:**
|
||
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/startup/MigrationStartupConfig.java`
|
||
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java`
|
||
- Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxCapabilityRootAutoConfiguration.java`
|
||
- Modify: `src/app-bootstrap/src/main/resources/application.yml`
|
||
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/outbox/OutboxCapabilityGateTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DataSourceRequirement` (Task 4), `MasterSwitch.MESSAGING` (Task 1).
|
||
- Produces: `ca-skeleton.outbox.enabled` as the capability root property; `relay-enabled` demoted to
|
||
"run the scheduler", meaningful only when the capability is on.
|
||
|
||
**Context (JPA-INT-004, MSG-INT-001):** Verified at HEAD, `MigrationStartupConfig` unconditionally
|
||
creates a `FlywayMigrationStrategy`, a datasource env validator, and a prod Flyway validator; and
|
||
`OutboxConfig` assembles settings, a validator, an RNG, a leader token, and
|
||
`OutboxMetrics(OutboxStorePort)` even with `relay-enabled=false`.
|
||
|
||
The two YAML changes this task makes:
|
||
- `ca-skeleton.outbox.relay-enabled: true` → add `ca-skeleton.outbox.enabled: false` above it and
|
||
set `relay-enabled: false`. Document in the comment that `relay-enabled` now only starts the
|
||
scheduler and that the capability switch is the one above it.
|
||
- `ca-skeleton.idempotency.provider: ${APP_IDEMPOTENCY_PROVIDER:jdbc}` → default `disabled`.
|
||
|
||
- [ ] **Step 1–7:** TDD cycle. The test asserts: with `outbox.enabled=false`, no outbox bean, no
|
||
Flyway strategy, and no migration validator exists; with `outbox.enabled=true` and messaging off,
|
||
startup fails naming `app.messaging.enabled`; with `outbox.enabled=true`, messaging on, and JPA
|
||
off, startup fails naming `ca-skeleton.persistence-jpa.enabled`.
|
||
- [ ] Remove `@Tag("wave0-red")` from
|
||
`DefaultProfileBootCharacterizationTest.localProfileStartsWithShippedDefaults`.
|
||
|
||
---
|
||
|
||
## Task 10: The capability dependency validator
|
||
|
||
**Files:**
|
||
- Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/CapabilityDependencyValidator.java`
|
||
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/CapabilityDependencyValidatorTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MasterSwitch`, `DataSourceRequirement`.
|
||
- Produces: `CapabilityDependencyValidator`, an `InitializingBean` registered by
|
||
`AdapterActivationAutoConfiguration` (Task 11).
|
||
|
||
**Context:** Spec §5.3's table is the specification. Each row becomes one rule, and each rule's
|
||
failure message names the **exact** switch or provider that is missing — spec §12.3's
|
||
`relay on, dependency 누락` matrix row requires the name, not a generic "misconfiguration".
|
||
|
||
Rules to implement, one test case each:
|
||
|
||
| rule | failure names |
|
||
| --- | --- |
|
||
| outbox on ⇒ JPA on **and** messaging on **and** broker non-blank | whichever is missing |
|
||
| `idempotency.provider=jdbc` ⇒ JPA on | `ca-skeleton.persistence-jpa.enabled` |
|
||
| `lock.multi-instance-enabled` ⇒ JPA on | `ca-skeleton.persistence-jpa.enabled` |
|
||
| notification on with a relational store ⇒ JPA on | `ca-skeleton.persistence-jpa.enabled` |
|
||
| messaging on ⇒ `app.messaging.broker` non-blank | `app.messaging.broker` |
|
||
| Mongo on ⇒ `active-profile` non-blank and present in `profiles` | `ca-skeleton.persistence-mongo.active-profile` |
|
||
| GraphQL on ⇒ `deployment-mode` set and permitted for the active runtime | `backend.graphql.deployment-mode` |
|
||
| two implementations of one application port both active | both bean names, refusing to pick |
|
||
|
||
- [ ] **Steps 1–5:** TDD cycle with one `@Test` per rule.
|
||
|
||
---
|
||
|
||
## Task 11: Derive readiness membership and publish the activation report
|
||
|
||
**Files:**
|
||
- Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationReport.java`
|
||
- Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationEndpoint.java`
|
||
- Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/AdapterActivationAutoConfiguration.java`
|
||
- Create: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/activation/ReadinessGroupContributor.java`
|
||
- Modify: `src/app-bootstrap/src/main/resources/application.yml`
|
||
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/ReadinessGroupDerivationTest.java`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `record AdapterActivationReport(String activeProfile, Map<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 1–7:** TDD cycle. Cases: all-off ⇒ readiness group is exactly `readinessState` and
|
||
startup succeeds; JPA on ⇒ group contains `db`; a deliberately misspelled static entry ⇒ startup
|
||
still fails.
|
||
|
||
---
|
||
|
||
## Task 12: Register the env keys and align the YAML defaults
|
||
|
||
**Files:**
|
||
- Modify: `docs/registries/env-keys.yaml`
|
||
- Modify: `src/app-bootstrap/src/main/resources/application.yml`
|
||
- Modify: `src/app-bootstrap/src/main/resources/application-local.yml`
|
||
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/MasterSwitchRegistryContractTest.java`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MasterSwitch` (Task 1).
|
||
- Produces: seven registry rows (five masters + two subordinate selectors) and the YAML defaults.
|
||
|
||
**Context:** `docs/registries/env-keys.yaml` is the SSOT for name, type, default, required-when,
|
||
classification, restart policy, and required tests. Read an existing row before writing new ones and
|
||
match its field set exactly — do not invent fields.
|
||
|
||
Rows to add: the five masters (`type: boolean`, `default: false`, `classification: public`), plus
|
||
`APP_PERSISTENCE_MONGO_ACTIVE_PROFILE` (`required-when: APP_PERSISTENCE_MONGO_ENABLED=true`) and
|
||
`APP_GRAPHQL_DEPLOYMENT_MODE` (enum, `required-when: APP_GRAPHQL_ENABLED=true`).
|
||
|
||
Rows to demote from activation to selection, keeping the key but changing its documented role:
|
||
`APP_MESSAGING_BROKER`, `APP_NOTIFICATION_SLACK_PROVIDER`, `APP_NOTIFICATION_EMAIL_PROVIDER`.
|
||
|
||
The contract test asserts, for each `MasterSwitch`: a registry row exists under its
|
||
`environmentVariable()`; its default is exactly `false`; `application.yml` binds the canonical
|
||
property to that env var with a `false` inline default; and — the negative assertion spec §6.4
|
||
requires — no demoted key is documented as an activation switch.
|
||
|
||
- [ ] **Steps 1–6:** TDD cycle, ending with `./gradlew verifyEnvKeys --console=plain --no-daemon`.
|
||
|
||
---
|
||
|
||
## Task 13: Replace the membership gate with the resolved-closure gate
|
||
|
||
**Files:**
|
||
- Modify: `src/build.gradle`
|
||
- Test: reuse `RuntimeMembershipClasspathAgreementTest` (Wave 0 Task 8)
|
||
|
||
**Context (MSG-INT-002):** The existing `verifyRuntimeModuleMembership` compares direct project
|
||
dependencies. Replace its comparison input with
|
||
`app-bootstrap/build/architecture/runtime-project-closure.txt` produced by
|
||
`:app-bootstrap:runtimeClasspathManifest`, and make the verification task depend on it. Keep the task
|
||
name — CI and `AGENTS.md` reference it.
|
||
|
||
- [ ] **Steps 1–5:** modify, run
|
||
`./gradlew verifyRuntimeModuleMembership verifyCleanArchitectureDependencies --console=plain --no-daemon`,
|
||
confirm both green, commit.
|
||
|
||
---
|
||
|
||
## Wave 1 Exit Criteria
|
||
|
||
- [ ] `./gradlew :app-bootstrap:test --tests '*FiveAdapterOffInventoryTest*' --console=plain --no-daemon`
|
||
— all five cases green, no `wave0-red` tag remaining on the class.
|
||
- [ ] `./gradlew :app-bootstrap:test --tests '*ShippedRuntimeFacadePresenceTest*'` — Mongo and
|
||
GraphQL green (messaging platform stays red; Wave 2 owns it).
|
||
- [ ] All-off boots on each environment with no external infrastructure:
|
||
```bash
|
||
cd src
|
||
for p in local dev prod; do
|
||
SPRING_PROFILES_ACTIVE=$p ./gradlew :app-bootstrap:bootRun --console=plain \
|
||
-Dspring-boot.run.arguments="--ca-skeleton.persistence-jpa.enabled=false --ca-skeleton.persistence-mongo.enabled=false --app.messaging.enabled=false --ca-skeleton.notification.platform.enabled=false --backend.graphql.enabled=false --ca-skeleton.outbox.enabled=false --ca-skeleton.idempotency.provider=disabled"
|
||
done
|
||
```
|
||
Each must reach a started context and answer `/actuator/health` with 200.
|
||
- [ ] `./gradlew verifyCleanArchitectureDependencies verifyRuntimeModuleMembership verifyEnvKeys --console=plain --no-daemon` — green.
|
||
- [ ] `./gradlew wave0RedReport --console=plain --no-daemon` — the remaining red set is exactly the
|
||
Wave 2/3/4 entries from the Wave 0 exit table.
|
||
- [ ] `./gradlew :app-bootstrap:test --console=plain --no-daemon` — green, with any test broken by
|
||
Task 3's scan narrowing fixed by a root import rather than by re-widening a scan.
|
||
|
||
## What Wave 1 explicitly does not do
|
||
|
||
- No messaging platform runtime membership (Wave 2, MSG-INT-002/003).
|
||
- No production provider assembler for Notification (Wave 2, NTF-INT-001).
|
||
- No Mongo client/profile/secret wiring (Wave 2, MNG-INT-002/003/004).
|
||
- No GraphQL deployment-mode collapse or endpoint pipeline (Wave 2, GQL-INT-002/003/004).
|
||
- No secret-scanner fix (Wave 2, MSG-INT-005).
|
||
- No `SPRING_PROFILES_ACTIVE` fail-closed change, no `.env` split, no Compose work (Wave 3).
|
||
- No warning removal (Wave 4) and no build-logic extraction (Wave 5).
|