- 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>
76 KiB
Wave 0 — Red Baseline and Evidence Pinning Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. Read2026-08-15-five-adapter-runtime-remediation-index.mdfirst — its Global Constraints section is implicitly part of every task here.
Goal: Turn every failure named in spec §2 into a named, executable test that fails for the documented reason, so that Waves 1–6 have a characterization harness that cannot be satisfied by deleting the thing being measured.
Architecture: Wave 0 writes only tests, fixtures, and verification tasks — no production
behaviour changes. Most of what it adds is expected to be red at the end of this wave, and that
is the deliverable: a red that names its cause. Two categories are exceptions and must be green
immediately: the meta-verifiers (Task 6, Task 7), which assert facts about the registry and about
release manifests rather than about runtime behaviour, and the harness self-tests, which assert the
harness works. Every red test added here carries a @Tag("wave0-red") so a single Gradle lane can
report the exact remaining red set at any point during Waves 1–4.
Tech Stack: JUnit 5, AssertJ, ApplicationContextRunner, Spring Boot ApplicationContextRunner
SpringApplicationprocess harness, Gradle 9 customTestlanes,docker compose configas a subprocess.
Spec: docs/superpowers/specs/2026-08-15-five-adapter-runtime-remediation-review-design.md
(§2, §3.1, §4, §11 Wave 0, §12.2)
Global Constraints
Inherited in full from the index. Restated here only where Wave 0 narrows them:
- No production code changes in this wave. If a task appears to require one, stop and record it as a Wave 1 input instead. The only non-test files Wave 0 may create are Gradle lane registrations and test-fixture resources.
- Red is the deliverable. Do not "fix" a test added by this plan to make it pass. Do not add it
to any allowlist. Do not
@Disabledit. - A red test must fail with a message that names what is wrong, not just
expected true but was false. Every assertion below carries anas(...)/withFailMessage(...)describing the contract. @Tag("wave0-red")marks tests expected to be red at the end of this wave. Tests that must be green carry no such tag.- Commit policy is
human-only: at each "Commit" step, report the file list and message; the human commits.
File Structure
| File | Responsibility |
|---|---|
src/messaging/messaging-observability/src/test/java/dev/caskeleton/messaging/observation/SecretLeakScannerCharacterizationTest.java |
Create. Pins the scanner's two false positives and its true-positive detection power as separate, independently-failing cases. |
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/AdapterActivationInventory.java |
Create. Shared fixture: given a Spring ApplicationContext, returns the bean/resource inventory owned by each of the five adapters. One place that knows what "JPA's beans" means. |
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/FiveAdapterOffInventoryTest.java |
Create. The off invariant (index §Off invariant, items 1–8) as a full-context test for all five adapters. Red. |
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/ShippedRuntimeFacadePresenceTest.java |
Create. Asserts all five runtime facades are loadable from the app-bootstrap runtime classpath. Red for Mongo and GraphQL. |
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/DefaultProfileBootCharacterizationTest.java |
Create. Reproduces the local and dev default-boot failures with their exact causes. Red. |
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/StartupWarningRecorder.java |
Create. Logback appender + assertion API that captures WARN/ERROR emitted during context startup. |
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/StartupWarningRecorderTest.java |
Create. Self-test for the recorder. Green. |
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/StartupWarningZeroTest.java |
Create. Asserts zero WARN/ERROR during startup. Red (BeanPostProcessorChecker + Micrometer). |
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/compose/ComposeMergeCharacterizationTest.java |
Create. Runs docker compose config per file stack and pins the dev merge failure. Red for dev. |
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/registry/RuntimeMembershipClasspathAgreementTest.java |
Create. Registry runtime_memberships vs the resolved runtime classpath. Green (documents today's agreement), and becomes the gate Wave 1 must keep green. |
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/registry/ReleaseManifestTaskExistenceTest.java |
Create. Every task named by a release-contract manifest must exist in the owning Gradle project. Red (three ghost Mongo tasks). |
src/app-bootstrap/build.gradle |
Modify. Register the wave0Red reporting lane and the runtimeClasspathManifest task that feeds the two registry tests. |
src/build.gradle |
Modify. Register verifyReleaseManifestTasks as an architecture-wide task and wire the wave0Red aggregate. |
Task 1: Pin the secret scanner's false positives
The full test run has exactly one failure, and it is a scanner defect, not a leak. Waves 1–5 cannot
run a full test until it is understood, and MSG-INT-005 forbids fixing it by allowlisting. This
task pins both the defect and the detection power that must survive the fix.
Files:
- Create:
src/messaging/messaging-observability/src/test/java/dev/caskeleton/messaging/observation/SecretLeakScannerCharacterizationTest.java - Read (do not modify):
src/messaging/messaging-observability/src/test/java/dev/caskeleton/messaging/observation/SecretLeakStaticScanTest.java
Interfaces:
- Consumes: nothing from earlier tasks.
- Produces:
SecretLeakScannerCharacterizationTest— Wave 2's MSG-INT-005 fix must makemethodCallWithSafeSuffixIsNotALeakandnumericFencingIsNotALeakpass without changingaConcatenatedCredentialIsALeakoraConcatenatedPayloadIsALeak.
Context the implementer needs:
The scanner under test is a private-static-method class. It is not designed for reuse, and this task must not refactor it (that is production-shaped work reserved for Wave 2). Instead, the characterization test restates the scanner's exact regexes and decision procedure locally and asserts against the restated copy. That sounds like duplication, and it is — deliberately. The point of a characterization test is to record behaviour precisely enough that the Wave 2 fix can be checked against it; when Wave 2 makes the real scanner testable, this local copy is deleted in the same commit that proves the real one behaves identically.
The two real offenders, captured from the reproduced failure:
KafkaSecurityConfigurer.java:104 ... + oauth.credentialId());
InMemoryAdminOperationJournal.java:110 existing.leaseToken() + 1,
-
oauth.credentialId()—CONCATENATION_OPERANDcapturesoauth.credentialId()with the trailing(), sotailiscredentialId();DESCRIBES_RATHER_THAN_REVEALSis(Id|Ids|Name|Type|Count|Bytes|Length|Size|Ref|Reference)$and the$cannot match before(). The safe-suffix exemption is therefore dead for every method call in the codebase. -
existing.leaseToken() + 1— an integer increment. Numeric addition cannot concatenate a secret into a string at all, but the scanner treats every+as concatenation. -
Step 1: Write the failing test
Create src/messaging/messaging-observability/src/test/java/dev/caskeleton/messaging/observation/SecretLeakScannerCharacterizationTest.java:
package dev.caskeleton.messaging.observation;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
/**
* Records exactly what {@link SecretLeakStaticScanTest}'s line classifier does today, so the fix
* that removes its two false positives can be checked against the detection power it must keep.
*
* <p>The classifier below is a verbatim copy of the one under test. A characterization test that
* called the real method would be the better design, and Wave 2 makes that possible by extracting
* the classifier; until then a copy is the only way to assert on the decision procedure at all,
* because every part of it is private and static. The copy is deleted in the same change that
* proves the extracted classifier agrees with it.
*
* <p>Two cases here are expected to fail. That is the point: they are the two offenders that fail
* the full {@code test} run at HEAD, and naming them as characterization turns "the build is red"
* into "the scanner cannot see a method call's suffix, and cannot see that {@code + 1} is
* arithmetic".
*/
class SecretLeakScannerCharacterizationTest {
private static final List<String> SENSITIVE_IDENTIFIERS =
List.of("password", "secret", "credential", "token", "apikey", "payload", "passphrase");
private static final Pattern STRING_LITERAL = Pattern.compile("\"(\\\\.|[^\"\\\\])*\"");
private static final Pattern CONCATENATION_OPERAND =
Pattern.compile(
"(?<before>[A-Za-z_][\\w.]*(?:\\(\\))?)\\s*\\+|\\+\\s*(?<after>[A-Za-z_][\\w.]*(?:\\(\\))?)");
private static final Pattern SAFE_DERIVATION =
Pattern.compile("\\.(length|size|sizeBytes|getSimpleName|getName|getClass|hashCode)\\b");
private static final Pattern DESCRIBES_RATHER_THAN_REVEALS =
Pattern.compile("(Id|Ids|Name|Type|Count|Bytes|Length|Size|Ref|Reference)$");
@Test
@DisplayName("a concatenated credential value is a leak")
void aConcatenatedCredentialIsALeak() {
assertThat(leaksASensitiveValue("log.info(\"connecting \" + oauth.credential);"))
.as("a bare credential field reaches the log without passing the redactor")
.isTrue();
}
@Test
@DisplayName("a concatenated payload value is a leak")
void aConcatenatedPayloadIsALeak() {
assertThat(leaksASensitiveValue("throw new IllegalStateException(\"bad \" + payload);"))
.as("an exception message that interpolates a payload bypasses the redactor")
.isTrue();
}
@Test
@DisplayName("a size or type derivation of a payload is not a leak")
void aDerivationIsNotALeak() {
assertThat(leaksASensitiveValue("log.debug(\"size \" + payload.length);"))
.as("a length describes the value instead of revealing it")
.isFalse();
}
@Test
@Tag("wave0-red")
@DisplayName("RED: a method call whose name ends in a safe suffix is not a leak")
void methodCallWithSafeSuffixIsNotALeak() {
assertThat(leaksASensitiveValue("log.info(\"using \" + oauth.credentialId());"))
.as(
"credentialId() names a credential without carrying it; the safe-suffix exemption is "
+ "anchored with $ but the captured operand still has its trailing (), so the "
+ "exemption never fires for a method call")
.isFalse();
}
@Test
@Tag("wave0-red")
@DisplayName("RED: incrementing a fencing token is arithmetic, not concatenation")
void numericFencingIsNotALeak() {
assertThat(leaksASensitiveValue("existing.leaseToken() + 1,"))
.as(
"a fencing token incremented by an integer literal cannot concatenate into a string; "
+ "the scanner treats every + as string concatenation")
.isFalse();
}
private static boolean leaksASensitiveValue(String line) {
String code = STRING_LITERAL.matcher(line).replaceAll("\"\"");
if (!code.contains("+")) {
return false;
}
for (String operand : operandsAdjacentToConcatenation(code)) {
String lower = operand.toLowerCase(Locale.ROOT);
if (SENSITIVE_IDENTIFIERS.stream().noneMatch(lower::contains)) {
continue;
}
if (SAFE_DERIVATION.matcher(operand).find()) {
continue;
}
String tail = operand.substring(operand.lastIndexOf('.') + 1);
if (tail.equals(tail.toUpperCase(Locale.ROOT))) {
continue;
}
if (DESCRIBES_RATHER_THAN_REVEALS.matcher(tail).find()) {
continue;
}
return true;
}
return false;
}
private static List<String> operandsAdjacentToConcatenation(String code) {
List<String> operands = new ArrayList<>();
var matcher = CONCATENATION_OPERAND.matcher(code);
while (matcher.find()) {
if (matcher.group("before") != null) {
operands.add(matcher.group("before"));
}
if (matcher.group("after") != null) {
operands.add(matcher.group("after"));
}
}
return operands;
}
}
- Step 2: Run the test to verify the split
Run:
cd src
./gradlew :messaging:messaging-observability:test \
--tests '*SecretLeakScannerCharacterizationTest*' --console=plain --no-daemon
Expected: 5 tests run, 3 pass (aConcatenatedCredentialIsALeak, aConcatenatedPayloadIsALeak,
aDerivationIsNotALeak), 2 fail (methodCallWithSafeSuffixIsNotALeak,
numericFencingIsNotALeak) with the as(...) messages above.
If a case in the first group fails, the local copy has drifted from the real scanner — re-copy it before continuing, because a characterization test that does not characterize is worse than none.
- Step 3: Record the baseline in the plan's evidence log
Append the exact console output to
docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md under a ## Task 1 heading. Create
the file and directory if absent. This is the artifact Wave 2 diffs against.
- Step 4: Commit
Report to the human for commit:
git add src/messaging/messaging-observability/src/test/java/dev/caskeleton/messaging/observation/SecretLeakScannerCharacterizationTest.java \
docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md
git commit -m "test(messaging): characterize the secret scanner's two false positives
The full test run fails on exactly one test, and both offenders are the
scanner misreading safe code: a method call keeps its trailing () so the
safe-suffix exemption's \$ anchor never matches, and an integer increment
is read as string concatenation. Pin both alongside the true positives the
fix must keep, so MSG-INT-005 cannot be closed by an allowlist."
Task 2: Build the adapter activation inventory fixture
Every off-invariant assertion in this plan and in Waves 1–3 needs one answer to "which beans belong to JPA?". Writing that list inline in each test guarantees the lists drift. This task builds the single fixture they all consume.
Files:
- Create:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/AdapterActivationInventory.java
Interfaces:
-
Consumes: nothing.
-
Produces:
enum AdapterActivationInventory.Adapter { JPA, MONGO, MESSAGING, NOTIFICATION, GRAPHQL }static List<String> beanNamesOwnedBy(ApplicationContext context, Adapter adapter)static List<String> ownedBeanTypeNames(ApplicationContext context, Adapter adapter)static List<String> liveThreadNamesMatching(Adapter adapter)static String describe(ApplicationContext context, Adapter adapter)— a stable, sorted, human-readable report used as the failure message and, in Wave 3, as evidence content.
Tasks 3, 4, and 5 of this plan and Wave 1 Tasks 6–12 all consume these signatures. Do not rename them.
Context the implementer needs:
Ownership is decided by package prefix, not by bean name, because bean names are generated and a
new bean must be caught without anyone remembering to register it. The prefixes are derived from the
registry's source_path entries plus the two vendor packages Boot contributes on the adapter's
behalf.
Vendor-contributed beans matter as much as project ones: JPA-off means no HikariDataSource even
though com.zaxxer is not a project package. So each adapter carries two prefix sets — project
packages and vendor types — and the inventory is their union.
- Step 1: Write the fixture
Create src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/AdapterActivationInventory.java:
package dev.caskeleton.bootstrap.activation;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import org.springframework.context.ApplicationContext;
/**
* The one answer to "which beans, and which threads, belong to this adapter?".
*
* <p>Ownership is a package question rather than a bean-name question. Bean names are generated,
* and a rule written against them silently stops covering the bean somebody adds next week; a
* package prefix keeps covering it. The vendor sets exist for the same reason from the other
* direction: JPA being off has to mean no {@code HikariDataSource} and no {@code Flyway}, and
* neither of those lives under a project package, so a project-only rule would report a clean
* inventory for a deployment that had opened a connection pool.
*/
public final class AdapterActivationInventory {
/** The five optional adapters this repository ships behind a master switch. */
public enum Adapter {
JPA(
Set.of(
"dev.caskeleton.adapter.outbound.persistence.jpa",
"dev.caskeleton.bootstrap.autoconfigure.jpa",
"dev.caskeleton.bootstrap.migration"),
Set.of(
"com.zaxxer.hikari",
"org.flywaydb",
"org.hibernate",
"jakarta.persistence",
"org.springframework.orm.jpa",
"org.springframework.jdbc.datasource",
"org.springframework.boot.jdbc",
"org.springframework.boot.autoconfigure.orm.jpa",
"org.springframework.boot.autoconfigure.jdbc",
"org.springframework.boot.autoconfigure.flyway"),
"hikari|flyway|jpa|hibernate"),
MONGO(
Set.of("dev.caskeleton.adapter.outbound.mongo"),
Set.of(
"com.mongodb",
"org.springframework.data.mongodb",
"org.springframework.boot.autoconfigure.mongo",
"org.springframework.boot.autoconfigure.data.mongo"),
"mongo|cluster-|maintenance-"),
MESSAGING(
Set.of("dev.caskeleton.adapter.outbound.messaging", "dev.caskeleton.messaging"),
Set.of(
"org.apache.kafka",
"com.rabbitmq",
"org.springframework.kafka",
"org.springframework.amqp",
"org.springframework.boot.autoconfigure.kafka",
"org.springframework.boot.autoconfigure.amqp"),
"kafka|rabbit|messaging-|outbox-relay"),
NOTIFICATION(
Set.of(
"dev.caskeleton.adapter.outbound.notification",
"dev.caskeleton.bootstrap.autoconfigure.notification"),
Set.of(),
"notification-"),
GRAPHQL(
Set.of("dev.caskeleton.adapter.inbound.graphql"),
Set.of(
"graphql",
"org.springframework.graphql",
"org.springframework.boot.autoconfigure.graphql"),
"graphql-");
private final Set<String> projectPackages;
private final Set<String> vendorPackages;
private final String threadNamePattern;
Adapter(Set<String> projectPackages, Set<String> vendorPackages, String threadNamePattern) {
this.projectPackages = projectPackages;
this.vendorPackages = vendorPackages;
this.threadNamePattern = threadNamePattern;
}
boolean owns(String typeName) {
return projectPackages.stream().anyMatch(prefix -> typeName.startsWith(prefix + "."))
|| vendorPackages.stream().anyMatch(prefix -> typeName.startsWith(prefix + "."));
}
}
private AdapterActivationInventory() {}
/**
* Returns the names of every bean in the context whose type the adapter owns.
*
* @param context the context to inspect
* @param adapter the adapter whose ownership decides membership
* @return sorted bean names; empty when the adapter is structurally off
*/
public static List<String> beanNamesOwnedBy(ApplicationContext context, Adapter adapter) {
List<String> owned = new ArrayList<>();
for (String name : context.getBeanDefinitionNames()) {
Class<?> type = context.getType(name);
if (type != null && adapter.owns(type.getName())) {
owned.add(name);
}
}
owned.sort(Comparator.naturalOrder());
return List.copyOf(owned);
}
/**
* Returns the distinct type names behind {@link #beanNamesOwnedBy}, which read better in a
* failure message than generated bean names do.
*
* @param context the context to inspect
* @param adapter the adapter whose ownership decides membership
* @return sorted, distinct fully-qualified type names
*/
public static List<String> ownedBeanTypeNames(ApplicationContext context, Adapter adapter) {
return beanNamesOwnedBy(context, adapter).stream()
.map(context::getType)
.filter(java.util.Objects::nonNull)
.map(Class::getName)
.distinct()
.sorted()
.toList();
}
/**
* Returns live thread names that match the adapter's thread-naming pattern.
*
* <p>A connection pool or a consumer loop that survives an "off" deployment shows up here and
* nowhere in the bean inventory, because the thread outlives the factory that made it.
*
* @param adapter the adapter whose naming pattern decides membership
* @return sorted matching thread names
*/
public static List<String> liveThreadNamesMatching(Adapter adapter) {
return Thread.getAllStackTraces().keySet().stream()
.map(Thread::getName)
.filter(name -> name.toLowerCase(java.util.Locale.ROOT).matches(".*(" + adapter.threadNamePattern + ").*"))
.distinct()
.sorted()
.toList();
}
/**
* Renders a stable report of everything the adapter currently owns.
*
* @param context the context to inspect
* @param adapter the adapter to report on
* @return a multi-line report suitable for an assertion message or an evidence artifact
*/
public static String describe(ApplicationContext context, Adapter adapter) {
return String.join(
System.lineSeparator(),
Arrays.asList(
adapter.name() + " beans: " + ownedBeanTypeNames(context, adapter),
adapter.name() + " threads: " + liveThreadNamesMatching(adapter)));
}
}
- Step 2: Verify it compiles
Run:
cd src
./gradlew :app-bootstrap:compileTestJava --console=plain --no-daemon
Expected: BUILD SUCCESSFUL.
- Step 3: Commit
Report to the human:
git add src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/AdapterActivationInventory.java
git commit -m "test(bootstrap): add the adapter activation inventory fixture
Every off-invariant assertion needs one answer to which beans and threads
belong to an adapter. Ownership is a package prefix rather than a bean-name
list so a bean added later is covered without anyone remembering, and each
adapter carries the vendor packages Boot contributes on its behalf so that
JPA-off can mean no Hikari pool rather than no project bean."
Task 3: Pin the five-adapter off invariant as a full-context red
Files:
- Create:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/FiveAdapterOffInventoryTest.java - Read:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/autoconfigure/httpclient/HttpClientPlatformActivationTest.java(the established shape)
Interfaces:
- Consumes:
AdapterActivationInventory.Adapter,.ownedBeanTypeNames,.liveThreadNamesMatching,.describefrom Task 2. - Produces:
FiveAdapterOffInventoryTest— Wave 1's exit criterion is that all five cases here go green and the@Tag("wave0-red")annotations are removed in the same change.
Context the implementer needs:
This is a @SpringBootTest against the real CaSkeletonApplication, not an
ApplicationContextRunner — the whole point is that a broad component scan and vendor
auto-configuration are in play, and a runner would not reproduce either. Because it boots the real
composition root, it needs the environment to be complete enough to start at all; the property block
below supplies the same values the local profile does, minus anything that would activate an
adapter.
Expected results at HEAD, all documented rather than guessed:
| Adapter | Expected at HEAD | Why |
|---|---|---|
| JPA | RED | app.jpa-platform.enabled is matchIfMissing=true and gates only add-on beans; PostgreSqlPersistenceConfig imports PersistenceJpaConfig regardless, so entity/repository scan, a DataSource, and Flyway all exist. |
| MONGO | GREEN, vacuously | The leaf is not on the runtime classpath at all. Task 4 is the test that says this is the wrong reason to be green. |
| MESSAGING | RED | The legacy bridge is an implementation dependency and its beans are component-scanned. |
| NOTIFICATION | RED | @ConfigurationPropertiesScan covers dev.caskeleton.adapter, so NotificationPlatformSettings binds with the master off. |
| GRAPHQL | GREEN, vacuously | Not on the runtime classpath. Same caveat as Mongo. |
- Step 1: Write the failing test
Create src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/FiveAdapterOffInventoryTest.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.Tag;
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;
/**
* With all five master switches off, the application must hold nothing that belongs to any of them.
*
* <p>This boots the real composition root rather than an {@code ApplicationContextRunner}, because
* the two things most likely to defeat a master switch are exactly the two a runner does not have:
* the broad component scan, and the vendor auto-configuration a starter drags in from the
* classpath. A green runner and a red application is the outcome this test exists to prevent.
*
* <p>Four of the five cases are expected to be red at Wave 0, and two of the greens are green for
* the wrong reason — Mongo and GraphQL hold nothing because their code is not shipped at all. {@code
* ShippedRuntimeFacadePresenceTest} is the test that refuses to accept absence as off.
*/
@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.relay-enabled=false",
"ca-skeleton.idempotency.provider=disabled",
"management.endpoint.health.group.readiness.include=readinessState"
})
@ActiveProfiles("local")
class FiveAdapterOffInventoryTest {
@Autowired private ApplicationContext context;
@Test
@Tag("wave0-red")
@DisplayName("RED: JPA off holds no entity manager, no pool, and no migration")
void jpaOffHoldsNothing() {
assertOffInventoryIsEmpty(Adapter.JPA);
}
@Test
@DisplayName("Mongo off holds nothing")
void mongoOffHoldsNothing() {
assertOffInventoryIsEmpty(Adapter.MONGO);
}
@Test
@Tag("wave0-red")
@DisplayName("RED: messaging off holds no publisher, sender, or relay")
void messagingOffHoldsNothing() {
assertOffInventoryIsEmpty(Adapter.MESSAGING);
}
@Test
@Tag("wave0-red")
@DisplayName("RED: notification off binds no settings and holds no worker")
void notificationOffHoldsNothing() {
assertOffInventoryIsEmpty(Adapter.NOTIFICATION);
}
@Test
@DisplayName("GraphQL off exposes no schema and no endpoint")
void graphQlOffHoldsNothing() {
assertOffInventoryIsEmpty(Adapter.GRAPHQL);
}
private void assertOffInventoryIsEmpty(Adapter adapter) {
assertThat(AdapterActivationInventory.ownedBeanTypeNames(context, adapter))
.as(
"%s is off, so it must own no bean; the composition root still assembled:%n%s",
adapter, AdapterActivationInventory.describe(context, adapter))
.isEmpty();
assertThat(AdapterActivationInventory.liveThreadNamesMatching(adapter))
.as(
"%s is off, so it must have started no thread; these are running:%n%s",
adapter, AdapterActivationInventory.liveThreadNamesMatching(adapter))
.isEmpty();
}
}
- Step 2: Run it and record which cases are red
Run:
cd src
./gradlew :app-bootstrap:test --tests '*FiveAdapterOffInventoryTest*' \
--console=plain --no-daemon
Expected: the test class boots, and the red/green split matches the table above. If the context fails to start at all, that is itself a Wave 0 finding — record the startup failure verbatim in the evidence log and add the minimum properties needed to reach a started context, documenting each addition with the reason it was needed.
- Step 3: Append the inventory report to the evidence log
Copy each failure message — the describe(...) output lists the exact types — into
docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md under ## Task 3. Wave 1 closes these
one type at a time and diffs against this list.
- Step 4: Commit
git add src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/FiveAdapterOffInventoryTest.java \
docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md
git commit -m "test(bootstrap): pin the five-adapter off invariant against the real context
Boots the composition root rather than a context runner, because the two
things most likely to defeat a master switch are the broad component scan
and vendor auto-configuration, and a runner has neither. Four cases are red
and two are green only because the code is not shipped yet."
Task 4: Refuse to accept absence as "off"
Mongo and GraphQL pass Task 3 because their classes do not exist on the runtime classpath. Spec §1 requires the opposite: all five facades present in one bootJar, all five off by default. This task writes the test that distinguishes the two.
Files:
- Create:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/ShippedRuntimeFacadePresenceTest.java
Interfaces:
- Consumes: nothing from earlier tasks.
- Produces:
ShippedRuntimeFacadePresenceTest— Wave 1 Task 3 (registry + dependency edges) must turn its Mongo and GraphQL cases green; Wave 2's messaging task turns the starter case green.
Context the implementer needs:
The assertion is Class.forName against the test runtime classpath, which for app-bootstrap
includes everything implementation puts on the main runtime classpath. The three class names below
were read from the actual AutoConfiguration.imports files at HEAD:
dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfigurationdev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformAutoConfigurationdev.caskeleton.messaging.autoconfigure.MessagingCoreAutoConfiguration
JPA and Notification are already present, and are asserted here too — not because they are at risk today, but because this test becomes the standing statement of what "shipped" means, and a later change that drops one of them must fail here rather than in a Compose lane an hour later.
- Step 1: Write the failing test
Create src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/ShippedRuntimeFacadePresenceTest.java:
package dev.caskeleton.bootstrap.activation;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
/**
* All five adapters ship in one artifact, and are off because a switch says so.
*
* <p>An adapter whose classes are absent also holds no beans, which makes it indistinguishable from
* a correctly gated one in any bean-inventory test — and it is not the same thing at all. An
* operator can turn a gated adapter on by setting one environment variable; they cannot turn on code
* that was never built into the jar. This test is the one that tells the two apart, so that "off by
* default" cannot be delivered by leaving something out of the build.
*/
class ShippedRuntimeFacadePresenceTest {
@Test
@DisplayName("the JPA runtime facade is on the shipped classpath")
void jpaFacadeIsShipped() {
assertFacadeIsShipped("dev.caskeleton.bootstrap.autoconfigure.jpa.JpaPlatformRuntimeAutoConfiguration");
}
@Test
@DisplayName("the notification runtime facade is on the shipped classpath")
void notificationFacadeIsShipped() {
assertFacadeIsShipped("dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier");
}
@Test
@Tag("wave0-red")
@DisplayName("RED: the Mongo runtime facade is on the shipped classpath")
void mongoFacadeIsShipped() {
assertFacadeIsShipped(
"dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfiguration");
}
@Test
@Tag("wave0-red")
@DisplayName("RED: the GraphQL runtime facade is on the shipped classpath")
void graphQlFacadeIsShipped() {
assertFacadeIsShipped(
"dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformAutoConfiguration");
}
@Test
@Tag("wave0-red")
@DisplayName("RED: the messaging platform runtime facade is on the shipped classpath")
void messagingPlatformFacadeIsShipped() {
assertFacadeIsShipped("dev.caskeleton.messaging.autoconfigure.MessagingCoreAutoConfiguration");
}
private static void assertFacadeIsShipped(String className) {
Throwable thrown =
catchThrowable(() -> Class.forName(className, false, ShippedRuntimeFacadePresenceTest.class.getClassLoader()));
assertThat(thrown)
.as(
"%s must be on the composition root's runtime classpath; an adapter that is absent "
+ "cannot be enabled by an operator setting one environment variable, so absence "
+ "is not the same contract as off",
className)
.isNull();
}
}
- Step 2: Run it
Run:
cd src
./gradlew :app-bootstrap:test --tests '*ShippedRuntimeFacadePresenceTest*' \
--console=plain --no-daemon
Expected: 2 pass (JPA, notification), 3 fail (Mongo, GraphQL, messaging platform) with
ClassNotFoundException surfaced through the as(...) message.
- Step 3: Commit
git add src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/ShippedRuntimeFacadePresenceTest.java
git commit -m "test(bootstrap): refuse to accept an absent adapter as a disabled one
Mongo and GraphQL hold no beans because their classes are not in the jar,
which is indistinguishable from correct gating in any inventory test and is
not the same contract: an operator can enable a gated adapter with one env
var and cannot enable code that was never built."
Task 5: Reproduce the default-profile boot failures
Spec §3.1 records that local and dev both fail to start with shipped defaults. Those are the two
failures Wave 1's exit criterion has to clear, so they need to be executable rather than a line in a
table.
Files:
- Create:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/DefaultProfileBootCharacterizationTest.java
Interfaces:
- Consumes: nothing.
- Produces:
DefaultProfileBootCharacterizationTest— Wave 1's "all-off boots on local/dev/prod" exit criterion is measured by these cases plus Wave 3's Compose lanes.
Context the implementer needs:
The two failures and their exact causes, verified at HEAD:
local—app.messaging.brokeris blank inapplication-local.ymlwhileca-skeleton.outbox.relay-enabledistrueinapplication.yml:539.OutboxRelayBrokerRequirementValidatorrejects the combination, because a relay that claims PENDING rows and fails every publish would exhaust them to DEAD.dev— the trackedsrc/.envsetsAPP_DATASOURCE_DDL_AUTO=update, whichJpaSchemaSafetyValidatorrejects wherever Flyway owns the schema.
prod additionally fails at PostgreSqlTransportSecurityValidator (no TLS on the JDBC URL), which
is correct behaviour and is asserted as such — a validator rejecting an unsafe URL is the system
working. What is wrong is only that there is no TLS-capable prod smoke to satisfy it, which is Wave
3's problem.
This test asserts on the startup failure's cause chain, not on a log string, so that a reworded message does not silently turn it green.
- Step 1: Write the failing test
Create src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/DefaultProfileBootCharacterizationTest.java:
package dev.caskeleton.bootstrap.activation;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import dev.caskeleton.bootstrap.CaSkeletonApplication;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
/**
* The shipped defaults do not start.
*
* <p>Both failures are the same class of mistake: a capability that consumes an adapter defaults to
* on while the adapter it consumes defaults to off. The relay is enabled with no broker; the schema
* is Flyway-owned with {@code ddl-auto=update}. Neither validator is wrong to refuse — the defaults
* they are refusing are.
*
* <p>Asserted on the cause chain rather than on a message, because a validator whose wording changes
* must not quietly turn this green.
*/
class DefaultProfileBootCharacterizationTest {
@Test
@Tag("wave0-red")
@DisplayName("RED: the local profile starts with shipped defaults")
void localProfileStartsWithShippedDefaults() {
assertProfileStarts("local");
}
@Test
@Tag("wave0-red")
@DisplayName("RED: the dev profile starts with shipped defaults")
void devProfileStartsWithShippedDefaults() {
assertProfileStarts("dev");
}
@Test
@DisplayName("the prod profile refuses a JDBC URL without verify-full TLS")
void prodProfileRefusesPlaintextJdbc() {
Throwable thrown = catchThrowable(() -> startAndClose("prod"));
assertThat(thrown)
.as(
"a production datasource without sslmode=verify-full must be refused at startup; "
+ "this case documents a validator working, and the missing piece is a TLS-capable "
+ "prod smoke environment rather than a code fix")
.isNotNull();
}
private static void assertProfileStarts(String profile) {
Throwable thrown = catchThrowable(() -> startAndClose(profile));
assertThat(thrown)
.as(
"the %s profile must start with the values this repository ships, with no operator "
+ "override; it currently fails because a capability that consumes an adapter "
+ "defaults to on while the adapter defaults to off",
profile)
.isNull();
}
private static void startAndClose(String profile) {
try (ConfigurableApplicationContext context =
new SpringApplicationBuilder(CaSkeletonApplication.class)
.web(WebApplicationType.NONE)
.profiles(profile)
.run()) {
assertThat(context.isRunning()).isTrue();
}
}
}
- Step 2: Run it
Run:
cd src
./gradlew :app-bootstrap:test --tests '*DefaultProfileBootCharacterizationTest*' \
--console=plain --no-daemon
Expected: localProfileStartsWithShippedDefaults and devProfileStartsWithShippedDefaults fail;
prodProfileRefusesPlaintextJdbc passes.
- Step 3: Record the two exact cause chains
From the test report, copy the root exception type and message for each of the two red cases into the
evidence log under ## Task 5. Wave 1 must reference these exact validators when it changes their
defaults.
- Step 4: Commit
git add src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/DefaultProfileBootCharacterizationTest.java \
docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md
git commit -m "test(bootstrap): reproduce the local and dev default-boot failures
Both are the same mistake from two directions: a capability that consumes an
adapter defaults to on while the adapter defaults to off. Asserted on the
cause chain rather than a log string so a reworded validator cannot turn the
characterization green."
Task 6: Capture startup warnings as an assertable signal
Spec §9 requires zero WARN/ERROR at startup with an empty allowlist. Wave 4 does that work; it needs a measuring instrument that exists before the work starts, and a self-test proving the instrument itself is not the thing that is broken.
Files:
- Create:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/StartupWarningRecorder.java - Create:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/StartupWarningRecorderTest.java - Create:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/StartupWarningZeroTest.java
Interfaces:
-
Consumes: nothing.
-
Produces:
StartupWarningRecorder.install()→StartupWarningRecorder(attaches to the Logback root logger)List<String> StartupWarningRecorder.records()—"<LEVEL> <loggerName> :: <message>", in emission ordervoid StartupWarningRecorder.close()— detaches;AutoCloseable
Wave 4 Task 1 and Wave 3's runtime-smoke evidence both consume
records().
Context the implementer needs:
The known non-zero warnings at HEAD, from spec §9.1 — the recorder must be able to see all of them:
BeanPostProcessorCheckerearly-instantiation warnings forRolePermissionPolicy,RolePermissionRegistry,AuthorizationAdapter- two Micrometer warnings about a
MeterFilteradded after meters were already registered - on
dev, aBeanPostProcessorCheckerwarning about a Flyway converter
StartupWarningRecorder attaches to Logback's root logger. It must be installed before the context
starts and detached in a finally, or a leaked appender makes every later test in the same JVM
report the previous test's warnings.
- Step 1: Write the recorder
Create src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/StartupWarningRecorder.java:
package dev.caskeleton.bootstrap.activation;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.AppenderBase;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.slf4j.LoggerFactory;
/**
* Captures every WARN and ERROR emitted while a context starts.
*
* <p>A warning-zero rule enforced by reading the console is a rule nobody runs. This turns the same
* signal into an assertion, and keeps the raw records so a failure names the warnings rather than
* only counting them.
*
* <p>Installed before the context starts and detached in a finally block. A leaked appender would
* make the next test in the same JVM report this test's warnings, which is the kind of failure that
* costs an afternoon.
*/
public final class StartupWarningRecorder implements AutoCloseable {
private final List<String> records = new CopyOnWriteArrayList<>();
private final Logger rootLogger;
private final AppenderBase<ILoggingEvent> appender;
private StartupWarningRecorder() {
this.rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
this.appender =
new AppenderBase<>() {
@Override
protected void append(ILoggingEvent event) {
if (event.getLevel().isGreaterOrEqual(Level.WARN)) {
records.add(
event.getLevel() + " " + event.getLoggerName() + " :: " + event.getFormattedMessage());
}
}
};
this.appender.setContext(rootLogger.getLoggerContext());
this.appender.start();
this.rootLogger.addAppender(appender);
}
/**
* Attaches a recorder to the root logger.
*
* @return the started recorder; close it to detach
*/
public static StartupWarningRecorder install() {
return new StartupWarningRecorder();
}
/**
* Returns the captured WARN and ERROR records in emission order.
*
* @return the records, each rendered as {@code LEVEL logger :: message}
*/
public List<String> records() {
return List.copyOf(records);
}
@Override
public void close() {
rootLogger.detachAppender(appender);
appender.stop();
}
}
- Step 2: Write the recorder's self-test
Create src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/StartupWarningRecorderTest.java:
package dev.caskeleton.bootstrap.activation;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
/** The instrument has to work before a zero-warning claim made with it means anything. */
class StartupWarningRecorderTest {
@Test
@DisplayName("captures WARN and ERROR, ignores INFO, and detaches on close")
void capturesWhatItClaimsTo() {
var logger = LoggerFactory.getLogger("test.subject");
try (StartupWarningRecorder recorder = StartupWarningRecorder.install()) {
logger.info("ignored");
logger.warn("a warning");
logger.error("an error");
assertThat(recorder.records())
.as("only WARN and above are recorded, in emission order")
.containsExactly(
"WARN test.subject :: a warning", "ERROR test.subject :: an error");
}
try (StartupWarningRecorder second = StartupWarningRecorder.install()) {
logger.warn("after reinstall");
assertThat(second.records())
.as("a closed recorder must not keep receiving events, or later tests inherit them")
.containsExactly("WARN test.subject :: after reinstall");
}
}
}
- Step 3: Run the self-test
Run:
cd src
./gradlew :app-bootstrap:test --tests '*StartupWarningRecorderTest*' --console=plain --no-daemon
Expected: PASS. If this is red, fix it here — it is an instrument, not a characterization.
- Step 4: Write the warning-zero red
Create src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/StartupWarningZeroTest.java:
package dev.caskeleton.bootstrap.activation;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.bootstrap.CaSkeletonApplication;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
/**
* Starting the application emits no warning and no error.
*
* <p>The allowlist is empty and stays empty. A warning that cannot be removed today belongs in a
* registry entry with an owner, an upstream issue, and an expiry — not in a quiet exception here.
*/
class StartupWarningZeroTest {
@Test
@Tag("wave0-red")
@DisplayName("RED: an all-off local startup emits no WARN and no ERROR")
void allOffLocalStartupIsSilent() {
List<String> warnings;
try (StartupWarningRecorder recorder = StartupWarningRecorder.install()) {
try (ConfigurableApplicationContext context =
new SpringApplicationBuilder(CaSkeletonApplication.class)
.web(WebApplicationType.NONE)
.profiles("local")
.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.relay-enabled=false",
"ca-skeleton.idempotency.provider=disabled",
"management.endpoint.health.group.readiness.include=readinessState")
.run()) {
assertThat(context.isRunning()).isTrue();
}
warnings = recorder.records();
}
assertThat(warnings)
.as(
"startup must be silent with an empty allowlist; these were emitted:%n%s",
String.join(System.lineSeparator(), warnings))
.isEmpty();
}
}
- Step 5: Run it and record the warnings
Run:
cd src
./gradlew :app-bootstrap:test --tests '*StartupWarningZeroTest*' --console=plain --no-daemon
Expected: FAIL, listing the BeanPostProcessorChecker and Micrometer warnings. Copy the full list into
the evidence log under ## Task 6 — Wave 4 works down exactly that list.
Note: if the context cannot start (Task 5's failures), this test fails for that reason instead. Record which it was; the warning list becomes available once Wave 1 clears the boot.
- Step 6: Commit
git add src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/StartupWarningRecorder.java \
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/StartupWarningRecorderTest.java \
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/activation/StartupWarningZeroTest.java \
docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md
git commit -m "test(bootstrap): make startup warnings an assertion instead of console reading
A warning-zero rule enforced by reading the console is a rule nobody runs.
The recorder self-tests first, because a zero-warning claim made with a
broken instrument is worse than no claim."
Task 7: Reproduce the dev Compose merge failure
Files:
- Create:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/compose/ComposeMergeCharacterizationTest.java
Interfaces:
- Consumes: nothing.
- Produces:
ComposeMergeCharacterizationTest— Wave 3 Task 2 (the!overridetmpfs fix) turns the dev case green, and Wave 3'sverify-compose-profile-contracts.shsupersedes this test's job for the full lane matrix.
Context the implementer needs:
Verified at HEAD by running the command directly:
$ docker compose -f docker-compose.yml -f docker-compose.dev.yml config
services.app.volumes[1]: target /var/tmp/heap already mounted as services.app.tmpfs[1]
docker-compose.yml:53-69 declares /var/tmp/heap as a tmpfs; docker-compose.dev.yml:19-21,55-60
bind-mounts the same target so heap dumps survive on the host. Compose refuses the collision. The fix
belongs to Wave 3 (tmpfs: !override [] in the dev overlay, which needs Compose ≥ 2.24.4 — this
machine has 5.4.0).
The test shells out to docker compose, so it must skip cleanly rather than fail where Docker is
absent. Assumptions.assumeTrue is the right tool: a machine without Docker reports "skipped", and
CI — which has Docker — reports the real result.
- Step 1: Write the failing test
Create src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/compose/ComposeMergeCharacterizationTest.java:
package dev.caskeleton.bootstrap.compose;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
/**
* Every shipped Compose file stack must at least render.
*
* <p>A stack that cannot merge has no failure mode short of total: nothing starts, and the error
* arrives at the moment somebody most wants the environment. The dev stack is in exactly that state
* — the base declares {@code /var/tmp/heap} as a tmpfs and the dev overlay bind-mounts the same
* target so heap dumps survive on the host, and Compose refuses the collision rather than choosing.
*/
class ComposeMergeCharacterizationTest {
private static final Path REPOSITORY_ROOT = repositoryRoot();
@Test
@DisplayName("the base stack renders")
void baseStackRenders() {
assertStackRenders("docker-compose.yml");
}
@Test
@DisplayName("the local stack renders")
void localStackRenders() {
assertStackRenders("docker-compose.yml", "docker-compose.local.yml");
}
@Test
@Tag("wave0-red")
@DisplayName("RED: the dev stack renders")
void devStackRenders() {
assertStackRenders("docker-compose.yml", "docker-compose.dev.yml");
}
private static void assertStackRenders(String... files) {
Assumptions.assumeTrue(dockerComposeIsAvailable(), "docker compose is not on this machine");
List<String> command = new ArrayList<>(List.of("docker", "compose"));
for (String file : files) {
command.add("-f");
command.add(file);
}
command.add("config");
ProcessResult result = run(command);
assertThat(result.exitCode())
.as(
"the %s stack must render; docker compose said:%n%s",
String.join(" + ", files), result.output())
.isZero();
}
private static boolean dockerComposeIsAvailable() {
try {
return run(List.of("docker", "compose", "version", "--short")).exitCode() == 0;
} catch (RuntimeException failure) {
return false;
}
}
private static ProcessResult run(List<String> command) {
try {
Process process =
new ProcessBuilder(command)
.directory(REPOSITORY_ROOT.toFile())
.redirectErrorStream(true)
.start();
String output = new String(process.getInputStream().readAllBytes());
if (!process.waitFor(120, TimeUnit.SECONDS)) {
process.destroyForcibly();
throw new IllegalStateException("docker compose did not finish within 120s");
}
return new ProcessResult(process.exitValue(), output);
} catch (IOException failure) {
throw new IllegalStateException("could not run " + command, failure);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted while running " + command, interrupted);
}
}
private static Path repositoryRoot() {
Path candidate = Path.of("").toAbsolutePath();
while (candidate != null && !Files.isRegularFile(candidate.resolve("docker-compose.yml"))) {
candidate = candidate.getParent();
}
if (candidate == null) {
throw new IllegalStateException("could not locate the repository root from the test cwd");
}
return candidate;
}
private record ProcessResult(int exitCode, String output) {}
}
- Step 2: Run it
Run:
cd src
./gradlew :app-bootstrap:test --tests '*ComposeMergeCharacterizationTest*' --console=plain --no-daemon
Expected: base and local pass; dev fails with
services.app.volumes[1]: target /var/tmp/heap already mounted as services.app.tmpfs[1].
- Step 3: Commit
git add src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/compose/ComposeMergeCharacterizationTest.java
git commit -m "test(bootstrap): reproduce the dev compose merge failure
A stack that cannot merge has no partial failure mode, and the error arrives
exactly when somebody needs the environment. Skips rather than fails where
Docker is absent so a laptop without it does not report a false red."
Task 8: Gate registry membership against the resolved runtime classpath
Spec §6.3 (MSG-INT-002) says the current membership gate compares direct project dependencies, so a transitive leaf can reach the bootJar while the registry records it as belonging to nothing. Wave 1 changes the gate; Wave 0 builds the measurement it will be judged by.
Files:
- Modify:
src/app-bootstrap/build.gradle(add theruntimeClasspathManifesttask) - Create:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/registry/RuntimeMembershipClasspathAgreementTest.java
Interfaces:
-
Consumes: nothing.
-
Produces:
- Gradle task
:app-bootstrap:runtimeClasspathManifest, writingapp-bootstrap/build/architecture/runtime-project-closure.txt— one registry module ID per line, sorted, for every project on the resolvedruntimeClasspath. RuntimeMembershipClasspathAgreementTest, which compares that file against the registry'sruntime_memberships.
Wave 1 Task 4 replaces the direct-dependency gate in
src/build.gradlewith a closure-based one and consumes this same manifest. - Gradle task
Context the implementer needs:
runtimeClasspath resolves to a mix of project and external components. Only project components map
to registry IDs. The mapping from a Gradle project path (:adapter:outbound:messaging) to a registry
ID (adapter-outbound-messaging) is not mechanical — read it from modules.json's gradle_path
field rather than deriving it by string substitution, because
:adapter:outbound:persistence-jpa → adapter-outbound-persistence-jpa and
:messaging:messaging-core-api → messaging-core-api follow different shapes.
At HEAD this test is expected to be green: the direct dependencies and the closure agree, because none of the build-only leaves is reachable. Its value is as the gate that must stay green while Wave 1 and Wave 2 add edges — the moment a starter drags six transitive leaves onto the classpath, this goes red until the registry records them.
- Step 1: Add the manifest task
In src/app-bootstrap/build.gradle, append:
// Wave 0 / spec MSG-INT-002 — the membership gate must judge what actually ships.
// A direct-dependency comparison cannot see a leaf a starter pulls in transitively, so the leaf
// reaches the bootJar while the registry records it as belonging to no runtime at all.
tasks.register('runtimeClasspathManifest') {
description = 'Writes the registry IDs of every project on the resolved runtime classpath.'
group = 'verification'
File registryFile = file("${rootProject.projectDir}/config/architecture/modules.json")
File manifest = layout.buildDirectory.file('architecture/runtime-project-closure.txt').get().asFile
Provider<Set<String>> projectPaths = provider {
configurations.runtimeClasspath.incoming.resolutionResult.allComponents
.findAll { it.id instanceof org.gradle.api.artifacts.component.ProjectComponentIdentifier }
.collect { (it.id as org.gradle.api.artifacts.component.ProjectComponentIdentifier).projectPath }
.toSet()
}
inputs.file registryFile
inputs.property 'projectPaths', projectPaths
outputs.file manifest
doLast {
Map<String, String> idByGradlePath = new groovy.json.JsonSlurper()
.parse(registryFile)
.modules
.collectEntries { [(it.gradle_path): it.id] }
List<String> unknown = projectPaths.get().findAll { !idByGradlePath.containsKey(it) }.sort()
if (!unknown.isEmpty()) {
throw new GradleException(
"runtime classpath contains project(s) absent from the architecture registry: " +
"${unknown}. Register the leaf before shipping it.")
}
manifest.parentFile.mkdirs()
manifest.text = projectPaths.get().collect { idByGradlePath[it] }.sort().join('\n') + '\n'
}
}
- Step 2: Run the task and read the manifest
Run:
cd src
./gradlew :app-bootstrap:runtimeClasspathManifest --console=plain --no-daemon
cat app-bootstrap/build/architecture/runtime-project-closure.txt
Expected: a sorted list of registry IDs. Record it in the evidence log under ## Task 8.
- Step 3: Write the agreement test
Create src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/registry/RuntimeMembershipClasspathAgreementTest.java:
package dev.caskeleton.bootstrap.registry;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* What the registry says ships, and what the runtime classpath actually resolves, must be the same
* set.
*
* <p>The existing gate compares direct project dependencies. That comparison cannot see a leaf a
* starter pulls in transitively, so the leaf reaches the bootJar while the registry records it as
* belonging to no runtime at all — the membership list stays clean precisely because it is not
* looking at what ships.
*
* <p>Green at Wave 0, and that is the point: it is the gate that must stay green while Waves 1 and 2
* add the Mongo, GraphQL, and messaging edges.
*/
class RuntimeMembershipClasspathAgreementTest {
private static final String COMPOSITION_ROOT = "app-bootstrap";
@Test
@DisplayName("every project on the runtime classpath records app-bootstrap membership")
void classpathAndRegistryAgree() throws IOException {
Path manifest =
repositoryRoot().resolve("src/app-bootstrap/build/architecture/runtime-project-closure.txt");
Assumptions.assumeTrue(
Files.isRegularFile(manifest),
"run :app-bootstrap:runtimeClasspathManifest first; this test reads its output");
List<String> onClasspath = Files.readAllLines(manifest).stream().filter(l -> !l.isBlank()).toList();
List<String> declared = declaredMembers();
assertThat(onClasspath)
.as(
"these projects resolve onto the composition root's runtime classpath but do not "
+ "declare app-bootstrap in runtime_memberships, so the registry describes a jar "
+ "that is not the one being built")
.allSatisfy(id -> assertThat(declared).contains(id));
assertThat(declared)
.as(
"these leaves declare app-bootstrap membership but do not resolve onto its runtime "
+ "classpath, so the registry promises something the jar does not carry")
.allSatisfy(id -> assertThat(onClasspath).contains(id));
}
private static List<String> declaredMembers() throws IOException {
JsonNode registry =
new ObjectMapper()
.readTree(repositoryRoot().resolve("src/config/architecture/modules.json").toFile());
List<String> declared = new ArrayList<>();
for (JsonNode module : registry.get("modules")) {
for (JsonNode membership : module.get("runtime_memberships")) {
if (COMPOSITION_ROOT.equals(membership.asText())) {
declared.add(module.get("id").asText());
}
}
}
return declared;
}
private static Path repositoryRoot() {
Path candidate = Path.of("").toAbsolutePath();
while (candidate != null
&& !Files.isRegularFile(candidate.resolve("src/config/architecture/modules.json"))) {
candidate = candidate.getParent();
}
if (candidate == null) {
throw new IllegalStateException("could not locate the repository root from the test cwd");
}
return candidate;
}
}
- Step 4: Wire the manifest as a test input and run
In src/app-bootstrap/build.gradle, make the ordinary test lane depend on the manifest so the
assumeTrue never silently skips in CI:
tasks.named('test') {
dependsOn tasks.named('runtimeClasspathManifest')
}
Run:
cd src
./gradlew :app-bootstrap:test --tests '*RuntimeMembershipClasspathAgreementTest*' \
--console=plain --no-daemon
Expected: PASS. If it fails at HEAD, that is a genuine finding — record both sides of the diff in the evidence log and treat closing it as a Wave 1 input.
- Step 5: Commit
git add src/app-bootstrap/build.gradle \
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/registry/RuntimeMembershipClasspathAgreementTest.java \
docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md
git commit -m "test(bootstrap): gate registry membership on the resolved runtime closure
A direct-dependency comparison cannot see a leaf a starter pulls in
transitively, so the leaf reaches the bootJar while the registry records it
as belonging to nothing. Green today, and that is the point: it is the gate
that stays green while Waves 1 and 2 add the Mongo, GraphQL, and messaging
edges."
Task 9: Fail a release manifest that names a task which does not exist
Spec §12.2: the Mongo release registry names mongoShardedTest, mongoAtlasTest, and mongoKmsTest.
Verified at HEAD: src/config/mongodb/release-contracts.json names all three
(lines 30, 38, 46), and src/adapter/outbound/persistence-mongo/build.gradle registers seven mongo
lanes, none of which is any of them. A manifest that reports green while naming a task that cannot
run is worse than no manifest.
Files:
- Create:
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/registry/ReleaseManifestTaskExistenceTest.java - Modify:
src/build.gradle(registerverifyReleaseManifestTasks)
Interfaces:
- Consumes: nothing.
- Produces:
verifyReleaseManifestTasksGradle task andReleaseManifestTaskExistenceTest. Wave 2's Mongo work must either implement the three lanes or demote their Stable blocking claim; either way this test is the arbiter.
Context the implementer needs:
The three missing tasks are not to be created in Wave 0, and their manifest entries are not to be deleted in Wave 0. Wave 0 only makes the discrepancy fail. Spec §14 is explicit that the resolution is a Wave 2 decision with two legitimate outcomes, and pre-empting it here would decide it by accident.
- Step 1: Write the failing test
Create src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/registry/ReleaseManifestTaskExistenceTest.java:
package dev.caskeleton.bootstrap.registry;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
/**
* A release manifest may only name gates that exist.
*
* <p>A manifest entry pointing at a task nobody registered does not fail — it is simply never run,
* and the release reports green for a capability that was never qualified. That is a worse outcome
* than an obviously missing gate, because it produces evidence.
*
* <p>Red at Wave 0 on three Mongo lanes. The fix is a Wave 2 decision with two legitimate answers:
* implement the lanes with protected-environment evidence, or demote the Stable blocking claim to an
* explicit experimental promotion. Wave 0 only refuses to let the discrepancy stay quiet.
*/
class ReleaseManifestTaskExistenceTest {
private static final Pattern REGISTERED_TASK =
Pattern.compile("tasks\\.register\\(\\s*'([A-Za-z0-9_]+)'");
@Test
@Tag("wave0-red")
@DisplayName("RED: every task named by the Mongo release contract is registered")
void mongoReleaseContractNamesOnlyRegisteredTasks() throws IOException {
Path root = repositoryRoot();
List<String> named =
taskNamesIn(root.resolve("src/config/mongodb/release-contracts.json"));
List<String> registered =
registeredTaskNamesIn(
root.resolve("src/adapter/outbound/persistence-mongo/build.gradle"));
List<String> missing = named.stream().filter(task -> !registered.contains(task)).sorted().toList();
assertThat(missing)
.as(
"the Mongo release contract names task(s) that no build file registers, so a release "
+ "manifest can report them green without ever running them; registered lanes are %s",
registered)
.isEmpty();
}
private static List<String> taskNamesIn(Path manifest) throws IOException {
JsonNode root = new ObjectMapper().readTree(manifest.toFile());
List<String> tasks = new ArrayList<>();
collectTaskFields(root, tasks);
return tasks;
}
private static void collectTaskFields(JsonNode node, List<String> into) {
if (node.isObject()) {
JsonNode task = node.get("task");
if (task != null && task.isTextual()) {
into.add(task.asText());
}
node.fields().forEachRemaining(entry -> collectTaskFields(entry.getValue(), into));
} else if (node.isArray()) {
node.forEach(child -> collectTaskFields(child, into));
}
}
private static List<String> registeredTaskNamesIn(Path buildFile) throws IOException {
Matcher matcher = REGISTERED_TASK.matcher(Files.readString(buildFile));
List<String> names = new ArrayList<>();
while (matcher.find()) {
names.add(matcher.group(1));
}
return names;
}
private static Path repositoryRoot() {
Path candidate = Path.of("").toAbsolutePath();
while (candidate != null
&& !Files.isRegularFile(candidate.resolve("src/config/architecture/modules.json"))) {
candidate = candidate.getParent();
}
if (candidate == null) {
throw new IllegalStateException("could not locate the repository root from the test cwd");
}
return candidate;
}
}
- Step 2: Run it
Run:
cd src
./gradlew :app-bootstrap:test --tests '*ReleaseManifestTaskExistenceTest*' --console=plain --no-daemon
Expected: FAIL, naming [mongoAtlasTest, mongoKmsTest, mongoShardedTest].
- Step 3: Commit
git add src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/registry/ReleaseManifestTaskExistenceTest.java
git commit -m "test(bootstrap): fail a release manifest naming a task that does not exist
A manifest entry pointing at an unregistered task never fails; it is simply
never run, and the release reports green for a capability nobody qualified.
Three Mongo lanes are in that state. Wave 0 only refuses to let it stay
quiet — whether to implement or demote them is a Wave 2 decision."
Task 10: Give the red set a single reporting lane
Waves 1–4 need one command that answers "what is still red from the baseline?". Without it, the answer is assembled by hand from six test classes and drifts immediately.
Files:
- Modify:
src/app-bootstrap/build.gradle(registerwave0Red) - Modify:
src/build.gradle(register the aggregatewave0RedReport)
Interfaces:
- Consumes: the
@Tag("wave0-red")annotations from Tasks 1, 3, 4, 5, 6, 7, 9. - Produces:
./gradlew wave0RedReport— runs everywave0-red-tagged test across modules and prints the remaining red set. Waves 1–4 run it at each wave boundary.
Context the implementer needs:
This lane must not fail the build when red — it is a report, not a gate. ignoreFailures = true
plus a doLast that prints the summary is the right shape. The gate that does fail is Wave 6's
requirement that the tag set be empty.
- Step 1: Register the module lane
In src/app-bootstrap/build.gradle, append:
// Wave 0 — a report, not a gate. Waves 1-4 run this at each boundary to see what is still red;
// the gate that fails is Wave 6's requirement that no wave0-red tag survives at all.
tasks.register('wave0Red', Test) {
description = 'Reports which Wave 0 baseline characterizations are still red.'
group = 'verification'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'wave0-red' }
ignoreFailures = true
outputs.upToDateWhen { false }
reports.junitXml.required = true
reports.junitXml.outputLocation = layout.buildDirectory.dir('test-results/wave0Red')
}
- Step 2: Register the equivalent lane in the messaging observability module
In src/messaging/messaging-observability/build.gradle, append the same block, changing only the
description. Task 1's characterization lives there.
- Step 3: Register the aggregate
In src/build.gradle, append:
// Wave 0 red-set report. Aggregates the per-module wave0Red lanes so one command answers
// "what is still red from the baseline?" — the question every wave boundary asks.
tasks.register('wave0RedReport') {
description = 'Runs every Wave 0 baseline characterization and reports the remaining red set.'
group = 'verification'
dependsOn ':app-bootstrap:wave0Red', ':messaging:messaging-observability:wave0Red'
}
- Step 4: Run it
Run:
cd src
./gradlew wave0RedReport --console=plain --no-daemon
Expected: completes with BUILD SUCCESSFUL (because ignoreFailures = true) and reports the failing
tagged tests. Record the count and names in the evidence log under ## Task 10 as the Wave 0 exit
state.
- Step 5: Commit
git add src/app-bootstrap/build.gradle src/messaging/messaging-observability/build.gradle src/build.gradle \
docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md
git commit -m "build: add the wave0Red reporting lane
One command answers what is still red from the baseline. A report rather
than a gate: the gate that fails is Wave 6's requirement that no wave0-red
tag survives at all."
Wave 0 Exit Criteria
Wave 0 is done when all of the following hold, each with recorded output in
docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md:
./gradlew wave0RedReport --console=plain --no-daemonruns and lists a red set that matches the table below.- Every non-tagged test added by this plan is green.
./gradlew :app-bootstrap:compileTestJava :messaging:messaging-observability:compileTestJava --console=plain --no-daemonsucceeds.- No production source file was modified. Verify with
git diff --name-only <wave0-base>..HEAD | grep '/src/main/'returning nothing.
Expected Wave 0 red set — each entry names the wave that closes it:
| Red test | Closed by |
|---|---|
SecretLeakScannerCharacterizationTest.methodCallWithSafeSuffixIsNotALeak |
Wave 2 (MSG-INT-005) |
SecretLeakScannerCharacterizationTest.numericFencingIsNotALeak |
Wave 2 (MSG-INT-005) |
FiveAdapterOffInventoryTest.jpaOffHoldsNothing |
Wave 1 + Wave 2 (JPA-INT-001, JPA-INT-004) |
FiveAdapterOffInventoryTest.messagingOffHoldsNothing |
Wave 1 (structural gating) |
FiveAdapterOffInventoryTest.notificationOffHoldsNothing |
Wave 1 (NTF-INT-005 scan narrowing) |
ShippedRuntimeFacadePresenceTest.mongoFacadeIsShipped |
Wave 1 (MNG-INT-001) |
ShippedRuntimeFacadePresenceTest.graphQlFacadeIsShipped |
Wave 1 (GQL-INT-001) |
ShippedRuntimeFacadePresenceTest.messagingPlatformFacadeIsShipped |
Wave 2 (MSG-INT-002) |
DefaultProfileBootCharacterizationTest.localProfileStartsWithShippedDefaults |
Wave 1 (MSG-INT-001) |
DefaultProfileBootCharacterizationTest.devProfileStartsWithShippedDefaults |
Wave 3 (env separation) |
StartupWarningZeroTest.allOffLocalStartupIsSilent |
Wave 4 |
ComposeMergeCharacterizationTest.devStackRenders |
Wave 3 |
ReleaseManifestTaskExistenceTest.mongoReleaseContractNamesOnlyRegisteredTasks |
Wave 2 |
If the observed red set differs from this table, the difference is itself a finding: record it, and carry it into the wave named in the table rather than adjusting the table to match.
What Wave 0 explicitly does not do
Carried from the index's scope boundaries, restated so an executor reading this plan alone cannot over-reach:
- No production code changes, including no fix to the secret scanner.
- No registry edits, no
build.gradledependency edges, noAutoConfiguration.importschanges. - No decision on the three ghost Mongo lanes — implement-or-demote is Wave 2's call.
- No new Compose files, no Keycloak realm, no MinIO fixture — Wave 3 owns all of them.
- No
@Disabled, no allowlist entry, and no deletion of a failing assertion to reach a green build.