- 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>
1851 lines
76 KiB
Markdown
1851 lines
76 KiB
Markdown
# 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.
|
||
*
|
||
* <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:
|
||
```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<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`:
|
||
|
||
```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:
|
||
```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.
|
||
*
|
||
* <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:
|
||
```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.
|
||
*
|
||
* <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:
|
||
```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.
|
||
*
|
||
* <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:
|
||
```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<String> StartupWarningRecorder.records()` — `"<LEVEL> <loggerName> :: <message>"`, 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.
|
||
*
|
||
* <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`:
|
||
|
||
```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.
|
||
*
|
||
* <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:
|
||
```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.
|
||
*
|
||
* <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:
|
||
```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<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:
|
||
```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.
|
||
*
|
||
* <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:
|
||
|
||
```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.
|
||
*
|
||
* <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:
|
||
```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 <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.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.
|