# 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.
> **Read [`2026-08-15-five-adapter-runtime-remediation-index.md`](2026-08-15-five-adapter-runtime-remediation-index.md)
> first** — 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`
+ `SpringApplication` process harness, Gradle 9 custom `Test` lanes, `docker compose config` as a
subprocess.
**Spec:** [`docs/superpowers/specs/2026-08-15-five-adapter-runtime-remediation-review-design.md`](../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 `@Disabled` it.
- A red test must fail with a message that names *what* is wrong, not just `expected true but was
false`. Every assertion below carries an `as(...)` / `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 make
`methodCallWithSafeSuffixIsNotALeak` and `numericFencingIsNotALeak` pass **without** changing
`aConcatenatedCredentialIsALeak` or `aConcatenatedPayloadIsALeak`.
**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_OPERAND` captures `oauth.credentialId()` **with** the
trailing `()`, so `tail` is `credentialId()`; `DESCRIBES_RATHER_THAN_REVEALS` is
`(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`:
```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.
*
*
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.
*
*
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 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(
"(?[A-Za-z_][\\w.]*(?:\\(\\))?)\\s*\\+|\\+\\s*(?[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 operandsAdjacentToConcatenation(String code) {
List 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:
```bash
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:
```bash
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 beanNamesOwnedBy(ApplicationContext context, Adapter adapter)`
- `static List ownedBeanTypeNames(ApplicationContext context, Adapter adapter)`
- `static List 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`:
```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?".
*
* 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.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 projectPackages;
private final Set vendorPackages;
private final String threadNamePattern;
Adapter(Set projectPackages, Set 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 beanNamesOwnedBy(ApplicationContext context, Adapter adapter) {
List 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 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.
*
* 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 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:
```bash
cd src
./gradlew :app-bootstrap:compileTestJava --console=plain --no-daemon
```
Expected: `BUILD SUCCESSFUL`.
- [ ] **Step 3: Commit**
Report to the human:
```bash
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`,
`.describe` from 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`:
```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.
*
* 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.
*
*
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:
```bash
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**
```bash
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.MongoPlatformAutoConfiguration`
- `dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlPlatformAutoConfiguration`
- `dev.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`:
```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.
*
*
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:
```bash
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**
```bash
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:
1. `local` — `app.messaging.broker` is blank in `application-local.yml` while
`ca-skeleton.outbox.relay-enabled` is `true` in `application.yml:539`.
`OutboxRelayBrokerRequirementValidator` rejects the combination, because a relay that claims
PENDING rows and fails every publish would exhaust them to DEAD.
2. `dev` — the tracked `src/.env` sets `APP_DATASOURCE_DDL_AUTO=update`, which
`JpaSchemaSafetyValidator` rejects 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`:
```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.
*
*
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.
*
*
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:
```bash
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**
```bash
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 StartupWarningRecorder.records()` — `" :: "`, in
emission order
- `void 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:
- `BeanPostProcessorChecker` early-instantiation warnings for `RolePermissionPolicy`,
`RolePermissionRegistry`, `AuthorizationAdapter`
- two Micrometer warnings about a `MeterFilter` added after meters were already registered
- on `dev`, a `BeanPostProcessorChecker` warning 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`:
```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.
*
* 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.
*
*
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 records = new CopyOnWriteArrayList<>();
private final Logger rootLogger;
private final AppenderBase 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 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`:
```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:
```bash
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`:
```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.
*
* 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 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:
```bash
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**
```bash
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 `!override` tmpfs fix) turns the
dev case green, and Wave 3's `verify-compose-profile-contracts.sh` supersedes 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`:
```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.
*
* 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 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 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:
```bash
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**
```bash
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 the `runtimeClasspathManifest` task)
- Create: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/registry/RuntimeMembershipClasspathAgreementTest.java`
**Interfaces:**
- Consumes: nothing.
- Produces:
- Gradle task `:app-bootstrap:runtimeClasspathManifest`, writing
`app-bootstrap/build/architecture/runtime-project-closure.txt` — one registry module ID per line,
sorted, for every **project** on the resolved `runtimeClasspath`.
- `RuntimeMembershipClasspathAgreementTest`, which compares that file against the registry's
`runtime_memberships`.
Wave 1 Task 4 replaces the direct-dependency gate in `src/build.gradle` with a closure-based one
and consumes this same manifest.
**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:
```groovy
// 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> 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 idByGradlePath = new groovy.json.JsonSlurper()
.parse(registryFile)
.modules
.collectEntries { [(it.gradle_path): it.id] }
List 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:
```bash
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`:
```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.
*
* 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.
*
*
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 onClasspath = Files.readAllLines(manifest).stream().filter(l -> !l.isBlank()).toList();
List 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 declaredMembers() throws IOException {
JsonNode registry =
new ObjectMapper()
.readTree(repositoryRoot().resolve("src/config/architecture/modules.json").toFile());
List 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:
```groovy
tasks.named('test') {
dependsOn tasks.named('runtimeClasspathManifest')
}
```
Run:
```bash
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**
```bash
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` (register `verifyReleaseManifestTasks`)
**Interfaces:**
- Consumes: nothing.
- Produces: `verifyReleaseManifestTasks` Gradle task and `ReleaseManifestTaskExistenceTest`. 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`:
```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.
*
* 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.
*
*
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 named =
taskNamesIn(root.resolve("src/config/mongodb/release-contracts.json"));
List registered =
registeredTaskNamesIn(
root.resolve("src/adapter/outbound/persistence-mongo/build.gradle"));
List 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 taskNamesIn(Path manifest) throws IOException {
JsonNode root = new ObjectMapper().readTree(manifest.toFile());
List tasks = new ArrayList<>();
collectTaskFields(root, tasks);
return tasks;
}
private static void collectTaskFields(JsonNode node, List 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 registeredTaskNamesIn(Path buildFile) throws IOException {
Matcher matcher = REGISTERED_TASK.matcher(Files.readString(buildFile));
List 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:
```bash
cd src
./gradlew :app-bootstrap:test --tests '*ReleaseManifestTaskExistenceTest*' --console=plain --no-daemon
```
Expected: FAIL, naming `[mongoAtlasTest, mongoKmsTest, mongoShardedTest]`.
- [ ] **Step 3: Commit**
```bash
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` (register `wave0Red`)
- Modify: `src/build.gradle` (register the aggregate `wave0RedReport`)
**Interfaces:**
- Consumes: the `@Tag("wave0-red")` annotations from Tasks 1, 3, 4, 5, 6, 7, 9.
- Produces: `./gradlew wave0RedReport` — runs every `wave0-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:
```groovy
// 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:
```groovy
// 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:
```bash
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**
```bash
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-daemon` runs 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-daemon` succeeds.
- [ ] No production source file was modified. Verify with
`git diff --name-only ..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.gradle` dependency edges, no `AutoConfiguration.imports` changes.
- 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.