diff --git a/docs/superpowers/plans/2026-07-25-application-outbox-failure-reporting.md b/docs/superpowers/plans/2026-07-25-application-outbox-failure-reporting.md
new file mode 100644
index 00000000..72fef765
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-25-application-outbox-failure-reporting.md
@@ -0,0 +1,1853 @@
+# Application Outbox Failure Reporting 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.
+
+**Goal:** Remove Spring/logging framework coupling from `application-core` and report confirmed
+outbox `FAILED`/`DEAD` transitions through a safe typed port implemented by the messaging adapter.
+
+**Architecture:** `application-core` owns `OutboxRelayFailureReportPort` and an allowlisted,
+immutable-shape report. `adapter:outbound:messaging` renders the report as one structured SLF4J
+ERROR, while `app-bootstrap` only injects the port into the manually constructed relay.
+Persistence transitions remain authoritative; reporting is attempted afterward and can never
+change the relay outcome.
+
+**Tech Stack:** Java 21, Spring Boot 4.0.0 at adapter/bootstrap boundaries, Gradle multi-project
+build, JUnit Jupiter 6, AssertJ, ArchUnit, SLF4J 2 fluent key-value logging, Logback test appenders.
+
+**Spec:** `docs/superpowers/specs/2026-07-25-application-outbox-failure-reporting-design.md`
+
+**Working policy:** Commits are human-only. Agentic workers do not stage, commit, amend, or push.
+Each task leaves reviewed changes in the working tree.
+
+---
+
+## Prerequisite Gate
+
+The current checkout cannot configure Gradle because `.harness/project/modules.yaml` is absent.
+Complete the independently governed harness-registry recovery before starting Task 1. Execute this
+plan only from a controller turn that has resolved one stable task packet after recovery and retains
+its packet/rule hashes for all tasks.
+
+- [ ] **Gate 1: Confirm the module registry and task resolver exist**
+
+Run from the repository root:
+
+```bash
+test -f .harness/project/modules.yaml
+test -f .harness/validators/resolve_task.py
+```
+
+Expected after recovery: both commands exit `0` with no output. The current unrecovered checkout
+exits `1`.
+
+- [ ] **Gate 2: Confirm Gradle can evaluate settings**
+
+Run:
+
+```bash
+cd src
+./gradlew help --console=plain
+```
+
+Expected after recovery:
+
+```text
+BUILD SUCCESSFUL
+```
+
+Do not proceed when the output contains `Missing module registry`.
+
+- [ ] **Gate 3: Record the human-owned baseline without changing it**
+
+Run:
+
+```bash
+git status --short --branch
+```
+
+Expected: the controller records all pre-existing changes and preserves them. No task in this plan
+uses a destructive Git command.
+
+## File Map
+
+### New files
+
+- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReport.java`
+ — safe immutable application value with retryable/dead-letter factories.
+- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportPort.java`
+ — non-throwing outbound reporting contract.
+- `src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportTest.java`
+ — value invariants and privacy surface.
+- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java`
+ — structured SLF4J adapter.
+- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java`
+ — ERROR field, cause, and privacy contract.
+- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/LoggerUsingApplicationFixture.java`
+ — intentional ArchUnit mutation.
+
+### Modified production/build files
+
+- `src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java`
+ — replace direct logger calls with the typed port.
+- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java`
+ — remove misleading fail-open logging and retain fail-closed propagation.
+- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java`
+ — bind the reporter and simplify outbox publisher construction.
+- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java`
+ — inject and pass the reporter port.
+- `src/application-core/build.gradle`
+ — remove the Boot starter.
+- `src/build.gradle`
+ — give application-core a pure JUnit/AssertJ test baseline and add dependency purity verification.
+- `src/application-core/gradle.lockfile`
+ — regenerate after dependency removal.
+- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`
+ — ban logger/metrics frameworks from application packages.
+
+### Modified tests and support
+
+- `src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java`
+- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java`
+- `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java`
+- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java`
+- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java`
+- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java`
+
+### Modified documentation
+
+- `src/application-core/CLAUDE.md`
+- `src/application-core/README.md`
+- `src/adapter/outbound/messaging/CLAUDE.md`
+- `src/adapter/outbound/messaging/README.md`
+- `docs/runbooks/outbox-publish-failed.md`
+- `docs/runbooks/outbox-dead-letter.md`
+
+## Task 1: Safe Application Failure Report Contract
+
+**Files:**
+
+- Create:
+ `src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportTest.java`
+- Create:
+ `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReport.java`
+- Create:
+ `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportPort.java`
+
+- [ ] **Step 1: Write the failing value-contract test**
+
+Create the complete test:
+
+```java
+package dev.caskeleton.application.outbox;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import dev.caskeleton.shared.error.OperationalError;
+import java.time.Instant;
+import java.util.Arrays;
+import org.junit.jupiter.api.Test;
+
+class OutboxRelayFailureReportTest {
+
+ private static final RuntimeException CAUSE = new RuntimeException("broker unavailable");
+
+ @Test
+ void retryableFailureCarriesOnlySafeOperationalFields() {
+ Instant nextAttemptAt = Instant.parse("2026-07-25T01:02:03Z");
+
+ OutboxRelayFailureReport report =
+ OutboxRelayFailureReport.retryableFailure(
+ "evt-1", "WorkLogReserved", "worklog-1", "corr-1", 1, nextAttemptAt, CAUSE);
+
+ assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_PUBLISH_FAILED);
+ assertThat(report.eventId()).isEqualTo("evt-1");
+ assertThat(report.eventType()).isEqualTo("WorkLogReserved");
+ assertThat(report.aggregateId()).isEqualTo("worklog-1");
+ assertThat(report.correlationId()).isEqualTo("corr-1");
+ assertThat(report.attemptCount()).isEqualTo(1);
+ assertThat(report.nextAttemptAt()).isEqualTo(nextAttemptAt);
+ assertThat(report.cause()).isSameAs(CAUSE);
+ }
+
+ @Test
+ void deadLetterHasNoNextAttempt() {
+ OutboxRelayFailureReport report =
+ OutboxRelayFailureReport.deadLetter(
+ "evt-2", "WorkLogReserved", "worklog-2", "corr-2", 3, CAUSE);
+
+ assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_DEAD_LETTER);
+ assertThat(report.nextAttemptAt()).isNull();
+ }
+
+ @Test
+ void recordSurfaceCannotCarryPayloadOrIdempotencyKey() {
+ assertThat(
+ Arrays.stream(OutboxRelayFailureReport.class.getRecordComponents())
+ .map(component -> component.getName())
+ .toList())
+ .containsExactly(
+ "code",
+ "eventId",
+ "eventType",
+ "aggregateId",
+ "correlationId",
+ "attemptCount",
+ "nextAttemptAt",
+ "cause")
+ .doesNotContain("payload", "idempotencyKey");
+ }
+
+ @Test
+ void retryableFailureRequiresNextAttempt() {
+ assertThatThrownBy(
+ () ->
+ new OutboxRelayFailureReport(
+ OperationalError.OUTBOX_PUBLISH_FAILED,
+ "evt-1",
+ "Event",
+ "agg-1",
+ "corr-1",
+ 1,
+ null,
+ CAUSE))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("nextAttemptAt");
+ }
+
+ @Test
+ void deadLetterRejectsNextAttempt() {
+ assertThatThrownBy(
+ () ->
+ new OutboxRelayFailureReport(
+ OperationalError.OUTBOX_DEAD_LETTER,
+ "evt-1",
+ "Event",
+ "agg-1",
+ "corr-1",
+ 3,
+ Instant.parse("2026-07-25T01:02:03Z"),
+ CAUSE))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("DEAD");
+ }
+
+ @Test
+ void unsupportedOperationalCodeIsRejected() {
+ assertThatThrownBy(
+ () ->
+ new OutboxRelayFailureReport(
+ OperationalError.INTERNAL_ERROR,
+ "evt-1",
+ "Event",
+ "agg-1",
+ "corr-1",
+ 1,
+ null,
+ CAUSE))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("outbox failure code");
+ }
+
+ @Test
+ void blankMetadataAndNonPositiveAttemptAreRejected() {
+ assertThatThrownBy(
+ () ->
+ OutboxRelayFailureReport.deadLetter(
+ " ", "Event", "agg-1", "corr-1", 1, CAUSE))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("eventId");
+
+ assertThatThrownBy(
+ () ->
+ OutboxRelayFailureReport.deadLetter(
+ "evt-1", "Event", "agg-1", "corr-1", 0, CAUSE))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("attemptCount");
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify the red state**
+
+Run:
+
+```bash
+cd src
+./gradlew :application-core:test \
+ --tests 'dev.caskeleton.application.outbox.OutboxRelayFailureReportTest' \
+ --console=plain
+```
+
+Expected: `compileTestJava` fails because `OutboxRelayFailureReport` does not exist.
+
+- [ ] **Step 3: Implement the safe immutable report**
+
+Create the complete value:
+
+```java
+package dev.caskeleton.application.outbox;
+
+import dev.caskeleton.shared.error.OperationalError;
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * Safe operational description of a confirmed outbox FAILED or DEAD transition.
+ *
+ *
The value deliberately excludes payload and idempotency data. Logging severity, field names,
+ * and rendering belong to the outbound adapter.
+ */
+public record OutboxRelayFailureReport(
+ OperationalError code,
+ String eventId,
+ String eventType,
+ String aggregateId,
+ String correlationId,
+ int attemptCount,
+ Instant nextAttemptAt,
+ RuntimeException cause) {
+
+ public OutboxRelayFailureReport {
+ Objects.requireNonNull(code, "code must not be null");
+ eventId = requireText(eventId, "eventId");
+ eventType = requireText(eventType, "eventType");
+ aggregateId = requireText(aggregateId, "aggregateId");
+ correlationId = requireText(correlationId, "correlationId");
+ Objects.requireNonNull(cause, "cause must not be null");
+ if (attemptCount < 1) {
+ throw new IllegalArgumentException("attemptCount must be >= 1, was " + attemptCount);
+ }
+ if (code == OperationalError.OUTBOX_PUBLISH_FAILED) {
+ Objects.requireNonNull(
+ nextAttemptAt, "nextAttemptAt must not be null for OUTBOX_PUBLISH_FAILED");
+ } else if (code == OperationalError.OUTBOX_DEAD_LETTER) {
+ if (nextAttemptAt != null) {
+ throw new IllegalArgumentException("DEAD outbox report must not have nextAttemptAt");
+ }
+ } else {
+ throw new IllegalArgumentException("unsupported outbox failure code: " + code);
+ }
+ }
+
+ public static OutboxRelayFailureReport retryableFailure(
+ String eventId,
+ String eventType,
+ String aggregateId,
+ String correlationId,
+ int attemptCount,
+ Instant nextAttemptAt,
+ RuntimeException cause) {
+ return new OutboxRelayFailureReport(
+ OperationalError.OUTBOX_PUBLISH_FAILED,
+ eventId,
+ eventType,
+ aggregateId,
+ correlationId,
+ attemptCount,
+ nextAttemptAt,
+ cause);
+ }
+
+ public static OutboxRelayFailureReport deadLetter(
+ String eventId,
+ String eventType,
+ String aggregateId,
+ String correlationId,
+ int attemptCount,
+ RuntimeException cause) {
+ return new OutboxRelayFailureReport(
+ OperationalError.OUTBOX_DEAD_LETTER,
+ eventId,
+ eventType,
+ aggregateId,
+ correlationId,
+ attemptCount,
+ null,
+ cause);
+ }
+
+ private static String requireText(String value, String field) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(field + " must not be null or blank");
+ }
+ return value;
+ }
+}
+```
+
+- [ ] **Step 4: Implement the typed outbound port**
+
+Create the complete port:
+
+```java
+package dev.caskeleton.application.outbox;
+
+/**
+ * Outbound port for reporting a confirmed FAILED or DEAD outbox relay transition.
+ *
+ *
Implementations must not throw. Persistence state and {@link OutboxRelayResult} are
+ * authoritative; operational reporting must not rewrite or interrupt relay processing.
+ */
+@FunctionalInterface
+public interface OutboxRelayFailureReportPort {
+
+ void report(OutboxRelayFailureReport report);
+}
+```
+
+- [ ] **Step 5: Run the focused value test**
+
+Run:
+
+```bash
+./gradlew :application-core:test \
+ --tests 'dev.caskeleton.application.outbox.OutboxRelayFailureReportTest' \
+ --console=plain
+```
+
+Expected:
+
+```text
+BUILD SUCCESSFUL
+```
+
+## Task 2: Relay Uses the Typed Port Without Changing Outcomes
+
+**Files:**
+
+- Modify:
+ `src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java`
+- Modify:
+ `src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java`
+
+- [ ] **Step 1: Add a recording reporter to the existing test fixture**
+
+Add this field beside the existing fake ports:
+
+```java
+private RecordingOutboxRelayFailureReportPort failureReports;
+```
+
+Initialize and inject it in `setUp()`:
+
+```java
+failureReports = new RecordingOutboxRelayFailureReportPort();
+useCase =
+ new PublishPendingOutboxEventsUseCase(
+ store,
+ publishPort,
+ failureReports,
+ tx,
+ backoffPolicy,
+ clock,
+ BATCH_SIZE,
+ IN_FLIGHT_TIMEOUT);
+```
+
+Add this complete fake at the bottom of the test class:
+
+```java
+static final class RecordingOutboxRelayFailureReportPort
+ implements OutboxRelayFailureReportPort {
+
+ final List reports = new ArrayList<>();
+ java.util.function.Consumer onReport = report -> {};
+
+ @Override
+ public void report(OutboxRelayFailureReport report) {
+ onReport.accept(report);
+ reports.add(report);
+ }
+}
+```
+
+- [ ] **Step 2: Add red tests for ordering, false reports, and reporter isolation**
+
+Add these test methods:
+
+```java
+@Test
+void transientFailureReportsOnlyAfterFailedTransitionCommits() {
+ OutboxEvent event =
+ makeEvent("evt-report-failed", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
+ RuntimeException cause = new RuntimeException("broker down");
+ store.addClaimable(event);
+ publishPort.failOn(event.eventId(), cause);
+ failureReports.onReport =
+ report -> assertThat(store.failedEvents).containsKey(event.eventId());
+
+ OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
+
+ assertThat(result.outcomes().getFirst().outcome())
+ .isEqualTo(OutboxRelayResult.Outcome.FAILED);
+ assertThat(failureReports.reports).singleElement().satisfies(
+ report -> {
+ assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_PUBLISH_FAILED);
+ assertThat(report.eventId()).isEqualTo(event.eventId());
+ assertThat(report.eventType()).isEqualTo(event.eventType());
+ assertThat(report.aggregateId()).isEqualTo(event.aggregateId());
+ assertThat(report.correlationId()).isEqualTo(event.correlationId());
+ assertThat(report.attemptCount()).isEqualTo(event.attemptCount());
+ assertThat(report.nextAttemptAt()).isEqualTo(store.failedEvents.get(event.eventId()));
+ assertThat(report.cause()).isSameAs(cause);
+ });
+}
+
+@Test
+void deadLetterReportsOnlyAfterDeadTransitionCommits() {
+ OutboxEvent event =
+ makeEvent("evt-report-dead", "UserCreated", "agg-1", NOW.minusSeconds(60), 3);
+ RuntimeException cause = new RuntimeException("broker still down");
+ store.addClaimable(event);
+ publishPort.failOn(event.eventId(), cause);
+ failureReports.onReport =
+ report -> assertThat(store.deadEvents).contains(event.eventId());
+
+ OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
+
+ assertThat(result.outcomes().getFirst().outcome())
+ .isEqualTo(OutboxRelayResult.Outcome.DEAD);
+ assertThat(failureReports.reports).singleElement().satisfies(
+ report -> {
+ assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_DEAD_LETTER);
+ assertThat(report.nextAttemptAt()).isNull();
+ assertThat(report.cause()).isSameAs(cause);
+ });
+}
+
+@Test
+void successfulPublishDoesNotReportPublishFailure() {
+ OutboxEvent success =
+ makeEvent("evt-success", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
+ store.addClaimable(success);
+
+ useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
+
+ assertThat(failureReports.reports).isEmpty();
+}
+
+@Test
+void reporterExceptionDoesNotChangeOutcomeOrStopNextEvent() {
+ OutboxEvent failed =
+ makeEvent("evt-report-throws", "UserCreated", "agg-1", NOW.minusSeconds(120), 1);
+ OutboxEvent succeeds =
+ makeEvent("evt-after-report", "UserUpdated", "agg-2", NOW.minusSeconds(60), 1);
+ store.addClaimable(failed);
+ store.addClaimable(succeeds);
+ publishPort.failOn(failed.eventId(), new RuntimeException("broker down"));
+ failureReports.onReport = report -> {
+ throw new IllegalStateException("reporter unavailable");
+ };
+
+ OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
+
+ assertThat(result.outcomes())
+ .extracting(OutboxRelayResult.EventOutcome::outcome)
+ .containsExactly(OutboxRelayResult.Outcome.FAILED, OutboxRelayResult.Outcome.PUBLISHED);
+ assertThat(store.failedEvents).containsKey(failed.eventId());
+ assertThat(store.publishedEvents).contains(succeeds.eventId());
+}
+```
+
+Add this import:
+
+```java
+import dev.caskeleton.shared.error.OperationalError;
+```
+
+For both existing tests that directly construct the use case,
+`markPublishedFailurePropagatesAndDoesNotMisclassifyAsPublishFailure` and
+`markPublishedFailureAbortsRemainingBatchForCurrentTick`, insert `failureReports` immediately after
+`publishPort`:
+
+```java
+new PublishPendingOutboxEventsUseCase(
+ throwingStore,
+ publishPort,
+ failureReports,
+ tx,
+ backoffPolicy,
+ clock,
+ BATCH_SIZE,
+ IN_FLIGHT_TIMEOUT)
+```
+
+Add this assertion to both tests after the existing no-misclassification assertions:
+
+```java
+assertThat(failureReports.reports).isEmpty();
+```
+
+- [ ] **Step 3: Add a transition-failure fake and red test**
+
+Add this complete fake:
+
+```java
+static final class ThrowingOnMarkFailedStorePort extends FakeOutboxStorePort {
+
+ private final RuntimeException failure;
+
+ ThrowingOnMarkFailedStorePort(RuntimeException failure) {
+ this.failure = failure;
+ }
+
+ @Override
+ public void markFailed(String eventId, Instant nextAttemptAt) {
+ throw failure;
+ }
+}
+
+static final class ThrowingOnMarkDeadStorePort extends FakeOutboxStorePort {
+
+ private final RuntimeException failure;
+
+ ThrowingOnMarkDeadStorePort(RuntimeException failure) {
+ this.failure = failure;
+ }
+
+ @Override
+ public void markDead(String eventId) {
+ throw failure;
+ }
+}
+```
+
+If `FakeOutboxStorePort` is currently `final`, remove only that `final` modifier. Add both tests:
+
+```java
+@Test
+void failedTransitionFailurePropagatesWithoutFalseReport() {
+ OutboxEvent event =
+ makeEvent("evt-store-failed", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
+ ThrowingOnMarkFailedStorePort throwingStore =
+ new ThrowingOnMarkFailedStorePort(new RuntimeException("DB down on markFailed"));
+ throwingStore.addClaimable(event);
+ publishPort.failOn(event.eventId(), new RuntimeException("broker down"));
+ PublishPendingOutboxEventsUseCase useCaseWithThrowingStore =
+ new PublishPendingOutboxEventsUseCase(
+ throwingStore,
+ publishPort,
+ failureReports,
+ tx,
+ backoffPolicy,
+ clock,
+ BATCH_SIZE,
+ IN_FLIGHT_TIMEOUT);
+
+ assertThatThrownBy(
+ () -> useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessage("DB down on markFailed");
+ assertThat(failureReports.reports).isEmpty();
+}
+
+@Test
+void deadTransitionFailurePropagatesWithoutFalseReport() {
+ OutboxEvent event =
+ makeEvent("evt-store-dead", "UserCreated", "agg-1", NOW.minusSeconds(60), 3);
+ ThrowingOnMarkDeadStorePort throwingStore =
+ new ThrowingOnMarkDeadStorePort(new RuntimeException("DB down on markDead"));
+ throwingStore.addClaimable(event);
+ publishPort.failOn(event.eventId(), new RuntimeException("broker down"));
+ PublishPendingOutboxEventsUseCase useCaseWithThrowingStore =
+ new PublishPendingOutboxEventsUseCase(
+ throwingStore,
+ publishPort,
+ failureReports,
+ tx,
+ backoffPolicy,
+ clock,
+ BATCH_SIZE,
+ IN_FLIGHT_TIMEOUT);
+
+ assertThatThrownBy(
+ () -> useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessage("DB down on markDead");
+ assertThat(failureReports.reports).isEmpty();
+}
+```
+
+- [ ] **Step 4: Run the relay test to verify the red state**
+
+Run:
+
+```bash
+./gradlew :application-core:test \
+ --tests 'dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCaseTest' \
+ --console=plain
+```
+
+Expected: `compileTestJava` fails because the use-case constructor does not accept
+`OutboxRelayFailureReportPort`; after a temporary constructor adjustment, behavioral tests still
+fail because no report is emitted.
+
+- [ ] **Step 5: Replace SLF4J with the typed reporter in the use case**
+
+Remove:
+
+```java
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+```
+
+Remove the static logger field. Add:
+
+```java
+private final OutboxRelayFailureReportPort failureReports;
+```
+
+Use this constructor signature and assignment:
+
+```java
+public PublishPendingOutboxEventsUseCase(
+ OutboxStorePort store,
+ OutboxMessagePublishPort publishPort,
+ OutboxRelayFailureReportPort failureReports,
+ TransactionPort tx,
+ OutboxBackoffPolicy backoffPolicy,
+ Clock clock,
+ int batchSize,
+ Duration inFlightTimeout) {
+ this.store = Objects.requireNonNull(store, "store must not be null");
+ this.publishPort = Objects.requireNonNull(publishPort, "publishPort must not be null");
+ this.failureReports =
+ Objects.requireNonNull(failureReports, "failureReports must not be null");
+ this.tx = Objects.requireNonNull(tx, "tx must not be null");
+ this.backoffPolicy = Objects.requireNonNull(backoffPolicy, "backoffPolicy must not be null");
+ this.clock = Objects.requireNonNull(clock, "clock must not be null");
+ if (batchSize <= 0) {
+ throw new IllegalArgumentException("batchSize must be > 0, was " + batchSize);
+ }
+ this.batchSize = batchSize;
+ this.inFlightTimeout =
+ Objects.requireNonNull(inFlightTimeout, "inFlightTimeout must not be null");
+}
+```
+
+Replace `handlePublishFailure` with:
+
+```java
+private OutboxRelayResult.Outcome handlePublishFailure(
+ OutboxEvent event, Instant now, RuntimeException cause) {
+
+ if (event.attemptCount() >= backoffPolicy.maxAttempts()) {
+ tx.inWrite(() -> store.markDead(event.eventId()));
+ reportWithoutChangingOutcome(
+ OutboxRelayFailureReport.deadLetter(
+ event.eventId(),
+ event.eventType(),
+ event.aggregateId(),
+ event.correlationId(),
+ event.attemptCount(),
+ cause));
+ return OutboxRelayResult.Outcome.DEAD;
+ }
+
+ Instant nextAttemptAt = backoffPolicy.nextAttemptAt(event.attemptCount(), now);
+ tx.inWrite(() -> store.markFailed(event.eventId(), nextAttemptAt));
+ reportWithoutChangingOutcome(
+ OutboxRelayFailureReport.retryableFailure(
+ event.eventId(),
+ event.eventType(),
+ event.aggregateId(),
+ event.correlationId(),
+ event.attemptCount(),
+ nextAttemptAt,
+ cause));
+ return OutboxRelayResult.Outcome.FAILED;
+}
+
+private void reportWithoutChangingOutcome(OutboxRelayFailureReport report) {
+ try {
+ failureReports.report(report);
+ } catch (RuntimeException ignored) {
+ // The persisted FAILED/DEAD transition is authoritative. Outbox metrics still expose the
+ // outcome even when a custom reporter violates its non-throwing contract.
+ }
+}
+```
+
+- [ ] **Step 6: Run the relay and contract tests**
+
+Run:
+
+```bash
+./gradlew :application-core:test \
+ --tests 'dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCaseTest' \
+ --tests 'dev.caskeleton.application.outbox.OutboxRelayFailureReportTest' \
+ --console=plain
+```
+
+Expected:
+
+```text
+BUILD SUCCESSFUL
+```
+
+## Task 3: Structured Messaging Reporter Adapter
+
+**Files:**
+
+- Create:
+ `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java`
+- Create:
+ `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java`
+
+- [ ] **Step 1: Write the failing adapter contract test**
+
+Create the complete test:
+
+```java
+package dev.caskeleton.adapter.outbound.messaging.outbox;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.LoggerContext;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import dev.caskeleton.application.outbox.OutboxRelayFailureReport;
+import java.time.Instant;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+class Slf4jOutboxRelayFailureReportAdapterTest {
+
+ private final ch.qos.logback.classic.Logger logger =
+ (ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbox.failure-report");
+ private ListAppender appender;
+ private Slf4jOutboxRelayFailureReportAdapter adapter;
+
+ @BeforeEach
+ void attachAppender() {
+ appender = new ListAppender<>();
+ appender.setContext((LoggerContext) LoggerFactory.getILoggerFactory());
+ appender.start();
+ logger.addAppender(appender);
+ logger.setLevel(Level.ERROR);
+ adapter = new Slf4jOutboxRelayFailureReportAdapter(logger, "kafka");
+ }
+
+ @AfterEach
+ void detachAppender() {
+ logger.detachAppender(appender);
+ }
+
+ @Test
+ void retryableFailureEmitsCanonicalStructuredError() {
+ RuntimeException cause = new RuntimeException("broker unavailable");
+ adapter.report(
+ OutboxRelayFailureReport.retryableFailure(
+ "evt-1",
+ "WorkLogReserved",
+ "worklog-1",
+ "corr-1",
+ 1,
+ Instant.parse("2026-07-25T01:02:03Z"),
+ cause));
+
+ ILoggingEvent event = singleEvent();
+ assertThat(event.getLevel()).isEqualTo(Level.ERROR);
+ assertThat(event.getThrowableProxy().getClassName())
+ .isEqualTo(RuntimeException.class.getName());
+ assertThat(fields(event))
+ .containsEntry("error.code", "OUTBOX_PUBLISH_FAILED")
+ .containsEntry("error.category", "TRANSIENT_DEPENDENCY")
+ .containsEntry("dependency_name", "kafka")
+ .containsEntry("dependency_type", "messaging")
+ .containsEntry("outcome", "FAILED")
+ .containsEntry("event_id", "evt-1")
+ .containsEntry("event_type", "WorkLogReserved")
+ .containsEntry("aggregate_id", "worklog-1")
+ .containsEntry("correlation_id", "corr-1")
+ .containsEntry("attempt_count", "1")
+ .containsEntry("next_attempt_at", "2026-07-25T01:02:03Z")
+ .containsEntry("runbook_link", "runbook://outbox/publish-failed");
+ }
+
+ @Test
+ void deadLetterUsesDeadRunbookAndHasNoRetryTimestamp() {
+ adapter.report(
+ OutboxRelayFailureReport.deadLetter(
+ "evt-2",
+ "WorkLogReserved",
+ "worklog-2",
+ "corr-2",
+ 3,
+ new RuntimeException("broker unavailable")));
+
+ Map fields = fields(singleEvent());
+ assertThat(fields)
+ .containsEntry("error.code", "OUTBOX_DEAD_LETTER")
+ .containsEntry("error.category", "INTERNAL")
+ .containsEntry("outcome", "DEAD")
+ .containsEntry("runbook_link", "runbook://outbox/dead-letter")
+ .doesNotContainKey("next_attempt_at");
+ }
+
+ @Test
+ void logCannotContainPayloadOrIdempotencyData() {
+ adapter.report(
+ OutboxRelayFailureReport.deadLetter(
+ "evt-safe",
+ "SafeEvent",
+ "agg-safe",
+ "corr-safe",
+ 3,
+ new RuntimeException("safe cause")));
+
+ ILoggingEvent event = singleEvent();
+ assertThat(event.getFormattedMessage())
+ .doesNotContain("payload", "idempotency")
+ .contains("evt-safe", "SafeEvent", "corr-safe");
+ assertThat(fields(event).keySet()).doesNotContain("payload", "idempotency_key");
+ }
+
+ private ILoggingEvent singleEvent() {
+ assertThat(appender.list).hasSize(1);
+ return appender.list.getFirst();
+ }
+
+ private static Map fields(ILoggingEvent event) {
+ return event.getKeyValuePairs().stream()
+ .collect(
+ Collectors.toMap(
+ pair -> pair.key,
+ pair -> String.valueOf(pair.value),
+ (left, right) -> right));
+ }
+}
+```
+
+- [ ] **Step 2: Run the adapter test to verify the red state**
+
+Run:
+
+```bash
+./gradlew :adapter:outbound:messaging:test \
+ --tests 'dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapterTest' \
+ --console=plain
+```
+
+Expected: `compileTestJava` fails because `Slf4jOutboxRelayFailureReportAdapter` does not exist.
+
+- [ ] **Step 3: Implement the structured SLF4J adapter**
+
+Create the complete adapter:
+
+```java
+package dev.caskeleton.adapter.outbound.messaging.outbox;
+
+import dev.caskeleton.application.outbox.OutboxRelayFailureReport;
+import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;
+import dev.caskeleton.shared.error.OperationalError;
+import java.util.Objects;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.spi.LoggingEventBuilder;
+
+/** SLF4J structured adapter for confirmed outbox relay failure reports. */
+public final class Slf4jOutboxRelayFailureReportAdapter
+ implements OutboxRelayFailureReportPort {
+
+ private static final String DEPENDENCY_TYPE = "messaging";
+ private final Logger log;
+ private final String dependencyName;
+
+ public Slf4jOutboxRelayFailureReportAdapter(String dependencyName) {
+ this(
+ LoggerFactory.getLogger(Slf4jOutboxRelayFailureReportAdapter.class),
+ dependencyName);
+ }
+
+ Slf4jOutboxRelayFailureReportAdapter(Logger log, String dependencyName) {
+ this.log = Objects.requireNonNull(log, "log must not be null");
+ this.dependencyName =
+ dependencyName == null || dependencyName.isBlank() ? "disabled" : dependencyName;
+ }
+
+ @Override
+ public void report(OutboxRelayFailureReport report) {
+ Objects.requireNonNull(report, "report must not be null");
+ try {
+ LoggingEventBuilder event =
+ log.atError()
+ .setCause(report.cause())
+ .addKeyValue("error.code", report.code().code())
+ .addKeyValue("error.category", report.code().category().name())
+ .addKeyValue("dependency_name", dependencyName)
+ .addKeyValue("dependency_type", DEPENDENCY_TYPE)
+ .addKeyValue("outcome", outcome(report.code()))
+ .addKeyValue("event_id", report.eventId())
+ .addKeyValue("event_type", report.eventType())
+ .addKeyValue("aggregate_id", report.aggregateId())
+ .addKeyValue("correlation_id", report.correlationId())
+ .addKeyValue("attempt_count", report.attemptCount())
+ .addKeyValue("runbook_link", runbook(report.code()));
+ if (report.nextAttemptAt() != null) {
+ event.addKeyValue("next_attempt_at", report.nextAttemptAt());
+ }
+ event.log(
+ "outbox relay failure code={} event_id={} event_type={} correlation_id={} attempt_count={}",
+ report.code().code(),
+ report.eventId(),
+ report.eventType(),
+ report.correlationId(),
+ report.attemptCount());
+ } catch (RuntimeException ignored) {
+ // Reporting is secondary to the already committed outbox state and must not escape.
+ }
+ }
+
+ private static String outcome(OperationalError code) {
+ return code == OperationalError.OUTBOX_DEAD_LETTER ? "DEAD" : "FAILED";
+ }
+
+ private static String runbook(OperationalError code) {
+ return code == OperationalError.OUTBOX_DEAD_LETTER
+ ? "runbook://outbox/dead-letter"
+ : "runbook://outbox/publish-failed";
+ }
+}
+```
+
+- [ ] **Step 4: Run the adapter test**
+
+Run:
+
+```bash
+./gradlew :adapter:outbound:messaging:test \
+ --tests 'dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapterTest' \
+ --console=plain
+```
+
+Expected:
+
+```text
+BUILD SUCCESSFUL
+```
+
+## Task 4: Messaging Binding, Duplicate Log Removal, and Bootstrap Wiring
+
+**Files:**
+
+- Modify:
+ `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java`
+- Modify:
+ `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java`
+- Modify:
+ `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java`
+- Modify:
+ `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java`
+- Modify:
+ `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java`
+- Modify:
+ `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java`
+- Modify:
+ `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java`
+
+- [ ] **Step 1: Make bean-gating tests require one production reporter**
+
+Add imports to `OptionalAdapterBeanGatingTest`:
+
+```java
+import dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter;
+import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;
+```
+
+In the disabled-default assertion block add:
+
+```java
+assertThat(context.getBeansOfType(OutboxRelayFailureReportPort.class)).hasSize(1);
+assertThat(context.getBean(OutboxRelayFailureReportPort.class))
+ .isInstanceOf(Slf4jOutboxRelayFailureReportAdapter.class);
+```
+
+In the Kafka-enabled assertion block add the same two assertions. These assertions prove that
+disabled messaging still has a real reporter rather than a production NOOP.
+
+- [ ] **Step 2: Run the bean-gating test to verify the red state**
+
+Run:
+
+```bash
+./gradlew :app-bootstrap:test \
+ --tests 'dev.caskeleton.adapter.outbound.OptionalAdapterBeanGatingTest' \
+ --console=plain
+```
+
+Expected: FAIL because `MessagingConfig` does not expose an
+`OutboxRelayFailureReportPort` bean.
+
+- [ ] **Step 3: Bind the reporter in MessagingConfig**
+
+Add imports:
+
+```java
+import dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter;
+import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;
+```
+
+Add this bean:
+
+```java
+@Bean
+public OutboxRelayFailureReportPort outboxRelayFailureReportPort(MessagingSettings settings) {
+ return new Slf4jOutboxRelayFailureReportAdapter(settings.broker());
+}
+```
+
+Change `outboxMessagePublishPort` to:
+
+```java
+@Bean
+public OutboxMessagePublishPort outboxMessagePublishPort(
+ ObjectProvider brokerProvider, MessagingSettings settings) {
+ MessageBroker active = resolveBroker(brokerProvider, settings);
+ return (active == null)
+ ? new DisabledOutboxMessagePublisher()
+ : new OutboxMessagePublishAdapter(active);
+}
+```
+
+Do not change `messagePublisher`; it remains genuinely fail-open and continues to receive
+`FailOpenDependencyLogger`.
+
+- [ ] **Step 4: Remove fail-open logging from the fail-closed publisher**
+
+Replace `OutboxMessagePublishAdapter` with:
+
+```java
+package dev.caskeleton.adapter.outbound.messaging.outbox;
+
+import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
+import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage;
+import dev.caskeleton.application.outbox.OutboxEvent;
+import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
+
+/**
+ * Fail-closed outbox publisher that maps an application event to a broker envelope and surfaces
+ * every send failure to the relay state machine.
+ */
+public class OutboxMessagePublishAdapter implements OutboxMessagePublishPort {
+
+ private final MessageBroker broker;
+
+ public OutboxMessagePublishAdapter(MessageBroker broker) {
+ this.broker = broker;
+ }
+
+ @Override
+ public void publish(OutboxEvent event) {
+ String envelope = OutboxEnvelopeJson.toJson(event);
+ OutboundMessage message =
+ new OutboundMessage(event.eventType(), event.aggregateId(), envelope);
+ try {
+ broker.send(message);
+ } catch (RuntimeException ex) {
+ throw ex;
+ } catch (Exception ex) {
+ throw new RuntimeException(
+ "outbox publish failed for broker '" + broker.brokerId() + "'", ex);
+ }
+ }
+}
+```
+
+In `OutboxMessagePublishAdapterTest`:
+
+- remove `FailOpenDependencyLogger`, Logback appender, SLF4J, and MDC setup imports/fields;
+- remove `publishFailureIsLoggedBeforePropagation`;
+- remove `publishFailureLogCarriesDependencyAndOperation`;
+- replace every `new OutboxMessagePublishAdapter(broker, dependencyLogger)` with
+ `new OutboxMessagePublishAdapter(broker)`;
+- retain success envelope tests, runtime propagation, and checked-exception wrapping tests.
+
+- [ ] **Step 5: Wire the reporter through app-bootstrap**
+
+Add the import to `OutboxConfig`:
+
+```java
+import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;
+```
+
+Add the parameter immediately after `OutboxMessagePublishPort publishPort`:
+
+```java
+OutboxRelayFailureReportPort failureReports,
+```
+
+Pass it in the manual constructor:
+
+```java
+new PublishPendingOutboxEventsUseCase(
+ store,
+ publishPort,
+ failureReports,
+ tx,
+ new OutboxBackoffPolicy(outboxRandomGenerator),
+ clock,
+ properties.batchSize(),
+ properties.inFlightTimeout())
+```
+
+`app-bootstrap` must not construct `Slf4jOutboxRelayFailureReportAdapter`; Spring injects the
+messaging-owned port bean.
+
+- [ ] **Step 6: Update direct test constructors with test-only reporters**
+
+In `OutboxContainerTestSupport.relayUseCase`, insert this argument after `publisher`:
+
+```java
+report -> {}
+```
+
+In both direct constructors in `OutboxRowLifecycleContractTest`, insert the same test-only lambda
+after the publisher argument:
+
+```java
+report -> {}
+```
+
+Use test-only lambdas only in integration fixtures. Production wiring must never bind a NOOP.
+
+- [ ] **Step 7: Run messaging, bean-gating, and relay tests**
+
+Run:
+
+```bash
+./gradlew :adapter:outbound:messaging:test --console=plain
+./gradlew :app-bootstrap:test \
+ --tests 'dev.caskeleton.adapter.outbound.OptionalAdapterBeanGatingTest' \
+ --console=plain
+./gradlew :application-core:test \
+ --tests 'dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCaseTest' \
+ --console=plain
+```
+
+Expected for each command:
+
+```text
+BUILD SUCCESSFUL
+```
+
+## Task 5: Remove Framework Dependencies and Add a Resolved-Classpath Guard
+
+**Files:**
+
+- Modify: `src/build.gradle`
+- Modify: `src/application-core/build.gradle`
+- Modify: `src/application-core/gradle.lockfile`
+
+- [ ] **Step 1: Add the failing application-core dependency purity task**
+
+Add this task to `src/build.gradle` after `verifyCleanArchitectureDependencies`:
+
+```groovy
+tasks.register('verifyApplicationCoreDependencyPurity') {
+ group = 'verification'
+ description = 'Verifies application-core has project-only production declarations and no framework observability on main/test classpaths.'
+
+ doLast {
+ Project applicationCore = project(':application-core')
+ List productionConfigurations = ['api', 'implementation', 'compileOnly', 'runtimeOnly']
+ Set declaredNonProject = productionConfigurations
+ .collect { applicationCore.configurations.findByName(it) }
+ .findAll { it != null }
+ .collectMany { configuration ->
+ configuration.dependencies
+ .findAll {
+ !(it instanceof org.gradle.api.artifacts.ProjectDependency)
+ }
+ .collect { dependency ->
+ String coordinate = dependency.group
+ ? "${dependency.group}:${dependency.name}"
+ : "local:${dependency.name}"
+ "${configuration.name}:${coordinate}"
+ }
+ }
+ .toSet()
+
+ if (!declaredNonProject.isEmpty()) {
+ throw new GradleException(
+ "application-core production dependencies must be project-only; found " +
+ declaredNonProject.toSorted())
+ }
+
+ Closure forbiddenGroup = { String group ->
+ group == 'org.slf4j' ||
+ group == 'ch.qos.logback' ||
+ group == 'org.apache.logging.log4j' ||
+ group == 'io.micrometer' ||
+ group == 'org.springframework' ||
+ group.startsWith('org.springframework.')
+ }
+ List classpathConfigurations = [
+ 'compileClasspath',
+ 'runtimeClasspath',
+ 'testCompileClasspath',
+ 'testRuntimeClasspath'
+ ]
+ Set forbiddenResolved = classpathConfigurations.collectMany { configurationName ->
+ def configuration = applicationCore.configurations.getByName(configurationName)
+ configuration.resolvedConfiguration.resolvedArtifacts.findResults { artifact ->
+ String group = artifact.moduleVersion.id.group
+ forbiddenGroup(group)
+ ? "${configurationName}:${group}:${artifact.name}:${artifact.moduleVersion.id.version}"
+ : null
+ }
+ }.toSet()
+
+ if (!forbiddenResolved.isEmpty()) {
+ throw new GradleException(
+ "application-core main/test classpaths contain forbidden framework observability: " +
+ forbiddenResolved.toSorted())
+ }
+
+ logger.lifecycle(
+ 'verifyApplicationCoreDependencyPurity: OK — production declarations are project-only and main/test classpaths are framework-observability-free.')
+ }
+}
+
+project(':application-core').tasks.named('check') {
+ dependsOn rootProject.tasks.named('verifyApplicationCoreDependencyPurity')
+}
+```
+
+- [ ] **Step 2: Run the purity task to verify the red state**
+
+Run:
+
+```bash
+./gradlew verifyApplicationCoreDependencyPurity --console=plain
+```
+
+Expected: FAIL listing at least
+`org.springframework.boot:spring-boot-starter` as a declared external dependency and resolved
+Spring/logging/Micrometer components.
+
+- [ ] **Step 3: Give application-core a pure test baseline**
+
+In the common leaf `dependencies` block of `src/build.gradle`, replace the unconditional Boot test
+starter declarations with:
+
+```groovy
+if (path == ':application-core') {
+ testImplementation 'org.junit.jupiter:junit-jupiter'
+ testImplementation 'org.assertj:assertj-core'
+} else {
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+ testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
+}
+testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+```
+
+Keep static-analysis dependencies unchanged. They live on analysis tool configurations, not
+application main/test runtime classpaths.
+
+- [ ] **Step 4: Remove the Boot starter from application-core**
+
+Make `src/application-core/build.gradle` contain:
+
+```groovy
+// Framework-free application use-case and outbound-port contracts.
+dependencies {
+ implementation project(':domain-core')
+ implementation project(':shared-contract')
+}
+```
+
+- [ ] **Step 5: Regenerate only application-core dependency locks**
+
+Run:
+
+```bash
+./gradlew :application-core:resolveAndLockAll --write-locks --console=plain
+```
+
+Expected:
+
+```text
+BUILD SUCCESSFUL
+```
+
+Review `src/application-core/gradle.lockfile`: Spring, SLF4J, Logback, Log4j, and Micrometer must be
+absent from `compileClasspath`, `runtimeClasspath`, `testCompileClasspath`, and
+`testRuntimeClasspath`. SLF4J entries used only by SpotBugs tool configurations may remain.
+
+- [ ] **Step 6: Run the green dependency checks**
+
+Run:
+
+```bash
+./gradlew verifyApplicationCoreDependencyPurity --console=plain
+./gradlew :application-core:verifyDependencyLocks --console=plain
+./gradlew :application-core:test --console=plain
+```
+
+Expected for all commands:
+
+```text
+BUILD SUCCESSFUL
+```
+
+Expected purity lifecycle line:
+
+```text
+verifyApplicationCoreDependencyPurity: OK — production declarations are project-only and main/test classpaths are framework-observability-free.
+```
+
+## Task 6: Add a Non-Vacuous Application Logger Architecture Rule
+
+**Files:**
+
+- Create:
+ `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/LoggerUsingApplicationFixture.java`
+- Modify:
+ `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java`
+- Modify:
+ `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`
+
+- [ ] **Step 1: Add the intentional violation fixture**
+
+Create:
+
+```java
+package dev.caskeleton.bootstrap.architecture.violations.application;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Intentional application-layer logger violation used only by architecture mutation tests. */
+public final class LoggerUsingApplicationFixture {
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(LoggerUsingApplicationFixture.class);
+
+ public void execute() {
+ LOG.info("application should report through a typed port");
+ }
+}
+```
+
+- [ ] **Step 2: Add the failing mutation assertion**
+
+Add this import and isolated fixture corpus to `ArchitectureViolationFixtureTest`:
+
+```java
+import dev.caskeleton.bootstrap.architecture.violations.application.LoggerUsingApplicationFixture;
+```
+
+```java
+private static final JavaClasses LOGGER_USING_APPLICATION_FIXTURE_ONLY =
+ new ClassFileImporter().importClasses(LoggerUsingApplicationFixture.class);
+```
+
+Add this test:
+
+```java
+@Test
+void applicationHasNoDiagnosticFrameworkCatchesSlf4jFixture() {
+ EvaluationResult result =
+ CleanArchitectureTest.APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK.evaluate(
+ LOGGER_USING_APPLICATION_FIXTURE_ONLY);
+
+ assertThat(result.hasViolation())
+ .as(
+ "APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK must catch "
+ + "LoggerUsingApplicationFixture")
+ .isTrue();
+}
+```
+
+- [ ] **Step 3: Run the mutation test to verify the red state**
+
+Run:
+
+```bash
+./gradlew :app-bootstrap:test \
+ --tests 'dev.caskeleton.bootstrap.architecture.ArchitectureViolationFixtureTest.applicationHasNoDiagnosticFrameworkCatchesSlf4jFixture' \
+ --console=plain
+```
+
+Expected: `compileTestJava` fails because
+`APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK` does not exist.
+
+- [ ] **Step 4: Implement the ArchUnit rule**
+
+Add this rule beside the existing application boundary rules in `CleanArchitectureTest`:
+
+```java
+@ArchTest
+static final ArchRule APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK =
+ noClasses()
+ .that()
+ .resideInAPackage("..application..")
+ .should()
+ .dependOnClassesThat()
+ .resideInAnyPackage(
+ "org.slf4j..",
+ "java.util.logging..",
+ "ch.qos.logback..",
+ "org.apache.logging.log4j..",
+ "io.micrometer..")
+ .as(
+ "application policy must report operational facts through typed ports, not logging "
+ + "or metrics framework APIs")
+ .allowEmptyShould(true);
+```
+
+- [ ] **Step 5: Run mutation and production architecture tests**
+
+Run:
+
+```bash
+./gradlew :app-bootstrap:test \
+ --tests 'dev.caskeleton.bootstrap.architecture.ArchitectureViolationFixtureTest.applicationHasNoDiagnosticFrameworkCatchesSlf4jFixture' \
+ --console=plain
+./gradlew :app-bootstrap:test \
+ --tests 'dev.caskeleton.bootstrap.architecture.CleanArchitectureTest' \
+ --console=plain
+```
+
+Expected for both:
+
+```text
+BUILD SUCCESSFUL
+```
+
+## Task 7: Align Documentation and Operational Field Contracts
+
+**Files:**
+
+- Modify: `src/application-core/CLAUDE.md`
+- Modify: `src/application-core/README.md`
+- Modify: `src/adapter/outbound/messaging/CLAUDE.md`
+- Modify: `src/adapter/outbound/messaging/README.md`
+- Modify: `docs/runbooks/outbox-publish-failed.md`
+- Modify: `docs/runbooks/outbox-dead-letter.md`
+
+- [ ] **Step 1: Correct application-core dependency guidance**
+
+In `src/application-core/CLAUDE.md`, make the Allowed production dependencies exactly:
+
+```markdown
+## Allowed
+
+- `:domain-core`
+- `:shared-contract`
+- Java standard-library types.
+
+Spring, SLF4J, Logback, Log4j, JUL logging, and Micrometer APIs are forbidden in
+`application-core`. Use cases are registered or manually constructed by a composition root or
+consumer module.
+```
+
+Remove `@Service` from the canonical application-core use-case example. Add these contract rows:
+
+```markdown
+| `outbox.OutboxRelayFailureReportPort` | Reports confirmed FAILED/DEAD transitions without exposing a logging framework. |
+| `outbox.OutboxRelayFailureReport` | Safe operational metadata only; payload and idempotency key are forbidden. |
+```
+
+- [ ] **Step 2: Document the relay's new failure-report semantics**
+
+Replace direct-logger language in `src/application-core/README.md` with:
+
+```markdown
+- Every confirmed publish failure performs both (a) the FAILED/DEAD state transition and (b) one
+ `OutboxRelayFailureReportPort` report attempt.
+- The transition commits before reporting. If the transition fails, no report is emitted because
+ no FAILED/DEAD state was confirmed.
+- A reporter failure is contained and cannot rewrite the persisted outcome or stop the remaining
+ batch. Production wiring must still provide a real reporter; a production NOOP is forbidden.
+- The safe report carries code, event/aggregate/correlation identifiers, attempt count, retry time,
+ and cause. Payload and idempotency key never cross the port.
+```
+
+Retain the existing `markPublished` failure and in-flight recovery explanation.
+
+- [ ] **Step 3: Document messaging ownership and duplicate-log removal**
+
+Add to `src/adapter/outbound/messaging/CLAUDE.md` Responsibility:
+
+```markdown
+- Implement `OutboxRelayFailureReportPort` as the single structured ERROR renderer for confirmed
+ outbox FAILED/DEAD transitions.
+```
+
+Update `src/adapter/outbound/messaging/README.md` with:
+
+```markdown
+## Outbox failure reporting
+
+`OutboxMessagePublishAdapter` is fail-closed and only surfaces broker failures. It does not use the
+fail-open dependency logger. After application-core commits FAILED or DEAD,
+`Slf4jOutboxRelayFailureReportAdapter` emits one structured ERROR with the registered runbook fields.
+This separation prevents a WARN-before-rethrow plus ERROR-after-transition duplicate.
+```
+
+Also remove the stale claim that this module has no `CLAUDE.md`; the existing module guidance is
+the local rule authority.
+
+- [ ] **Step 4: Align runbook code and fields**
+
+In both outbox runbooks:
+
+- name `OutboxRelayFailureReportPort` and
+ `Slf4jOutboxRelayFailureReportAdapter` as the canonical reporting path;
+- retain `error.code`, `event_id`, `event_type`, `correlation_id`, and `runbook_link`;
+- state that payload and idempotency key are forbidden;
+- remove stale concrete class names that do not exist in the repository.
+
+Use this code-path text:
+
+```markdown
+- 코드: `application-core`의 `PublishPendingOutboxEventsUseCase`
+ (상태 전이 + typed report 생성) → `OutboxRelayFailureReportPort` →
+ `adapter:outbound:messaging`의 `Slf4jOutboxRelayFailureReportAdapter`
+ (structured ERROR + runbook fields).
+```
+
+- [ ] **Step 5: Verify documentation contains no old core-logger rationale**
+
+Run from the repository root:
+
+```bash
+rg -n "spring-boot-starter.*@Service|LoggerFactory|log\\.error" \
+ src/application-core/CLAUDE.md \
+ src/application-core/README.md \
+ src/application-core/build.gradle
+```
+
+Expected: no matches.
+
+Run:
+
+```bash
+rg -n "OutboxRelayFailureReportPort|Slf4jOutboxRelayFailureReportAdapter" \
+ src/application-core \
+ src/adapter/outbound/messaging \
+ docs/runbooks/outbox-publish-failed.md \
+ docs/runbooks/outbox-dead-letter.md
+```
+
+Expected: matches in application contracts/docs, messaging implementation/docs, and both runbooks.
+
+## Task 8: Focused, Integration, and Full Verification
+
+**Files:** No planned source additions; fix only findings within the file map above.
+
+- [ ] **Step 1: Format and check the affected Java sources**
+
+Run:
+
+```bash
+cd src
+./gradlew \
+ :application-core:spotlessCheck \
+ :adapter:outbound:messaging:spotlessCheck \
+ :app-bootstrap:spotlessCheck \
+ --console=plain
+```
+
+Expected:
+
+```text
+BUILD SUCCESSFUL
+```
+
+If formatting fails, run the repository formatter only on the affected modules:
+
+```bash
+./gradlew \
+ :application-core:spotlessApply \
+ :adapter:outbound:messaging:spotlessApply \
+ :app-bootstrap:spotlessApply \
+ --console=plain
+```
+
+Then rerun the blocking `spotlessCheck`. `spotlessApply` is a local implementation step and must
+never be substituted for the CI check.
+
+- [ ] **Step 2: Run focused module tests**
+
+Run:
+
+```bash
+./gradlew :application-core:test --console=plain
+./gradlew :adapter:outbound:messaging:test --console=plain
+./gradlew :app-bootstrap:test \
+ --tests 'dev.caskeleton.adapter.outbound.OptionalAdapterBeanGatingTest' \
+ --tests 'dev.caskeleton.bootstrap.architecture.CleanArchitectureTest' \
+ --tests 'dev.caskeleton.bootstrap.architecture.ArchitectureViolationFixtureTest' \
+ --console=plain
+```
+
+Expected for all:
+
+```text
+BUILD SUCCESSFUL
+```
+
+- [ ] **Step 3: Run the real PostgreSQL outbox lifecycle contract when Docker is available**
+
+Run:
+
+```bash
+./gradlew :app-bootstrap:test \
+ --tests 'dev.caskeleton.bootstrap.integration.outbox.OutboxRowLifecycleContractTest' \
+ --console=plain
+```
+
+Expected with a reachable Docker daemon:
+
+```text
+BUILD SUCCESSFUL
+```
+
+If the test is skipped or Docker is unavailable, record that exact result and retain the
+integration-risk item in the final report.
+
+- [ ] **Step 4: Run dependency and architecture gates**
+
+Run:
+
+```bash
+./gradlew verifyApplicationCoreDependencyPurity --console=plain
+./gradlew verifyCleanArchitectureDependencies --console=plain
+./gradlew :application-core:verifyDependencyLocks --console=plain
+```
+
+Expected:
+
+```text
+BUILD SUCCESSFUL
+```
+
+- [ ] **Step 5: Prove the forbidden dependencies are absent**
+
+Run:
+
+```bash
+./gradlew :application-core:dependencyInsight \
+ --dependency org.springframework \
+ --configuration runtimeClasspath \
+ --console=plain
+./gradlew :application-core:dependencyInsight \
+ --dependency org.slf4j \
+ --configuration testRuntimeClasspath \
+ --console=plain
+./gradlew :application-core:dependencyInsight \
+ --dependency io.micrometer \
+ --configuration testRuntimeClasspath \
+ --console=plain
+```
+
+Expected for each report:
+
+```text
+No dependencies matching given input were found
+BUILD SUCCESSFUL
+```
+
+- [ ] **Step 6: Run full tests and checks**
+
+Run:
+
+```bash
+./gradlew test --console=plain
+./gradlew check --console=plain
+```
+
+Expected:
+
+```text
+BUILD SUCCESSFUL
+```
+
+`check` must transitively run dependency, environment-key, architecture, formatting, and static
+analysis gates. Report every failed or unrun command rather than claiming completion.
+
+- [ ] **Step 7: Review the final working-tree diff**
+
+Run from the repository root:
+
+```bash
+git status --short
+git diff --check
+git diff -- \
+ src/application-core \
+ src/adapter/outbound/messaging \
+ src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox \
+ src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture \
+ src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox \
+ src/build.gradle \
+ docs/runbooks
+```
+
+Expected:
+
+- `git diff --check` exits `0`;
+- no production file outside the declared file map changed;
+- no payload/idempotency field entered the report API;
+- no SLF4J/Spring/Micrometer import remains in application-core;
+- no agent-created commit exists.
+
+## Task 9: Required Implementation Evidence and LLM Wiki Capture
+
+This task is performed only after production implementation and verification. It is not performed
+while merely authoring this plan.
+
+- [ ] **Step 1: Read the Wiki authorities before writing**
+
+Read:
+
+```text
+/home/donghyeon/workspace/ai-tool/llm-wiki-private/AGENTS.md
+/home/donghyeon/workspace/ai-tool/llm-wiki-private/CLAUDE.md
+/home/donghyeon/workspace/clean-architecture-backend-template/.agents/plugins/ca-superpowers/rules/llm-wiki-capture.md
+```
+
+Expected: all authorities exist after harness recovery and are read in full.
+
+- [ ] **Step 2: Update the branch note**
+
+Create or update:
+
+```text
+/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/main.md
+```
+
+Record:
+
+- typed report/port decision and messaging ownership;
+- safe-field boundary and rejected alternatives;
+- changed files;
+- every verification command and result;
+- the harness-registry prerequisite and any Docker limitation;
+- evidence grade;
+- remaining risk.
+
+- [ ] **Step 3: Make the derived-document judgment explicit**
+
+Create linked raw error/interview/blog-topic notes only when the completed implementation provides
+genuine derived material. Otherwise write `추출할 별도 글감 없음` in the branch note's
+`## Cluster / 묶음` section.
+
+- [ ] **Step 4: Produce the final implementation report**
+
+The final response lists:
+
+- changed files;
+- core architecture and behavior changes;
+- focused/full verification commands and outcomes;
+- failed or unrun checks;
+- Wiki branch-note and derived-note result;
+- remaining risks and human-only commit status.
diff --git a/docs/superpowers/plans/2026-07-25-ci-control-plane-recovery.md b/docs/superpowers/plans/2026-07-25-ci-control-plane-recovery.md
new file mode 100644
index 00000000..f99e7766
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-25-ci-control-plane-recovery.md
@@ -0,0 +1,1978 @@
+# CI Control Plane Recovery 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.
+
+**Goal:** Recover the repository's authoritative hidden control plane, make Gradle and Docker
+builds hermetic, and establish fail-closed release gates on the Gitea 1.27.0 host.
+
+**Architecture:** Recovery is a hard gate: authoritative hidden assets are inventoried and restored
+before any policy is reconstructed or modified. A repository preflight then guards Gradle,
+security baselines, canonical `.github/workflows`, root-context Docker builds, and a single Gitea
+quality fan-in complemented by the vulnerability status.
+
+**Tech Stack:** Java 21, Spring Boot 4.0.0, Gradle 9 wrapper, Python 3 stdlib harness,
+Docker/Compose, Gitea 1.27.0 Actions, Gitea runner, Renovate
+
+---
+
+**Spec:** `docs/superpowers/specs/2026-07-25-ci-control-plane-recovery-design.md`
+
+**Upstream architecture:**
+`docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md`
+
+**Working policy:** commits are human-only. Agentic workers do not stage, commit, amend, or push.
+Each task ends with a reviewable working-tree checkpoint instead of a commit step.
+
+**Packet gate:** this plan starts without a resolved task packet because `.harness` is absent.
+Tasks 1, 2A, and 2B are bootstrap recovery/reconstruction only. After the human-selected path
+establishes and validates the harness, write the controller-approved overlay and resolved packet to
+the recovery evidence directory. Do not start Task 3 until deterministic re-resolution and the
+recorded packet/rule checksums pass.
+
+## File Responsibility Map
+
+### Mode A — restore byte-for-byte before editing
+
+- `.harness/` — project manifest, module registry, policies, validators, schemas, tests, canonical
+ agents, generators, and task resolver
+- `.agents/` — clean-architecture rules, Superpowers plugin, rendered Antigravity agents/hooks
+- `.claude/` — Claude hooks and rendered agents
+- `.codex/` — Codex rendered agents and validation guidance
+- `.github/` — canonical workflows, scripts, gate matrix, CODEOWNERS, and vulnerability policy
+- `.tool-versions` — repository Java toolchain pin
+- `.trivyignore.yaml` — structured suppression contract
+- `.gitattributes` — repository text/binary normalization contract
+
+### Mode B — reconstruct under recorded provenance
+
+- `.harness/` — reconstruct from the approved 2026-07-20 harness design and implementation plan
+- `.agents/` — regenerate from reconstructed canonical harness sources
+- `.claude/` — regenerate and validate Claude hooks/agents
+- `.codex/` — regenerate and validate Codex agents
+- `.harness/recovery-provenance.json` — declare `controlled-reconstruction`, source documents,
+ failed baseline revision, and the rule that reconstructed assets are not restored originals
+- `.github/`, `.tool-versions`, `.trivyignore.yaml`, `.gitattributes` — reconstruct in Tasks 4 and 6
+ from the approved current design and tracked build contracts
+
+### Create after Mode A or Mode B establishes the harness
+
+- `.harness/project/control-plane.yaml` — physical required-path and canonical-workflow manifest
+- `.harness/validators/validate_control_plane.py` — fail-fast physical control-plane validator
+- `.harness/tests/test_control_plane.py` — missing-path and workflow-shadow mutation tests
+- `.dockerignore` — repository-root Docker context exclusions
+- `docs/security/public-paths-snapshot.txt` — committed deny-by-default public-path baseline
+
+### Modify after Mode A or Mode B establishes the harness
+
+- `src/build.gradle:274-280,754-819` — wire read-only public-path verification into `check` and
+ separate approved baseline generation
+- `src/Dockerfile:30-54` — build from repository root while retaining `/build/src`
+- `src/Dockerfile.sample:42-63` — mirror the production builder layout
+- `docker-compose.yml:28-35` — use repository-root context and `src/Dockerfile`
+- `.github/workflows/ci-quality-gates.yml` — add control-plane preflight and stable `release-gate`
+- `.github/workflows/build-release-supply-chain.yml` — consume the same preflight and root context
+- `.github/workflows/dependency-vulnerability.yml` — publish the complementary blocking status
+- `.github/ci-gate-matrix.yml` — record exact workflow/job/task ownership
+- `.github/scripts/verify-gate-matrix.sh` — validate the restored job graph
+- `.github/scripts/verify-reproducible-build.sh` — invoke Docker/Gradle with corrected paths
+- `renovate.json:3-34` — correct the dependency model description and pause automerge
+- `README.md:31-107` — correct quick start, Gitea CI, workflow, and Docker references
+- `src/README.md:39-181` — correct registry SSOT, lock, snapshot, and Gitea gate guidance
+
+### Explicitly forbidden
+
+- `.gitea/workflows/` — would shadow `.github/workflows` under Gitea's default `WORKFLOW_DIRS`
+- `src/.harness/` — would duplicate the root registry and violate the harness SSOT
+- repository files containing Gitea API or runner registration tokens
+
+### Task 1: Preserve Evidence and Record the Human Recovery Mode
+
+**Files:**
+
+- Read: `AGENTS.md`
+- Read: `CLAUDE.md`
+- Read: `docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md`
+- Read: `docs/superpowers/plans/2026-07-20-harness-policy-engine.md`
+- Read: `docs/superpowers/specs/2026-07-25-ci-control-plane-recovery-design.md`
+- External recovery root: `/tmp/ca-control-plane-recovery/authoritative-root`
+- External evidence directory: `/tmp/ca-control-plane-recovery/evidence`
+
+- [ ] **Step 1: Capture the repository baseline outside the worktree**
+
+Run:
+
+```bash
+mkdir -p /tmp/ca-control-plane-recovery/evidence
+git rev-parse HEAD | tee /tmp/ca-control-plane-recovery/evidence/failed-head.txt
+git status --short --branch | tee /tmp/ca-control-plane-recovery/evidence/failed-status.txt
+git ls-tree -r --name-only HEAD \
+ | tee /tmp/ca-control-plane-recovery/evidence/failed-tree.txt
+```
+
+Expected: `failed-head.txt` contains one 40-character revision; status contains no tracked changes
+other than the plan executor's intentional working-tree state; the tree contains no root
+`.harness`, `.agents`, `.claude`, `.codex`, or `.github` path.
+
+- [ ] **Step 2: Inspect the preferred authoritative export**
+
+Human action: when available, copy or mount the original working tree/archive that produced the
+2026-07-20 harness policy implementation at:
+
+```text
+/tmp/ca-control-plane-recovery/authoritative-root
+```
+
+Inspect it without changing the repository:
+
+```bash
+for ci_recovery_path in \
+ .harness \
+ .agents \
+ .claude \
+ .codex \
+ .github \
+ .tool-versions \
+ .trivyignore.yaml \
+ .gitattributes
+do
+ if test -e "/tmp/ca-control-plane-recovery/authoritative-root/${ci_recovery_path}"
+ then
+ echo "PRESENT ${ci_recovery_path}"
+ else
+ echo "MISSING ${ci_recovery_path}"
+ fi
+done \
+ | tee /tmp/ca-control-plane-recovery/evidence/authoritative-inventory.txt
+```
+
+Expected: the inventory records all eight paths as `PRESENT` for Mode A. Any `MISSING` result makes
+Mode A incomplete but does not preclude the human from choosing Mode B.
+
+- [ ] **Step 3: Record exactly one human-selected mode**
+
+For a complete authoritative export, the human runs:
+
+```bash
+printf '%s\n' \
+ 'mode=A-authoritative-restore' \
+ 'decision=human-approved' \
+ 'claim=byte-for-byte-restore-after-hash-verification' \
+ > /tmp/ca-control-plane-recovery/evidence/recovery-mode.txt
+```
+
+When the original export is unavailable or incomplete, the human runs:
+
+```bash
+printf '%s\n' \
+ 'mode=B-controlled-reconstruction' \
+ 'decision=human-approved' \
+ 'claim=reconstructed-not-restored' \
+ 'sources=2026-07-20-harness-spec,2026-07-20-harness-plan,2026-07-25-ci-recovery-design,tracked-build-contracts' \
+ > /tmp/ca-control-plane-recovery/evidence/recovery-mode.txt
+```
+
+Verify:
+
+```bash
+grep -Eq '^mode=(A-authoritative-restore|B-controlled-reconstruction)$' \
+ /tmp/ca-control-plane-recovery/evidence/recovery-mode.txt
+grep -q '^decision=human-approved$' \
+ /tmp/ca-control-plane-recovery/evidence/recovery-mode.txt
+```
+
+Expected: both commands exit `0`. Stop until the human has selected one mode. Never infer the mode
+from which files happen to be available.
+
+- [ ] **Step 4: Validate and hash Mode A when selected**
+
+Run only when `recovery-mode.txt` contains `mode=A-authoritative-restore`:
+
+```bash
+grep -q '^mode=A-authoritative-restore$' \
+ /tmp/ca-control-plane-recovery/evidence/recovery-mode.txt
+for ci_recovery_path in \
+ .harness \
+ .agents \
+ .claude \
+ .codex \
+ .github \
+ .tool-versions \
+ .trivyignore.yaml \
+ .gitattributes
+do
+ test -e "/tmp/ca-control-plane-recovery/authoritative-root/${ci_recovery_path}" \
+ || {
+ echo "RECOVERY BLOCKED: missing authoritative ${ci_recovery_path}" >&2
+ exit 1
+ }
+done
+
+cd /tmp/ca-control-plane-recovery/authoritative-root
+find \
+ .harness \
+ .agents \
+ .claude \
+ .codex \
+ .github \
+ .tool-versions \
+ .trivyignore.yaml \
+ .gitattributes \
+ -type f -print0 \
+ | sort -z \
+ | xargs -0 sha256sum \
+ > /tmp/ca-control-plane-recovery/evidence/authoritative-sha256.txt
+test -s /tmp/ca-control-plane-recovery/evidence/authoritative-sha256.txt
+```
+
+Expected: exit `0` and a non-empty, stably sorted hash inventory. If validation fails, stop Mode A
+and ask the human to repair the export or explicitly replace the recorded decision with Mode B.
+
+- [ ] **Step 5: Record Mode B's reconstruction boundary when selected**
+
+Run only when `recovery-mode.txt` contains `mode=B-controlled-reconstruction`:
+
+```bash
+grep -q '^mode=B-controlled-reconstruction$' \
+ /tmp/ca-control-plane-recovery/evidence/recovery-mode.txt
+sha256sum \
+ docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md \
+ docs/superpowers/plans/2026-07-20-harness-policy-engine.md \
+ docs/superpowers/specs/2026-07-25-ci-control-plane-recovery-design.md \
+ docs/superpowers/plans/2026-07-25-ci-control-plane-recovery.md \
+ > /tmp/ca-control-plane-recovery/evidence/reconstruction-source-sha256.txt
+test -s /tmp/ca-control-plane-recovery/evidence/reconstruction-source-sha256.txt
+```
+
+Expected: exit `0`. These hashes identify the approved prose inputs; they are not presented as
+hashes of the missing original assets.
+
+- [ ] **Step 6: Review checkpoint**
+
+Review:
+
+```bash
+cat /tmp/ca-control-plane-recovery/evidence/recovery-mode.txt
+find /tmp/ca-control-plane-recovery/evidence -maxdepth 1 -type f -print | sort
+```
+
+Expected: one human-approved mode and its matching evidence files exist. No hidden repository file
+has been restored or reconstructed in this task.
+
+### Task 2A: Restore and Validate the Authoritative Hidden Control Plane
+
+**Files:**
+
+- Restore: `.harness/`
+- Restore: `.agents/`
+- Restore: `.claude/`
+- Restore: `.codex/`
+- Restore: `.github/`
+- Restore: `.tool-versions`
+- Restore: `.trivyignore.yaml`
+- Restore: `.gitattributes`
+
+**Entry condition:** run this task only when
+`/tmp/ca-control-plane-recovery/evidence/recovery-mode.txt` contains
+`mode=A-authoritative-restore`. Mode B skips Task 2A and executes Task 2B.
+
+- [ ] **Step 1: Assert that recovery will not overwrite an existing path**
+
+Run from the repository root:
+
+```bash
+for ci_recovery_path in \
+ .harness \
+ .agents \
+ .claude \
+ .codex \
+ .github \
+ .tool-versions \
+ .trivyignore.yaml \
+ .gitattributes
+do
+ test ! -e "${ci_recovery_path}" \
+ || {
+ echo "RECOVERY BLOCKED: destination already exists: ${ci_recovery_path}" >&2
+ exit 1
+ }
+done
+```
+
+Expected: exit `0`. If another worker restored a path, stop and compare it to the authoritative hash
+inventory instead of overwriting it.
+
+- [ ] **Step 2: Restore the exact asset set**
+
+Run:
+
+```bash
+for ci_recovery_path in \
+ .harness \
+ .agents \
+ .claude \
+ .codex \
+ .github \
+ .tool-versions \
+ .trivyignore.yaml \
+ .gitattributes
+do
+ cp -a \
+ "/tmp/ca-control-plane-recovery/authoritative-root/${ci_recovery_path}" \
+ "${ci_recovery_path}"
+done
+```
+
+Expected: all eight paths exist in the working tree.
+
+- [ ] **Step 3: Prove byte-for-byte parity with the recovery source**
+
+Run:
+
+```bash
+for ci_recovery_path in \
+ .harness \
+ .agents \
+ .claude \
+ .codex \
+ .github \
+ .tool-versions \
+ .trivyignore.yaml \
+ .gitattributes
+do
+ diff -qr \
+ "/tmp/ca-control-plane-recovery/authoritative-root/${ci_recovery_path}" \
+ "${ci_recovery_path}"
+done
+```
+
+Expected: exit `0` with no output.
+
+- [ ] **Step 4: Run the recovered harness's own tests before modifying it**
+
+Run:
+
+```bash
+python3 -m unittest discover -s .harness/tests -p 'test_*.py'
+python3 .harness/validators/validate_policy_parity.py
+python3 .harness/generators/render_agents.py --check
+```
+
+Expected: all commands exit `0`. If a recovered command name differs, stop and report the recovered
+manifest and CLI help to the controller; do not silently substitute a guessed command.
+
+- [ ] **Step 5: Confirm Gradle can now discover the registered projects**
+
+Run:
+
+```bash
+./src/gradlew -p src projects --no-daemon --console=plain
+./src/gradlew -p src verifyCleanArchitectureDependencies --no-daemon --console=plain
+```
+
+Expected: both commands exit `0`, and the project report contains all 19 registered leaf modules.
+
+- [ ] **Step 6: Stop for task-packet resolution**
+
+Using the exact overlay schema exposed by the recovered resolver and its tests, write the
+controller-approved CI/deployment overlay to:
+
+```text
+/tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-overlay.json
+```
+
+Then run the actual resolver and persist its output:
+
+```bash
+test -s /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-overlay.json
+python3 .harness/validators/resolve_task.py \
+ /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-overlay.json \
+ > /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet.json
+python3 -m json.tool \
+ /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet.json \
+ >/dev/null
+sha256sum \
+ /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet.json \
+ .harness/core/risk-policy.yaml \
+ .harness/core/evidence-policy.yaml \
+ .harness/core/review-policy.yaml \
+ .harness/core/report-policy.yaml \
+ > /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet-and-rules.sha256
+sha256sum --check \
+ /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet-and-rules.sha256
+```
+
+Expected: every command exits `0`; the overlay, resolved packet, packet hash, and all four governing
+rule hashes are retained at explicit evidence paths. The controller confirms the packet's
+high-risk CI/deployment classification before Task 3.
+
+- [ ] **Step 7: Review checkpoint**
+
+Run:
+
+```bash
+git status --short
+git diff --stat
+```
+
+Expected: only the byte-for-byte recovered assets and the two approved design/plan documents are
+present; no production Java file has changed.
+
+### Task 2B: Reconstruct the Harness Under New Provenance
+
+**Files:**
+
+- Create: `.harness/`
+- Create: `.agents/`
+- Create: `.claude/`
+- Create: `.codex/`
+- Create: `.harness/recovery-provenance.json`
+- Reference: `docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md`
+- Execute: `docs/superpowers/plans/2026-07-20-harness-policy-engine.md:Task 1-5`
+
+**Entry condition:** run this task only when
+`/tmp/ca-control-plane-recovery/evidence/recovery-mode.txt` contains
+`mode=B-controlled-reconstruction`. Mode A skips Task 2B.
+
+- [ ] **Step 1: Assert the controlled-reconstruction decision**
+
+Run:
+
+```bash
+grep -q '^mode=B-controlled-reconstruction$' \
+ /tmp/ca-control-plane-recovery/evidence/recovery-mode.txt
+grep -q '^claim=reconstructed-not-restored$' \
+ /tmp/ca-control-plane-recovery/evidence/recovery-mode.txt
+for ci_reconstruction_path in .harness .agents .claude .codex
+do
+ test ! -e "${ci_reconstruction_path}" \
+ || {
+ echo "RECONSTRUCTION BLOCKED: destination already exists: ${ci_reconstruction_path}" >&2
+ exit 1
+ }
+done
+```
+
+Expected: exit `0`.
+
+- [ ] **Step 2: Reconstruct the 2026-07-20 harness design**
+
+Use `superpowers:executing-plans` or `superpowers:subagent-driven-development` to execute Tasks 1-5
+of:
+
+```text
+docs/superpowers/plans/2026-07-20-harness-policy-engine.md
+```
+
+Apply its registry, resolver, import-gate, verdict/evidence, platform-rendering, and risk/profile
+outputs exactly to:
+
+```text
+.harness
+.agents
+.claude
+.codex
+```
+
+Expected: all 19 leaf modules are present in `.harness/project/modules.yaml`; generated platform
+agents carry new source hashes; every platform retains human-only commit policy. Do not copy
+content from an unrelated plugin cache or label these files as restored.
+
+- [ ] **Step 3: Add explicit reconstructed provenance**
+
+Create `.harness/recovery-provenance.json` with:
+
+```json
+{
+ "mode": "controlled-reconstruction",
+ "claim": "reconstructed-not-restored",
+ "decision_date": "2026-07-25",
+ "failed_baseline_revision": "821fe00c323b5335980f271c7ee47b92ac2168f2",
+ "source_documents": [
+ "docs/superpowers/specs/2026-07-20-harness-policy-engine-design.md",
+ "docs/superpowers/plans/2026-07-20-harness-policy-engine.md",
+ "docs/superpowers/specs/2026-07-25-ci-control-plane-recovery-design.md",
+ "docs/superpowers/plans/2026-07-25-ci-control-plane-recovery.md"
+ ]
+}
+```
+
+- [ ] **Step 4: Run schema, renderer, parity, and mutation evidence**
+
+Run:
+
+```bash
+python3 -m unittest discover -s .harness/tests -p 'test_*.py'
+python3 .harness/validators/validate_modules.py
+python3 .harness/validators/validate_policy_parity.py
+python3 .harness/generators/render_agents.py --check
+```
+
+Expected: all commands exit `0`, including the registry-driven import mutation suite for every
+registered production leaf.
+
+- [ ] **Step 5: Prove Gradle consumes the reconstructed registry**
+
+Run:
+
+```bash
+./src/gradlew -p src projects --no-daemon --console=plain
+./src/gradlew -p src verifyCleanArchitectureDependencies --no-daemon --console=plain
+```
+
+Expected: both commands exit `0` and the project report contains all 19 leaves.
+
+- [ ] **Step 6: Hash reconstructed outputs as new artifacts**
+
+Run:
+
+```bash
+find \
+ .harness \
+ .agents \
+ .claude \
+ .codex \
+ -type f -print0 \
+ | sort -z \
+ | xargs -0 sha256sum \
+ > /tmp/ca-control-plane-recovery/evidence/reconstructed-sha256.txt
+test -s /tmp/ca-control-plane-recovery/evidence/reconstructed-sha256.txt
+```
+
+Expected: exit `0`. The evidence filename and provenance both say reconstructed; no report calls
+these hashes a match to the lost original.
+
+- [ ] **Step 7: Stop for task-packet resolution**
+
+Using the exact overlay schema exposed by the reconstructed resolver and its tests, write the
+controller-approved CI/deployment overlay to:
+
+```text
+/tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-overlay.json
+```
+
+Then run:
+
+```bash
+test -s /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-overlay.json
+python3 .harness/validators/resolve_task.py \
+ /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-overlay.json \
+ > /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet.json
+python3 -m json.tool \
+ /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet.json \
+ >/dev/null
+sha256sum \
+ /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet.json \
+ .harness/core/risk-policy.yaml \
+ .harness/core/evidence-policy.yaml \
+ .harness/core/review-policy.yaml \
+ .harness/core/report-policy.yaml \
+ > /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet-and-rules.sha256
+sha256sum --check \
+ /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet-and-rules.sha256
+```
+
+Expected: every command exits `0`; the reconstructed authority produces a valid high-risk packet
+and explicit packet/rule checksum evidence before Task 3.
+
+- [ ] **Step 8: Review checkpoint**
+
+Run:
+
+```bash
+git status --short
+git diff --stat
+git diff --check
+```
+
+Expected: new harness/platform assets and explicit reconstruction provenance are reviewable; no
+production Java file changed.
+
+### Task 3: Add a Fail-Fast Physical Control-Plane Validator
+
+**Blocking entry assertion:** before editing any Task 3 file, verify the recorded packet and rules
+and prove that the recovered resolver is deterministic for the same overlay:
+
+```bash
+test -s /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-overlay.json
+test -s /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet.json
+test -s /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet-and-rules.sha256
+sha256sum --check \
+ /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet-and-rules.sha256
+python3 .harness/validators/resolve_task.py \
+ /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-overlay.json \
+ > /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet.recheck.json
+cmp \
+ /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet.json \
+ /tmp/ca-control-plane-recovery/evidence/ci-control-plane-recovery-packet.recheck.json
+```
+
+Expected: all commands exit `0`. Any missing checksum, changed rule, or non-deterministic packet
+returns control to Task 2A or 2B; Task 3 does not proceed.
+
+**Files:**
+
+- Create: `.harness/project/control-plane.yaml`
+- Create: `.harness/validators/validate_control_plane.py`
+- Create: `.harness/tests/test_control_plane.py`
+
+- [ ] **Step 1: Write the failing validator tests**
+
+Add a stdlib `unittest` suite with this public interface and fixture:
+
+```python
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+HARNESS_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(HARNESS_ROOT))
+
+from validators.validate_control_plane import validate_control_plane
+
+
+class ControlPlaneValidationTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.temporary_directory = tempfile.TemporaryDirectory()
+ self.repository = Path(self.temporary_directory.name)
+ policy = {
+ "schema_version": 1,
+ "canonical_workflow_directory": ".github/workflows",
+ "forbidden_workflow_shadow": ".gitea/workflows",
+ "required_directories": [".github/workflows", ".harness"],
+ "required_files": [".github/CODEOWNERS", ".tool-versions"],
+ }
+ policy_path = self.repository / ".harness/project/control-plane.yaml"
+ policy_path.parent.mkdir(parents=True)
+ policy_path.write_text(json.dumps(policy), encoding="utf-8")
+ (self.repository / ".github/workflows").mkdir(parents=True)
+ (self.repository / ".github/CODEOWNERS").write_text("* @owners\n", encoding="utf-8")
+ (self.repository / ".tool-versions").write_text("java temurin-21\n", encoding="utf-8")
+
+ def tearDown(self) -> None:
+ self.temporary_directory.cleanup()
+
+ def test_complete_control_plane_has_no_violations(self) -> None:
+ self.assertEqual([], validate_control_plane(self.repository))
+
+ def test_reports_every_missing_required_path(self) -> None:
+ (self.repository / ".tool-versions").unlink()
+ (self.repository / ".github/CODEOWNERS").unlink()
+ self.assertEqual(
+ [
+ "missing required file: .github/CODEOWNERS",
+ "missing required file: .tool-versions",
+ ],
+ validate_control_plane(self.repository),
+ )
+
+ def test_rejects_gitea_workflow_shadow(self) -> None:
+ (self.repository / ".gitea/workflows").mkdir(parents=True)
+ self.assertEqual(
+ ["forbidden workflow shadow exists: .gitea/workflows"],
+ validate_control_plane(self.repository),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
+```
+- [ ] **Step 2: Run the tests and observe the missing implementation**
+
+Run:
+
+```bash
+python3 .harness/tests/test_control_plane.py -v
+```
+
+Expected: non-zero exit because `validate_control_plane` or its manifest does not exist.
+
+- [ ] **Step 3: Add the physical manifest**
+
+Create `.harness/project/control-plane.yaml` with JSON syntax:
+
+```json
+{
+ "schema_version": 1,
+ "canonical_workflow_directory": ".github/workflows",
+ "forbidden_workflow_shadow": ".gitea/workflows",
+ "required_directories": [
+ ".agents",
+ ".claude",
+ ".codex",
+ ".github/workflows",
+ ".harness"
+ ],
+ "required_files": [
+ ".dockerignore",
+ ".gitattributes",
+ ".tool-versions",
+ ".trivyignore.yaml",
+ ".agents/plugins/ca-superpowers/rules/clean-architecture.md",
+ ".github/CODEOWNERS",
+ ".github/ci-gate-matrix.yml",
+ ".github/dependency-vulnerability-policy.md",
+ ".github/scripts/verify-gate-matrix.sh",
+ ".github/scripts/verify-reproducible-build.sh",
+ ".github/workflows/build-release-supply-chain.yml",
+ ".github/workflows/ci-quality-gates.yml",
+ ".github/workflows/dependency-vulnerability.yml",
+ ".harness/manifest.yaml",
+ ".harness/project/modules.yaml",
+ "docs/security/public-paths-snapshot.txt",
+ "src/gradle/wrapper/gradle-wrapper.jar",
+ "src/gradle/wrapper/gradle-wrapper.properties",
+ "src/gradlew"
+ ]
+}
+```
+
+- [ ] **Step 4: Implement deterministic validation**
+
+Implement:
+
+```python
+#!/usr/bin/env python3
+import json
+import sys
+from pathlib import Path
+
+
+def validate_control_plane(repository_root: Path) -> list[str]:
+ policy_path = repository_root / ".harness/project/control-plane.yaml"
+ policy = json.loads(policy_path.read_text(encoding="utf-8"))
+ violations: list[str] = []
+
+ for relative_path in policy["required_directories"]:
+ if not (repository_root / relative_path).is_dir():
+ violations.append(f"missing required directory: {relative_path}")
+
+ for relative_path in policy["required_files"]:
+ if not (repository_root / relative_path).is_file():
+ violations.append(f"missing required file: {relative_path}")
+
+ shadow = policy["forbidden_workflow_shadow"]
+ if (repository_root / shadow).exists():
+ violations.append(f"forbidden workflow shadow exists: {shadow}")
+
+ return sorted(violations)
+
+
+def main() -> int:
+ repository_root = Path(__file__).resolve().parents[2]
+ violations = validate_control_plane(repository_root)
+ if violations:
+ print("control-plane validation failed:", file=sys.stderr)
+ for violation in violations:
+ print(f" - {violation}", file=sys.stderr)
+ return 1
+ print("control-plane validation passed")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+```
+
+If the recovered harness has an established CLI/result abstraction, retain the function signature
+and deterministic messages above while adapting only the entrypoint plumbing to that abstraction.
+
+- [ ] **Step 5: Run positive and mutation tests**
+
+Run:
+
+```bash
+python3 .harness/tests/test_control_plane.py -v
+python3 .harness/validators/validate_control_plane.py
+```
+
+Expected: unit tests pass. The repository invocation remains non-zero and deterministically lists
+the contracts not yet created by Tasks 4-6. This is the intended control-plane red state; do not
+weaken the manifest to make it green.
+
+- [ ] **Step 6: Review checkpoint**
+
+Run:
+
+```bash
+git diff --check
+python3 -m unittest discover -s .harness/tests -p 'test_*.py'
+```
+
+Expected: no whitespace errors and the full recovered-plus-new harness suite passes.
+
+### Task 4: Restore Security Contracts and Make Public Paths Fail Closed
+
+**Files:**
+
+- Restore or create: `.tool-versions`
+- Restore or create: `.gitattributes`
+- Restore or create: `.trivyignore.yaml`
+- Create: `docs/security/public-paths-snapshot.txt`
+- Modify: `src/build.gradle:274-280,754-819`
+- Modify: `src/README.md:115-152`
+- Test: `.harness/tests/test_control_plane.py`
+
+- [ ] **Step 1: Establish the three root baseline contracts**
+
+For Mode A, verify the three files still match the authoritative export:
+
+```bash
+diff -q \
+ /tmp/ca-control-plane-recovery/authoritative-root/.tool-versions \
+ .tool-versions
+diff -q \
+ /tmp/ca-control-plane-recovery/authoritative-root/.gitattributes \
+ .gitattributes
+diff -q \
+ /tmp/ca-control-plane-recovery/authoritative-root/.trivyignore.yaml \
+ .trivyignore.yaml
+```
+
+Expected in Mode A: all commands exit `0`.
+
+For Mode B, create `.tool-versions` with:
+
+```text
+java temurin-21.0.11+10.0.LTS
+```
+
+Create `.gitattributes` with:
+
+```gitattributes
+* text=auto eol=lf
+*.bat text eol=crlf
+*.jar binary
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+```
+
+Create `.trivyignore.yaml` with:
+
+```yaml
+vulnerabilities: []
+licenses: []
+misconfigurations: []
+secrets: []
+```
+
+Expected in Mode B: the files are reported as reconstructed in the review evidence and are not
+described as recovered originals.
+
+- [ ] **Step 2: Verify the structured Trivy contract and Java major**
+
+Run:
+
+```bash
+sed -n '1,160p' .trivyignore.yaml
+java -version 2>&1 | grep -E 'version "21(\.|")'
+./src/gradlew -p src verifyTrivyignore --no-daemon --console=plain
+```
+
+Expected: the file contains all four structured sections and Gradle exits `0`. If there are active
+suppressions, each has the recovered reason and bounded future expiry.
+
+- [ ] **Step 3: Add a failing missing-baseline mutation**
+
+Extend `.harness/tests/test_control_plane.py` so deleting
+`docs/security/public-paths-snapshot.txt` produces:
+
+```python
+["missing required file: docs/security/public-paths-snapshot.txt"]
+```
+
+Run:
+
+```bash
+python3 .harness/tests/test_control_plane.py -v
+```
+
+Expected: exit `0`; the isolated fixture proves that deleting the baseline returns the exact
+blocking violation.
+
+- [ ] **Step 4: Create the reviewed current baseline**
+
+Create `docs/security/public-paths-snapshot.txt` with:
+
+```text
+# feature-security-operational-baseline D5 — deny-by-default public path snapshot.
+# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated.
+# Update only with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange
+/api/healthcheck
+```
+
+Expected: the single non-comment path matches `src/.env:121`.
+
+- [ ] **Step 5: Write the read-only verification behavior before changing Gradle**
+
+In an isolated execution worktree, temporarily move the snapshot and run:
+
+```bash
+./src/gradlew -p src verifyPublicPathSnapshot --no-daemon --console=plain
+```
+
+Expected before the fix: exit `0` and a newly generated file. Record this as the failing
+characterization because verification should return non-zero when the baseline is absent. Restore
+the reviewed snapshot before continuing.
+
+- [ ] **Step 6: Split verification from approved update**
+
+Change `verifyPublicPathSnapshot` so:
+
+```groovy
+if (!snapshotFile.isFile()) {
+ throw new GradleException(
+ "verifyPublicPathSnapshot: missing committed baseline ${snapshotFile}")
+}
+```
+
+Remove all writes from that task. Register `updatePublicPathSnapshot` to require
+`-PapprovePublicPathChange`, create the parent directory, and write the same canonical content.
+Without the property it must fail with:
+
+```text
+updatePublicPathSnapshot requires -PapprovePublicPathChange
+```
+
+- [ ] **Step 7: Wire read-only verification into every leaf `check`**
+
+At `src/build.gradle:274-280`, add:
+
+```groovy
+dependsOn rootProject.tasks.named('verifyPublicPathSnapshot')
+```
+
+Expected: `check` verifies but never updates the baseline.
+
+- [ ] **Step 8: Verify positive and negative behavior**
+
+Run:
+
+```bash
+./src/gradlew -p src verifyPublicPathSnapshot --no-daemon --console=plain
+./src/gradlew -p src updatePublicPathSnapshot --no-daemon --console=plain
+```
+
+Expected: verification exits `0`; update exits non-zero with the required approval message.
+
+Then, in the isolated execution worktree, move the snapshot aside and rerun verification.
+
+Expected: non-zero exit with `missing committed baseline`; no replacement file is created.
+
+- [ ] **Step 9: Update the security-gate documentation**
+
+Change `src/README.md` to state that the snapshot is committed, missing state fails closed, and only
+`updatePublicPathSnapshot -PapprovePublicPathChange` writes it. Remove the claim that a fresh
+checkout creates a baseline and passes.
+
+- [ ] **Step 10: Review checkpoint**
+
+Run:
+
+```bash
+python3 .harness/tests/test_control_plane.py -v
+./src/gradlew -p src verifyTrivyignore verifyPublicPathSnapshot --no-daemon --console=plain
+```
+
+Expected: unit and Gradle commands exit `0`. Repository-wide control-plane validation is still
+expected to report the not-yet-created root `.dockerignore` and, in Mode B, the not-yet-reconstructed
+`.github` contracts; Tasks 5 and 6 close those failures.
+
+### Task 5: Move Docker Builds to the Repository-Root Context
+
+**Files:**
+
+- Create: `.dockerignore`
+- Modify: `docker-compose.yml:28-35`
+- Modify: `src/Dockerfile:30-54`
+- Modify: `src/Dockerfile.sample:42-63`
+- Modify in Mode A, create in Task 6 for Mode B: `.github/scripts/verify-reproducible-build.sh`
+- Modify: `README.md`
+
+- [ ] **Step 1: Reproduce the current context failure**
+
+Run:
+
+```bash
+docker build \
+ -f src/Dockerfile \
+ src/ \
+ --build-arg RELEASE_VERSION=0.0.1 \
+ --build-arg BUILD_VERSION=0.0.1+821fe00 \
+ --build-arg GIT_SHA=821fe00 \
+ --build-arg SOURCE_URL=https://git.learn.hyeonworks.com/donghyeon.kang/clean-architecture-backend-template \
+ --tag caskeleton:context-red
+```
+
+Expected before the fix: non-zero exit while settings reports a missing
+`/build/.harness/project/modules.yaml`.
+
+- [ ] **Step 2: Add the root context exclusion contract**
+
+Create `.dockerignore` with:
+
+```dockerignore
+.git
+.git/**
+.gitea
+.github
+.agents
+.claude
+.codex
+docs
+**/.gradle
+**/build
+**/test-results
+**/reports
+**/.idea
+**/.vscode
+**/*.iml
+**/.env
+**/.env.*
+tmp
+
+!.harness/
+!.harness/project/
+!.harness/project/modules.yaml
+!src/
+!src/gradlew
+!src/gradle/
+!src/gradle/wrapper/
+!src/gradle/wrapper/gradle-wrapper.jar
+!src/gradle/wrapper/gradle-wrapper.properties
+!src/settings.gradle
+!src/build.gradle
+!src/**/build.gradle
+!src/**/gradle.lockfile
+!src/**/src/
+```
+
+Expected: root governance, Git metadata, build output, and environment files stay out of the
+context; the module registry and Gradle source inputs remain available.
+
+- [ ] **Step 3: Change the production builder layout**
+
+In `src/Dockerfile`, use:
+
+```dockerfile
+WORKDIR /build/src
+
+COPY --parents \
+ .harness/project/modules.yaml \
+ src/settings.gradle \
+ src/build.gradle \
+ src/**/build.gradle \
+ src/**/gradle.lockfile \
+ /build/
+COPY src/gradlew ./
+COPY src/gradle/ gradle/
+
+RUN test -n "${RELEASE_VERSION}" \
+ && test -n "${GIT_SHA}" \
+ && ./gradlew verifyDependencyLocks --no-daemon --quiet \
+ -PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
+
+COPY src/ /build/src/
+RUN ./gradlew :app-bootstrap:bootJar --no-daemon -x test \
+ -PreleaseVersion="${RELEASE_VERSION}" -PgitRevision="${GIT_SHA}"
+```
+
+Keep the runtime stage unchanged.
+
+- [ ] **Step 4: Mirror the sample builder layout**
+
+Apply the same `/build/src`, registry, wrapper, descriptor, lock, and source copy order to
+`src/Dockerfile.sample`; retain `:sample-portfolio:bootJar` as its target.
+
+- [ ] **Step 5: Change Compose to root context**
+
+At `docker-compose.yml:28-35`, use:
+
+```yaml
+build:
+ context: .
+ dockerfile: src/Dockerfile
+ args:
+ RELEASE_VERSION: "${RELEASE_VERSION:-0.0.1}"
+ BUILD_VERSION: "${BUILD_VERSION:-0.0.1+0000000}"
+ GIT_SHA: "${GIT_SHA:-0000000}"
+ SOURCE_URL: "${SOURCE_URL:-https://example.invalid/ca-tmpl}"
+```
+
+- [ ] **Step 6: Update reproducible-build and README commands**
+
+Every production build command must use:
+
+```bash
+docker build -f src/Dockerfile .
+```
+
+Every sample build command must use:
+
+```bash
+docker build -f src/Dockerfile.sample .
+```
+
+Expected: no tracked command retains `src/` as its Docker context.
+
+- [ ] **Step 7: Verify Compose and both images**
+
+Run:
+
+```bash
+docker compose -f docker-compose.yml -f docker-compose.local.yml config --quiet
+docker build \
+ -f src/Dockerfile \
+ . \
+ --build-arg RELEASE_VERSION=0.0.1 \
+ --build-arg BUILD_VERSION=0.0.1+821fe00 \
+ --build-arg GIT_SHA=821fe00 \
+ --build-arg SOURCE_URL=https://git.learn.hyeonworks.com/donghyeon.kang/clean-architecture-backend-template \
+ --tag caskeleton:context-green
+docker build \
+ -f src/Dockerfile.sample \
+ . \
+ --tag caskeleton-sample:context-green
+```
+
+Expected: Compose exits `0`; both images build successfully; settings finds
+`/build/.harness/project/modules.yaml`; strict dependency-lock verification succeeds.
+
+- [ ] **Step 8: Review checkpoint**
+
+Run:
+
+```bash
+rg -n 'docker build .* src/' README.md src/README.md src/Dockerfile src/Dockerfile.sample
+rg -n 'context:\\s*src/?' docker-compose*.yml
+```
+
+Expected: both searches return no matches.
+
+### Task 6: Enable Gitea Actions and Establish the Canonical Release Gate
+
+**Files:**
+
+- Restore or create/modify: `.github/workflows/ci-quality-gates.yml`
+- Restore or create/modify: `.github/workflows/build-release-supply-chain.yml`
+- Restore or create/modify: `.github/workflows/dependency-vulnerability.yml`
+- Restore or create/modify: `.github/ci-gate-matrix.yml`
+- Restore or create/modify: `.github/scripts/verify-gate-matrix.sh`
+- Restore or create/modify: `.github/scripts/verify-reproducible-build.sh`
+- Restore or create/modify: `.github/CODEOWNERS`
+- Restore or create: `.github/dependency-vulnerability-policy.md`
+- Verify absent: `.gitea/workflows/`
+- External: Gitea repository Actions setting and repository-scoped runner
+
+- [ ] **Step 1: Establish the canonical `.github` tree for the selected mode**
+
+For Mode A, run:
+
+```bash
+diff -qr \
+ /tmp/ca-control-plane-recovery/authoritative-root/.github \
+ .github
+```
+
+Expected in Mode A: exit `0` before intentional workflow edits.
+
+For Mode B, create these directories:
+
+```text
+.github
+.github/scripts
+.github/workflows
+```
+
+Create `.github/CODEOWNERS` with:
+
+```text
+/.agents/ @donghyeon.kang
+/.claude/ @donghyeon.kang
+/.codex/ @donghyeon.kang
+/.github/ @donghyeon.kang
+/.harness/ @donghyeon.kang
+/.trivyignore.yaml @donghyeon.kang
+/docs/security/public-paths-snapshot.txt @donghyeon.kang
+/renovate.json @donghyeon.kang
+```
+
+Create `.github/dependency-vulnerability-policy.md` with:
+
+```markdown
+# Dependency Vulnerability Policy
+
+- HIGH and CRITICAL fixable vulnerabilities block the dependency-vulnerability status.
+- Trivy suppressions require `.trivyignore.yaml` reason, future expiry, and CODEOWNERS review.
+- Scanner actions are pinned to full reviewed revisions.
+- Dependency declarations and strict Gradle lockfiles change together.
+- Renovate automerge remains disabled during CI recovery.
+```
+
+Expected in Mode B: the tree is explicitly reconstructed under
+`.harness/recovery-provenance.json`; it is not called a restored GitHub control plane.
+
+- [ ] **Step 2: Confirm the Gitea baseline**
+
+Run:
+
+```bash
+curl -fsS https://git.learn.hyeonworks.com/api/v1/version \
+ | jq -e '.version == "1.27.0"'
+curl -fsS \
+ https://git.learn.hyeonworks.com/api/v1/repos/donghyeon.kang/clean-architecture-backend-template \
+ | jq -e '.has_actions == false'
+```
+
+Expected before enablement: both commands exit `0`.
+
+- [ ] **Step 3: Enable repository Actions**
+
+Human action: open
+`https://git.learn.hyeonworks.com/donghyeon.kang/clean-architecture-backend-template/settings`
+and enable `Enable Repository Actions`.
+
+Verify:
+
+```bash
+curl -fsS \
+ https://git.learn.hyeonworks.com/api/v1/repos/donghyeon.kang/clean-architecture-backend-template \
+ | jq -e '.has_actions == true'
+```
+
+Expected: exit `0`.
+
+- [ ] **Step 4: Confirm the instance workflow-directory contract**
+
+Administrator action: inspect `[actions].WORKFLOW_DIRS` in the active Gitea configuration and
+confirm it contains:
+
+```text
+.gitea/workflows,.github/workflows
+```
+
+Expected: `.github/workflows` is eligible and `.gitea/workflows` is absent from this repository.
+If the active configuration excludes `.github/workflows`, stop until the administrator corrects it.
+
+- [ ] **Step 5: Register an isolated repository-scoped runner**
+
+Obtain the repository registration token from:
+
+```text
+https://git.learn.hyeonworks.com/donghyeon.kang/clean-architecture-backend-template/settings/actions/runners
+```
+
+Load it into the runner host's secret environment as
+`GITEA_RUNNER_REGISTRATION_TOKEN`, then run on that host:
+
+```bash
+act_runner --config /etc/act_runner/config.yaml register \
+ --no-interactive \
+ --instance https://git.learn.hyeonworks.com \
+ --token "${GITEA_RUNNER_REGISTRATION_TOKEN}" \
+ --name ca-skeleton-repository \
+ --labels ubuntu-22.04:docker://gitea/runner-images:ubuntu-22.04
+act_runner --config /etc/act_runner/config.yaml daemon
+```
+
+Expected: repository settings show an enabled, idle runner named `ca-skeleton-repository` with
+label `ubuntu-22.04`. The registration token is not written to this repository or printed in CI.
+
+- [ ] **Step 6: Verify runner inventory with authorization**
+
+Load a read-only repository or administrator API token into
+`GITEA_ACTIONS_AUDIT_TOKEN`, then run:
+
+```bash
+curl -fsS \
+ -H "Authorization: token ${GITEA_ACTIONS_AUDIT_TOKEN}" \
+ https://git.learn.hyeonworks.com/api/v1/repos/donghyeon.kang/clean-architecture-backend-template/actions/runners \
+ | jq -e '(.runners // .) | any(.name == "ca-skeleton-repository" and .status == "online" and .disabled == false)'
+```
+
+Expected: exit `0`. Without authorization the endpoint may return `401`; that remains expected.
+
+- [ ] **Step 7: Preserve `.github/workflows` as the only workflow directory**
+
+Run:
+
+```bash
+test ! -e .gitea/workflows
+find .github/workflows -maxdepth 1 -type f -name '*.yml' -print | sort
+```
+
+Expected: no shadow directory. Mode A lists the recovered workflows; Mode B may list none until
+Steps 8-10 create them.
+
+- [ ] **Step 8: Establish the quality job graph**
+
+Make `.github/workflows/ci-quality-gates.yml` conform to this stable graph:
+
+```yaml
+name: CI Quality Gates
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+jobs:
+ control-plane-preflight:
+ name: control-plane-preflight
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: gradle
+ - run: python3 -m unittest discover -s .harness/tests -p 'test_*.py'
+ - run: python3 .harness/validators/validate_control_plane.py
+ - run: python3 .harness/validators/validate_modules.py
+ - run: python3 .harness/validators/validate_policy_parity.py
+ - run: python3 .harness/generators/render_agents.py --check
+ - run: bash .github/scripts/verify-gate-matrix.sh
+ - run: ./src/gradlew -p src verifyTrivyignore --no-daemon --console=plain
+
+ gradle-quality:
+ name: gradle-quality
+ needs: control-plane-preflight
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: gradle
+ - working-directory: src
+ run: ./gradlew verifyDependencyLocks verifyCleanArchitectureDependencies verifyPublicPathSnapshot test check --no-daemon --console=plain
+
+ container-build:
+ name: container-build
+ needs: control-plane-preflight
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - run: |
+ ci_revision="$(git rev-parse HEAD)"
+ docker build -f src/Dockerfile . \
+ --build-arg RELEASE_VERSION=0.0.1 \
+ --build-arg BUILD_VERSION="0.0.1+${ci_revision:0:12}" \
+ --build-arg GIT_SHA="${ci_revision}" \
+ --build-arg SOURCE_URL=https://git.learn.hyeonworks.com/donghyeon.kang/clean-architecture-backend-template \
+ --tag caskeleton:ci
+ docker build -f src/Dockerfile.sample . --tag caskeleton-sample:ci
+
+ quarantine:
+ name: quarantine
+ needs: control-plane-preflight
+ runs-on: ubuntu-22.04
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: gradle
+ - working-directory: src
+ run: ./gradlew quarantineTest --no-daemon --console=plain
+
+ release-gate:
+ name: release-gate
+ if: always()
+ needs:
+ - control-plane-preflight
+ - gradle-quality
+ - container-build
+ runs-on: ubuntu-22.04
+ steps:
+ - name: Require every blocking quality job
+ run: |
+ test "${{ needs.control-plane-preflight.result }}" = "success"
+ test "${{ needs.gradle-quality.result }}" = "success"
+ test "${{ needs.container-build.result }}" = "success"
+```
+
+In Mode A, merge stronger recovered static-analysis, reproducibility, and gate-matrix jobs into this
+graph without renaming their stable statuses. In Mode B, the graph above is the minimum initial
+quality contract. Every additional blocking job must be added to `release-gate.needs` and its shell
+assertions; quarantine remains outside the fan-in.
+
+- [ ] **Step 9: Establish release-build and vulnerability workflows**
+
+Make `.github/workflows/build-release-supply-chain.yml` use this Gitea-compatible contract:
+
+```yaml
+name: Build Release Supply Chain
+
+on:
+ push:
+ tags:
+ - "v*"
+
+jobs:
+ release-preflight:
+ name: release-preflight
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: gradle
+ - run: python3 -m unittest discover -s .harness/tests -p 'test_*.py'
+ - run: python3 .harness/validators/validate_control_plane.py
+ - run: python3 .harness/validators/validate_modules.py
+ - run: python3 .harness/validators/validate_policy_parity.py
+ - run: python3 .harness/generators/render_agents.py --check
+ - run: bash .github/scripts/verify-gate-matrix.sh
+ - run: ./src/gradlew -p src verifyTrivyignore --no-daemon --console=plain
+
+ release-build:
+ name: release-build
+ needs: release-preflight
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: gradle
+ - run: ./src/gradlew -p src verifyDependencyLocks check --no-daemon --console=plain
+ - run: bash .github/scripts/verify-reproducible-build.sh
+ - run: |
+ release_revision="$(git rev-parse HEAD)"
+ release_tag="$(git describe --tags --exact-match)"
+ release_version="${release_tag#v}"
+ docker build -f src/Dockerfile . \
+ --build-arg RELEASE_VERSION="${release_version}" \
+ --build-arg BUILD_VERSION="${release_version}+${release_revision:0:12}" \
+ --build-arg GIT_SHA="${release_revision}" \
+ --build-arg SOURCE_URL=https://git.learn.hyeonworks.com/donghyeon.kang/clean-architecture-backend-template \
+ --tag "caskeleton:${release_version}"
+```
+
+Make `.github/workflows/dependency-vulnerability.yml` publish this separate blocking status:
+
+```yaml
+name: Dependency Vulnerability
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ schedule:
+ - cron: "17 2 * * *"
+
+jobs:
+ dependency-vulnerability:
+ name: dependency-vulnerability
+ runs-on: ubuntu-22.04
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
+ with:
+ distribution: temurin
+ java-version: "21"
+ cache: gradle
+ - run: python3 .harness/validators/validate_control_plane.py
+ - run: ./src/gradlew -p src verifyTrivyignore --no-daemon --console=plain
+ - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
+ with:
+ scan-type: fs
+ scan-ref: .
+ trivyignores: .trivyignore.yaml
+ format: table
+ exit-code: "1"
+ ignore-unfixed: true
+ severity: HIGH,CRITICAL
+```
+
+Expected: release tags re-run local release gates before building; pull requests and `main` publish
+the independent `dependency-vulnerability` status. All third-party actions are pinned to full
+reviewed revisions.
+
+- [ ] **Step 10: Align gate matrix and scripts**
+
+For Mode B, create `.github/ci-gate-matrix.yml` with:
+
+```yaml
+version: 1
+blocking:
+ - id: control-plane
+ workflow: .github/workflows/ci-quality-gates.yml
+ job: control-plane-preflight
+ commands:
+ - python3 -m unittest discover -s .harness/tests -p test_*.py
+ - python3 .harness/validators/validate_control_plane.py
+ - python3 .harness/validators/validate_modules.py
+ - python3 .harness/validators/validate_policy_parity.py
+ - python3 .harness/generators/render_agents.py --check
+ - bash .github/scripts/verify-gate-matrix.sh
+ - ./src/gradlew -p src verifyTrivyignore
+ release_gate: true
+ - id: gradle-quality
+ workflow: .github/workflows/ci-quality-gates.yml
+ job: gradle-quality
+ command: ./gradlew verifyDependencyLocks verifyCleanArchitectureDependencies verifyPublicPathSnapshot test check
+ release_gate: true
+ - id: container-build
+ workflow: .github/workflows/ci-quality-gates.yml
+ job: container-build
+ command: docker build -f src/Dockerfile .
+ release_gate: true
+ - id: dependency-vulnerability
+ workflow: .github/workflows/dependency-vulnerability.yml
+ job: dependency-vulnerability
+ commands:
+ - ./src/gradlew -p src verifyTrivyignore
+ - aquasecurity/trivy-action with trivyignores=.trivyignore.yaml
+ release_gate: protected-branch
+non_blocking:
+ - id: quarantine
+ workflow: .github/workflows/ci-quality-gates.yml
+ job: quarantine
+ release_gate: false
+```
+
+For Mode A, preserve its recovered schema and add equivalent rows without deleting stronger gates.
+
+Create or update `.github/scripts/verify-gate-matrix.sh` with these deterministic checks:
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+
+ci_repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "${ci_repository_root}"
+
+test ! -e .gitea/workflows
+for ci_workflow in \
+ .github/workflows/ci-quality-gates.yml \
+ .github/workflows/build-release-supply-chain.yml \
+ .github/workflows/dependency-vulnerability.yml
+do
+ test -f "${ci_workflow}"
+done
+
+for ci_job in control-plane-preflight gradle-quality container-build release-gate
+do
+ rg -q "^ ${ci_job}:$" .github/workflows/ci-quality-gates.yml
+done
+
+ci_release_gate="$(
+ awk '
+ /^ release-gate:$/ { in_release_gate = 1; next }
+ in_release_gate && /^ [A-Za-z0-9_-]+:$/ { exit }
+ in_release_gate { print }
+ ' .github/workflows/ci-quality-gates.yml
+)"
+for ci_dependency in control-plane-preflight gradle-quality container-build
+do
+ grep -q -- "- ${ci_dependency}" <<< "${ci_release_gate}"
+done
+if grep -q -- '- quarantine' <<< "${ci_release_gate}"
+then
+ echo "quarantine must not block release-gate" >&2
+ exit 1
+fi
+
+for ci_gate in control-plane gradle-quality container-build dependency-vulnerability quarantine
+do
+ rg -q "id: ${ci_gate}$" .github/ci-gate-matrix.yml
+done
+```
+
+For Mode B, create `.github/scripts/verify-reproducible-build.sh` with:
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+
+ci_repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+ci_revision="$(git -C "${ci_repository_root}" rev-parse HEAD)"
+cd "${ci_repository_root}/src"
+
+./gradlew clean :app-bootstrap:bootJar \
+ --no-daemon --no-build-cache --rerun-tasks \
+ -PreleaseVersion=0.0.1 -PgitRevision="${ci_revision}"
+ci_first_jar="$(find app-bootstrap/build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' -print -quit)"
+ci_first_hash="$(sha256sum "${ci_first_jar}" | cut -d' ' -f1)"
+
+./gradlew clean :app-bootstrap:bootJar \
+ --no-daemon --no-build-cache --rerun-tasks \
+ -PreleaseVersion=0.0.1 -PgitRevision="${ci_revision}"
+ci_second_jar="$(find app-bootstrap/build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' -print -quit)"
+ci_second_hash="$(sha256sum "${ci_second_jar}" | cut -d' ' -f1)"
+
+test "${ci_first_hash}" = "${ci_second_hash}"
+echo "reproducible bootJar sha256=${ci_second_hash}"
+```
+
+Make both scripts executable:
+
+```bash
+chmod 0755 \
+ .github/scripts/verify-gate-matrix.sh \
+ .github/scripts/verify-reproducible-build.sh
+```
+
+Run:
+
+```bash
+bash .github/scripts/verify-gate-matrix.sh
+python3 .harness/validators/validate_control_plane.py
+```
+
+Expected: both commands exit `0`; this is the first point at which the full physical control-plane
+validator is green in Mode B.
+
+- [ ] **Step 11: Exercise the workflow before protecting the branch**
+
+Human action: open a pull request containing only the reviewed recovery changes.
+
+Expected in Gitea Actions:
+
+```text
+control-plane-preflight: success
+gradle-quality: success
+container-build: success
+release-gate: success
+```
+
+The dependency vulnerability workflow must also publish its documented blocking success status.
+
+- [ ] **Step 12: Configure protected-branch requirements**
+
+Human repository-owner action: require the exact successful `release-gate` status and the exact
+dependency-vulnerability blocking status on `main`. Require CODEOWNERS review for the recovered
+policy, workflow, suppression, and public-path baseline paths.
+
+Expected: a pull request cannot merge when either required status is absent, pending, or failed.
+
+- [ ] **Step 13: Seed negative status exercises**
+
+Use separate temporary branches to prove:
+
+1. removing `.tool-versions` fails `control-plane-preflight`;
+2. creating `.gitea/workflows` fails `control-plane-preflight`;
+3. changing `SECURITY_PUBLIC_PATHS` without the snapshot fails `gradle-quality`;
+4. making one lockfile stale fails `gradle-quality`;
+5. adding a forbidden project edge fails `gradle-quality`;
+6. breaking the root Docker context fails `container-build`;
+7. every failure makes `release-gate` fail.
+
+Expected: branch protection blocks all seven pull requests. Close the exercises without merging.
+
+- [ ] **Step 14: Review checkpoint**
+
+Run:
+
+```bash
+python3 .harness/validators/validate_control_plane.py
+bash .github/scripts/verify-gate-matrix.sh
+git diff --check
+```
+
+Expected: all commands exit `0`.
+
+### Task 7: Enforce Dependency Locks and Pause Renovate Autonomy
+
+**Files:**
+
+- Modify: `renovate.json:3-34`
+- Modify: `.github/workflows/ci-quality-gates.yml`
+- Modify: `.github/ci-gate-matrix.yml`
+- Verify: all 19 `src/**/gradle.lockfile` files
+
+- [ ] **Step 1: Disable every Renovate automerge path**
+
+Change the patch/pin/digest rule to:
+
+```json
+{
+ "description": "Security patch, pin, and digest updates require human review until Gitea required checks and strict lock refresh are proven.",
+ "matchUpdateTypes": ["patch", "pin", "digest"],
+ "automerge": false
+}
+```
+
+Keep minor/major automerge disabled.
+
+- [ ] **Step 2: Correct the dependency-model description**
+
+Replace the version-catalog claim with:
+
+```text
+Renovate is primary over Dependabot for this repository's Gradle build scripts and per-leaf strict lockfiles. The repository does not currently use gradle/libs.versions.toml; direct declarations and all affected lockfiles must change together.
+```
+
+Expected: `renovate.json` no longer claims a non-existent version catalog.
+
+- [ ] **Step 3: Validate JSON and count the lock contracts**
+
+Run:
+
+```bash
+jq -e . renovate.json > /dev/null
+test "$(find src -name gradle.lockfile -type f | wc -l)" -eq 19
+```
+
+Expected: exit `0`.
+
+- [ ] **Step 4: Verify strict locks without writing**
+
+Run:
+
+```bash
+./src/gradlew -p src verifyDependencyLocks --no-daemon --console=plain
+```
+
+Expected: exit `0`; no lockfile changes appear in `git status`.
+
+- [ ] **Step 5: Exercise the supported lock refresh in an isolated branch**
+
+Check out the first real Renovate security pull-request branch created after Gitea enablement. Verify
+that its dependency declaration and affected lockfiles are both present, then run:
+
+```bash
+./src/gradlew -p src resolveAndLockAll --write-locks --no-daemon --console=plain
+./src/gradlew -p src verifyDependencyLocks test check --no-daemon --console=plain
+```
+
+Expected: only the declaration and affected lockfiles change; all verification commands exit `0`.
+Close the exercise without merging if it was created solely as a control test.
+
+- [ ] **Step 6: Prove stale lock state is blocking**
+
+In a separate temporary branch based on the recovery branch before the Renovate update, remove
+this exact lock entry from `src/application-core/gradle.lockfile` without changing the declaration:
+
+```text
+org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
+```
+
+Then run:
+
+```bash
+./src/gradlew -p src verifyDependencyLocks --no-daemon --console=plain
+```
+
+Expected: non-zero exit naming missing or stale lock state; the Gitea `gradle-quality` and
+`release-gate` statuses fail.
+
+- [ ] **Step 7: Keep automerge paused**
+
+Review the five re-enable conditions in the design. Record their evidence for the human owner, but
+leave `"automerge": false` in this recovery.
+
+- [ ] **Step 8: Review checkpoint**
+
+Run:
+
+```bash
+jq -e '.packageRules | all(.automerge == false)' renovate.json
+git diff --check
+```
+
+Expected: both commands exit `0`.
+
+### Task 8: Documentation Parity and Full Verification
+
+**Files:**
+
+- Modify: `README.md:31-107`
+- Modify: `src/README.md:39-181`
+- Modify: `AGENTS.md` only if the recovered authority requires a path or command correction
+- Modify: `CLAUDE.md` only if the recovered authority requires a path or command correction
+- Verify: all files changed by Tasks 1-7
+
+- [ ] **Step 1: Correct root onboarding and CI ownership**
+
+Update `README.md` so it states:
+
+- Gitea 1.27.0 hosts the repository;
+- repository Actions and an online runner are operational prerequisites;
+- `.github/workflows` is canonical and `.gitea/workflows` must remain absent;
+- Docker builds use repository-root context;
+- task packet resolution requires the restored `.harness`;
+- `release-gate` and the vulnerability status are protected-branch requirements.
+
+- [ ] **Step 2: Correct build and security guidance**
+
+Update `src/README.md` so it states:
+
+- `.harness/project/modules.yaml`, not a Gradle map, owns module edges;
+- `verifyDependencyLocks` is read-only and `resolveAndLockAll --write-locks` is the only refresh;
+- public-path verification never writes;
+- Gitea workflow discovery uses canonical `.github/workflows` only because no shadow directory
+ exists;
+- CI automerge remains paused.
+
+- [ ] **Step 3: Run physical, harness, and documentation validators**
+
+Run:
+
+```bash
+python3 .harness/validators/validate_control_plane.py
+python3 -m unittest discover -s .harness/tests -p 'test_*.py'
+python3 .harness/validators/validate_modules.py
+python3 .harness/validators/validate_policy_parity.py
+python3 .harness/generators/render_agents.py --check
+bash .github/scripts/verify-gate-matrix.sh
+./src/gradlew -p src verifyTrivyignore --no-daemon --console=plain
+```
+
+Expected: all commands exit `0`.
+
+- [ ] **Step 4: Run the complete Gradle evidence chain**
+
+Run:
+
+```bash
+./src/gradlew -p src projects --no-daemon --console=plain
+./src/gradlew -p src verifyDependencyLocks --no-daemon --console=plain
+./src/gradlew -p src verifyCleanArchitectureDependencies --no-daemon --console=plain
+./src/gradlew -p src :app-bootstrap:test --tests '*CleanArchitectureTest' --no-daemon --console=plain
+./src/gradlew -p src verifyPublicPathSnapshot --no-daemon --console=plain
+./src/gradlew -p src verifyEnvKeys --no-daemon --console=plain
+./src/gradlew -p src test --no-daemon --console=plain
+./src/gradlew -p src check --no-daemon --console=plain
+```
+
+Expected: all commands exit `0`; project discovery lists all 19 leaves; no task writes policy,
+snapshot, or lock state.
+
+- [ ] **Step 5: Run Docker and reproducibility evidence**
+
+Run:
+
+```bash
+docker compose -f docker-compose.yml -f docker-compose.local.yml config --quiet
+bash .github/scripts/verify-reproducible-build.sh
+docker build -f src/Dockerfile.sample . --tag caskeleton-sample:final-verification
+```
+
+Expected: Compose syntax passes, reproducible production artifacts have matching hashes, and the
+sample image builds.
+
+- [ ] **Step 6: Verify no shadow, secret, or stale documentation path remains**
+
+Run:
+
+```bash
+test ! -e .gitea/workflows
+git grep -n 'docker build .* src/' -- README.md src/README.md src/Dockerfile src/Dockerfile.sample .github \
+ && exit 1 || true
+git grep -n 'gradle/libs.versions.toml' -- renovate.json \
+ && exit 1 || true
+git grep -nE '(GITEA_RUNNER_REGISTRATION_TOKEN=|Authorization: token )[A-Za-z0-9_-]{20,}' \
+ -- . ':!docs/superpowers/**' \
+ && exit 1 || true
+```
+
+Expected: exit `0` and no leaked token value or stale context/catalog claim.
+
+- [ ] **Step 7: Perform architecture, specification, and quality review**
+
+Review in this order:
+
+1. recovered hashes and 2026-07-20 harness parity;
+2. current design acceptance criteria;
+3. Gitea workflow semantics and runner isolation;
+4. Gradle/Docker behavior and negative exercises;
+5. documentation and operational safety.
+
+Expected: every blocking finding is fixed and the full relevant verification chain is rerun.
+
+- [ ] **Step 8: Finalize the complete Mode B reconstruction inventory**
+
+When Mode B was selected, first record the final evidence paths in reconstructed provenance:
+
+```bash
+python3 - <<'PY'
+import json
+from pathlib import Path
+
+path = Path(".harness/recovery-provenance.json")
+provenance = json.loads(path.read_text(encoding="utf-8"))
+provenance["finalized_after"] = "Tasks 3-8 verification"
+provenance["final_inventory_evidence"] = (
+ "/tmp/ca-control-plane-recovery/evidence/mode-b-final-reconstruction-sha256.txt"
+)
+provenance["final_inventory_checksum_evidence"] = (
+ "/tmp/ca-control-plane-recovery/evidence/mode-b-final-reconstruction-sha256.txt.sha256"
+)
+path.write_text(
+ json.dumps(provenance, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+)
+PY
+find \
+ .harness \
+ .agents \
+ .claude \
+ .codex \
+ .github \
+ .tool-versions \
+ .trivyignore.yaml \
+ .gitattributes \
+ .dockerignore \
+ docs/security/public-paths-snapshot.txt \
+ -type f -print0 \
+ | sort -z \
+ | xargs -0 sha256sum \
+ > /tmp/ca-control-plane-recovery/evidence/mode-b-final-reconstruction-sha256.txt
+sha256sum \
+ /tmp/ca-control-plane-recovery/evidence/mode-b-final-reconstruction-sha256.txt \
+ > /tmp/ca-control-plane-recovery/evidence/mode-b-final-reconstruction-sha256.txt.sha256
+for ci_inventory_prefix in .harness/ .agents/ .claude/ .codex/ .github/
+do
+ grep -Fq " ${ci_inventory_prefix}" \
+ /tmp/ca-control-plane-recovery/evidence/mode-b-final-reconstruction-sha256.txt
+done
+for ci_inventory_file in \
+ .tool-versions \
+ .trivyignore.yaml \
+ .gitattributes \
+ .dockerignore \
+ docs/security/public-paths-snapshot.txt
+do
+ grep -Fq " ${ci_inventory_file}" \
+ /tmp/ca-control-plane-recovery/evidence/mode-b-final-reconstruction-sha256.txt
+done
+sha256sum --check \
+ /tmp/ca-control-plane-recovery/evidence/mode-b-final-reconstruction-sha256.txt.sha256
+```
+
+Expected in Mode B: all commands exit `0`; the complete post-change inventory includes every
+covered path and the final reconstructed provenance file itself. Mode A skips this step and retains
+its authoritative source/destination inventory plus ordinary review diffs for later changes.
+
+- [ ] **Step 9: Prepare the human commit handoff**
+
+Run:
+
+```bash
+git status --short
+git diff --stat
+git diff --check
+```
+
+Expected: only reviewed recovery and CI-control-plane files are present; there are no agent-created
+commits. Provide the human owner with the task-packet hash, recovery hash inventory, exact commands
+and exits, Gitea run links, required-status evidence, failures, and remaining risks.
+
+The documentation-authoring turn that created this plan does not write to the LLM Wiki. During
+future implementation, the executing controller follows the root `AGENTS.md` capture policy after
+all implementation and verification work is complete.
diff --git a/docs/superpowers/plans/2026-07-25-module-gradle-hygiene.md b/docs/superpowers/plans/2026-07-25-module-gradle-hygiene.md
new file mode 100644
index 00000000..4e77c7ec
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-25-module-gradle-hygiene.md
@@ -0,0 +1,1494 @@
+# Module and Gradle Hygiene Refactoring 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.
+
+**Goal:** Reduce the 19-leaf Gradle graph to its characterized minimum while preserving Clean
+Architecture direction, pure-core classpaths, optional runtime composition, and reproducible locks.
+
+**Architecture:** `.harness/project/modules.yaml` remains the only topology authority and gains
+validated runtime membership. Project and external dependency removals proceed leaf-by-leaf after a
+green compile/test baseline; every production leaf is added to a test-only architecture-analysis
+classpath without being added to the application runtime. The approved application outbox
+failure-reporting design is completed first and is not reimplemented here.
+
+**Tech Stack:** Java 21, Spring Boot 4.0.0, Gradle 9.0 Groovy DSL, JUnit Jupiter, Spock where Groovy
+specifications actually exist, ArchUnit, strict Gradle dependency locking
+
+**Spec:** `docs/superpowers/specs/2026-07-25-module-gradle-hygiene-design.md`
+
+**Working policy:** Human-only commits. Agents do not stage, commit, amend, or push. This plan has no
+agent commit step.
+
+---
+
+### Task 1: Prove the CI and logging prerequisites are green
+
+**Files:**
+
+- Verify only: `.harness/project/modules.yaml`
+- Verify only: `.tool-versions`
+- Verify only: `.trivyignore.yaml`
+- Verify only: `.github/workflows/ci-quality-gates.yml`
+- Verify only: `.github/workflows/link-check.yml`
+- Verify only: `docs/superpowers/specs/2026-07-25-application-outbox-failure-reporting-design.md`
+- Verify only: `src/application-core/build.gradle`
+- Verify only:
+ `src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java`
+- Verify only: `src/adapter/outbound/messaging/build.gradle`
+
+- [ ] **Step 1: Confirm the control-plane files exist**
+
+Run:
+
+```bash
+test -f .harness/project/modules.yaml
+test -f .tool-versions
+test -f .trivyignore.yaml
+test -f .github/workflows/ci-quality-gates.yml
+test -f .github/workflows/link-check.yml
+```
+
+Expected: all commands exit `0`. If any exits non-zero, stop this plan and complete
+`docs/superpowers/plans/2026-07-25-ci-control-plane-recovery.md`.
+
+- [ ] **Step 2: Run the recovered configuration and CI contract gates**
+
+Run:
+
+```bash
+cd src
+./gradlew projects --console=plain
+./gradlew :app-bootstrap:test \
+ --tests 'dev.caskeleton.bootstrap.contract.DeveloperExperienceContractTest' \
+ --console=plain
+./gradlew :app-bootstrap:test \
+ --tests 'dev.caskeleton.bootstrap.contract.SampleRemovalSmokeContractTest' \
+ --console=plain
+./gradlew verifyTrivyignore --console=plain
+```
+
+Expected: `BUILD SUCCESSFUL` for all four invocations and exactly 19 leaf projects in the projects
+report.
+
+- [ ] **Step 3: Confirm the approved outbox reporting refactor is complete**
+
+Run:
+
+```bash
+./gradlew :application-core:test \
+ --tests 'dev.caskeleton.application.outbox.*' --console=plain
+./gradlew :adapter:outbound:messaging:test --console=plain
+./gradlew :app-bootstrap:test \
+ --tests 'dev.caskeleton.bootstrap.outbox.*' --console=plain
+./gradlew verifyApplicationCoreDependencyPurity --console=plain
+./gradlew :application-core:dependencies \
+ --configuration runtimeClasspath --console=plain
+```
+
+Expected:
+
+- all focused tests pass;
+- `PublishPendingOutboxEventsUseCase` uses `OutboxRelayFailureReportPort`;
+- `adapter:outbound:messaging` owns the reporter implementation;
+- the application runtime dependency report contains no `org.springframework`, `org.slf4j`,
+ Logback, Log4j, Micrometer, or SnakeYAML coordinate.
+
+If any assertion fails, stop this plan and complete
+`docs/superpowers/plans/2026-07-25-application-outbox-failure-reporting.md`.
+
+- [ ] **Step 4: Record the untouched baseline**
+
+Run:
+
+```bash
+./gradlew verifyCleanArchitectureDependencies --console=plain
+./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
+./gradlew verifyDependencyLocks --console=plain
+git status --short
+```
+
+Expected: three successful Gradle gates. `git status` shows only work already authorized for the
+current implementation branch.
+
+### Task 2: Resolve the stable packet and write the recovered-control-plane addendum
+
+The `.harness` registry/API was absent when this plan was written. Its implementation is therefore
+not guessed here. CI recovery must expose the real parser, validator, packet, and test APIs before
+the control-plane changes receive executable code.
+
+**Files:**
+
+- Verify only: `.harness/manifest.yaml`
+- Verify only: `.harness/project/modules.yaml`
+- Verify only: `.harness/lib/module_registry.py`
+- Verify only: `.harness/validators/validate_modules.py`
+- Verify only: `.harness/tests/test_module_registry.py`
+- Verify only: `.harness/core/risk-policy.yaml`
+- Verify only: `.harness/core/evidence-policy.yaml`
+- Verify only:
+ `/tmp/ca-control-plane-recovery/evidence/stable-task-packet.env`
+- Add:
+ `docs/superpowers/plans/2026-07-25-module-gradle-hygiene-recovered-control-plane.md`
+
+- [ ] **Step 1: Resolve and freeze the recovered task packet**
+
+The completed CI recovery must have run the resolver against a concrete overlay and written
+`/tmp/ca-control-plane-recovery/evidence/stable-task-packet.env` with these literal keys:
+`overlay_path`, `packet_path`, `overlay_sha256`, `packet_sha256`, `rule_hash`, and
+`resolve_command`. A `--help` invocation or prose-only controller confirmation does not satisfy this
+gate.
+
+Run:
+
+```bash
+test -s .harness/manifest.yaml
+test -s .harness/project/modules.yaml
+test -s .harness/lib/module_registry.py
+test -s .harness/validators/validate_modules.py
+test -s .harness/tests/test_module_registry.py
+packet_evidence=/tmp/ca-control-plane-recovery/evidence/stable-task-packet.env
+test -s "${packet_evidence}"
+for packet_key in \
+ overlay_path packet_path overlay_sha256 packet_sha256 rule_hash resolve_command
+do
+ test "$(grep -c "^${packet_key}=" "${packet_evidence}")" -eq 1
+done
+overlay_path="$(sed -n 's/^overlay_path=//p' "${packet_evidence}")"
+packet_path="$(sed -n 's/^packet_path=//p' "${packet_evidence}")"
+overlay_sha256="$(sed -n 's/^overlay_sha256=//p' "${packet_evidence}")"
+packet_sha256="$(sed -n 's/^packet_sha256=//p' "${packet_evidence}")"
+rule_hash="$(sed -n 's/^rule_hash=//p' "${packet_evidence}")"
+resolve_command="$(sed -n 's/^resolve_command=//p' "${packet_evidence}")"
+test -f "${overlay_path}"
+test -s "${packet_path}"
+printf '%s %s\n' "${overlay_sha256}" "${overlay_path}" | sha256sum -c -
+printf '%s %s\n' "${packet_sha256}" "${packet_path}" | sha256sum -c -
+hash_count="$(
+ printf '%s\n' "${overlay_sha256}" "${packet_sha256}" "${rule_hash}" \
+ | grep -Ec '^[0-9a-f]{64}$'
+)"
+test "${hash_count}" -eq 3
+printf '%s\n' "${resolve_command}" | grep -F '.harness/validators/resolve_task.py'
+if printf '%s\n' "${resolve_command}" | grep -q -- '--help'; then
+ exit 1
+fi
+printf '%s\n' "${resolve_command}" | grep -F -- "${overlay_path}"
+printf '%s\n' "${resolve_command}" | grep -F -- "${packet_path}"
+grep -F "${rule_hash}" "${packet_path}" >/dev/null
+```
+
+Expected: every check exits `0`; concrete overlay and resolved packet files exist; both content
+hashes match; the 64-character rule hash occurs in the packet; and the recorded resolver command is
+an actual invocation rather than `--help`. If the evidence file is absent, return to CI recovery and
+perform the controller resolution before continuing.
+
+- [ ] **Step 2: Read and test the recovered API before planning against it**
+
+Run:
+
+```bash
+cat .harness/manifest.yaml
+cat .harness/project/modules.yaml
+cat .harness/lib/module_registry.py
+cat .harness/validators/validate_modules.py
+cat .harness/tests/test_module_registry.py
+python3 -m unittest discover -s .harness/tests -p 'test_module_registry.py' -v
+```
+
+Expected: all selected files are read to EOF and the recovered registry tests pass before the
+addendum is authored.
+
+- [ ] **Step 3: Write the exact control-plane addendum**
+
+Use `superpowers:writing-plans`. The addendum must have the standard Goal/Architecture/Tech Stack
+header, human-only commit policy, checkbox steps, literal file paths, complete compilable code, exact
+commands, and expected red/green output. It must contain all of the following:
+
+1. the concrete overlay path, resolved packet path, overlay hash, packet hash, and rule hash from
+ `stable-task-packet.env`;
+2. the recovered registry's exact serialization for all 19 runtime memberships from this design;
+3. complete Python tests and implementation for unique ids/paths, membership enum, unknown ids,
+ cycles, sample isolation, and rejection of any actual project edge absent from the source
+ module's `allowed_dependencies`;
+4. deterministic concrete error fixtures using `adapter-inbound-graphql`,
+ `adapter-inbound-web`, `adapter-outbound-cache-redis`, `app-bootstrap`, `application-core`, and
+ `domain-core`;
+5. complete Groovy that extends and renames the existing
+ `verifyApplicationCoreDependencyPurity` task to `verifyExternalDependencyPurity`, preserves its
+ main/test application rules and `:application-core:check` dependency, broadens production
+ compile/runtime rules to registered leaves, and removes the old task registration so only one
+ purity gate remains;
+6. complete Groovy and tests for configuration-processor parity against registered main source,
+ plus a class-by-class `@ConfigurationProperties` behavior-test inventory and complete binding/
+ validation test code for every uncovered settings class;
+7. complete Groovy for non-consumable test-only `architectureAnalysis`, populated from the
+ recovered registry without changing `implementation`, `runtimeOnly`, `bootJar`, or publication;
+8. complete Java tests for registered-leaf coverage, registry-driven sample removal, runtime
+ composition, and the two-root package scan contract;
+9. exact before/after commands for registry validation, both verification tasks, architecture
+ coverage, sample-off execution, and runtime composition.
+
+The addendum must explicitly preserve this membership assignment:
+
+```text
+core: domain-core, application-core, shared-contract
+app-default: adapter-inbound-web, adapter-outbound-persistence-jpa,
+ adapter-outbound-support, adapter-outbound-messaging, adapter-outbound-cache-redis,
+ adapter-outbound-notification, adapter-outbound-httpclient, adapter-outbound-identifier
+opt-in: adapter-inbound-graphql, adapter-inbound-grpc, adapter-inbound-websocket,
+ adapter-outbound-fileserver, adapter-outbound-objectstorage, adapter-outbound-persistence-mongo
+composition-root: app-bootstrap, sample-portfolio
+```
+
+- [ ] **Step 4: Review the addendum before any control-plane edit**
+
+Run:
+
+```bash
+python3 - <<'PY'
+from pathlib import Path
+import re
+
+path = Path(
+ "docs/superpowers/plans/"
+ "2026-07-25-module-gradle-hygiene-recovered-control-plane.md"
+)
+text = path.read_text(encoding="utf-8")
+banned = [
+ "T" + "BD",
+ "TO" + "DO",
+ "implement " + "later",
+ "fill in " + "details",
+ "as " + "needed",
+ "similar to " + "Task",
+]
+violations = [term for term in banned if term in text]
+violations.extend(
+ match.group(0)
+ for match in re.finditer(chr(60) + "[^" + chr(62) + "]+" + chr(62), text)
+)
+if violations:
+ raise SystemExit("placeholder content: " + ", ".join(violations))
+PY
+git diff --check -- \
+ docs/superpowers/plans/2026-07-25-module-gradle-hygiene-recovered-control-plane.md
+```
+
+Expected: both commands print nothing. Review confirms every code-changing step has complete code
+against the recovered API. Mark the addendum approved before proceeding.
+
+### Task 3: Execute the approved recovered-control-plane addendum
+
+**Files:**
+
+- Modify only the files enumerated by the approved
+ `docs/superpowers/plans/2026-07-25-module-gradle-hygiene-recovered-control-plane.md`
+
+- [ ] **Step 1: Reassert the stable packet and addendum hashes**
+
+Run:
+
+```bash
+packet_evidence=/tmp/ca-control-plane-recovery/evidence/stable-task-packet.env
+test -s "${packet_evidence}"
+overlay_path="$(sed -n 's/^overlay_path=//p' "${packet_evidence}")"
+packet_path="$(sed -n 's/^packet_path=//p' "${packet_evidence}")"
+overlay_sha256="$(sed -n 's/^overlay_sha256=//p' "${packet_evidence}")"
+packet_sha256="$(sed -n 's/^packet_sha256=//p' "${packet_evidence}")"
+rule_hash="$(sed -n 's/^rule_hash=//p' "${packet_evidence}")"
+printf '%s %s\n' "${overlay_sha256}" "${overlay_path}" | sha256sum -c -
+printf '%s %s\n' "${packet_sha256}" "${packet_path}" | sha256sum -c -
+grep -F "${rule_hash}" "${packet_path}" >/dev/null
+addendum=docs/superpowers/plans/2026-07-25-module-gradle-hygiene-recovered-control-plane.md
+test -s "${addendum}"
+grep -F "${overlay_sha256}" "${addendum}" >/dev/null
+grep -F "${packet_sha256}" "${addendum}" >/dev/null
+grep -F "${rule_hash}" "${addendum}" >/dev/null
+```
+
+Expected: both files still match their recorded hashes, the packet still carries the rule hash, and
+the approved addendum names all three hashes. Any mismatch invalidates the addendum and returns to
+Task 2.
+
+- [ ] **Step 2: Execute the addendum as its own plan**
+
+Stop this plan, use `superpowers:executing-plans` or
+`superpowers:subagent-driven-development`, and complete the recovered-control-plane addendum in
+full. Return here only after its registry, purity, processor, architecture-analysis, sample
+isolation, runtime composition, and package-scan checks are green.
+
+- [ ] **Step 3: Prove the addendum superseded the narrow purity task**
+
+Run:
+
+```bash
+cd src
+./gradlew tasks --all --console=plain | rg \
+ '^verify(ExternalDependencyPurity|ConfigurationPropertiesProcessor)\\b'
+if ./gradlew tasks --all --console=plain | \
+ rg -q '^verifyApplicationCoreDependencyPurity\\b'; then
+ exit 1
+fi
+./gradlew verifyExternalDependencyPurity \
+ verifyConfigurationPropertiesProcessor \
+ verifyCleanArchitectureDependencies \
+ --console=plain
+```
+
+Expected: the two replacement tasks are listed, the old application-only task is absent, and all
+three verification tasks pass.
+
+### Task 4: Replace global Spring tests with role-specific test conventions
+
+**Files:**
+
+- Modify: `src/build.gradle`
+- Modify: `src/domain-core/build.gradle`
+- Modify: `src/application-core/build.gradle`
+- Modify: `src/shared-contract/build.gradle`
+- Modify: `src/adapter/inbound/web/build.gradle`
+- Modify: `src/adapter/inbound/graphql/build.gradle`
+- Modify: `src/adapter/inbound/grpc/build.gradle`
+- Modify: `src/adapter/inbound/websocket/build.gradle`
+- Modify: `src/adapter/outbound/support/build.gradle`
+- Modify: `src/adapter/outbound/cache-redis/build.gradle`
+- Modify: `src/adapter/outbound/fileserver/build.gradle`
+- Modify: `src/adapter/outbound/httpclient/build.gradle`
+- Modify: `src/adapter/outbound/identifier/build.gradle`
+- Modify: `src/adapter/outbound/messaging/build.gradle`
+- Modify: `src/adapter/outbound/notification/build.gradle`
+- Modify: `src/adapter/outbound/objectstorage/build.gradle`
+- Modify: `src/adapter/outbound/persistence-jpa/build.gradle`
+- Modify: `src/adapter/outbound/persistence-mongo/build.gradle`
+- Modify: `src/app-bootstrap/build.gradle`
+- Modify: `src/sample-portfolio/build.gradle`
+
+- [ ] **Step 1: Characterize test-framework imports per leaf**
+
+Run:
+
+```bash
+for module in \
+ domain-core application-core shared-contract \
+ adapter/inbound/web adapter/inbound/graphql adapter/inbound/grpc adapter/inbound/websocket \
+ adapter/outbound/support adapter/outbound/cache-redis adapter/outbound/fileserver \
+ adapter/outbound/httpclient adapter/outbound/identifier adapter/outbound/messaging \
+ adapter/outbound/notification adapter/outbound/objectstorage \
+ adapter/outbound/persistence-jpa adapter/outbound/persistence-mongo \
+ app-bootstrap sample-portfolio
+do
+ printf '%s\n' "===== ${module} ====="
+ rg '^import (org\.springframework|org\.junit|org\.assertj|org\.mockito|spock\.|org\.testcontainers)' \
+ "${module}/src/test" -g '*.java' -g '*.groovy' 2>/dev/null \
+ | sed -E 's/^.*:import / /' | cut -d. -f1-3 | sort -u
+done
+```
+
+Expected: a deterministic per-leaf list. Save no generated report in source control.
+
+- [ ] **Step 2: Remove the global Spring test starters**
+
+In `src/build.gradle`, replace the global:
+
+```groovy
+testImplementation 'org.springframework.boot:spring-boot-starter-test'
+testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
+```
+
+with the framework-neutral baseline:
+
+```groovy
+testImplementation 'org.junit.jupiter:junit-jupiter'
+testImplementation 'org.assertj:assertj-core'
+testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+```
+
+Run:
+
+```bash
+./gradlew :domain-core:dependencies --configuration testRuntimeClasspath --console=plain
+./gradlew :application-core:dependencies --configuration testRuntimeClasspath --console=plain
+./gradlew :shared-contract:dependencies --configuration testRuntimeClasspath --console=plain
+```
+
+Expected: no Spring, Tomcat, servlet, Jackson, or Logback coordinate in these three reports.
+
+- [ ] **Step 3: Add Spring test support only to the characterized leaves**
+
+Add this exact declaration:
+
+```groovy
+testImplementation 'org.springframework.boot:spring-boot-starter-test'
+```
+
+to these leaves:
+
+```text
+adapter:inbound:web
+adapter:inbound:graphql
+adapter:inbound:grpc
+adapter:inbound:websocket
+adapter:outbound:httpclient
+adapter:outbound:persistence-jpa
+adapter:outbound:persistence-mongo
+app-bootstrap
+sample-portfolio
+```
+
+Add this exact declaration only to `adapter:inbound:web`, `app-bootstrap`, and
+`sample-portfolio`:
+
+```groovy
+testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
+```
+
+Do not add either starter to `domain-core`, `application-core`, `shared-contract`, or
+`adapter:outbound:identifier`.
+
+- [ ] **Step 4: Compile every test source set**
+
+Run:
+
+```bash
+./gradlew compileTestJava compileTestGroovy --console=plain
+```
+
+Expected: `BUILD SUCCESSFUL`; Gradle may report `NO-SOURCE` for leaves without Groovy tests.
+
+- [ ] **Step 5: Run pure-core tests**
+
+Run:
+
+```bash
+./gradlew :domain-core:test :application-core:test :shared-contract:test --console=plain
+```
+
+Expected: all discovered tests pass and no Spring application context starts.
+
+### Task 5: Align configuration processors
+
+**Files:**
+
+- Verify:
+ `docs/superpowers/plans/2026-07-25-module-gradle-hygiene-recovered-control-plane.md`
+- Modify: `src/adapter/inbound/web/build.gradle`
+- Modify: `src/adapter/inbound/graphql/build.gradle`
+- Modify: `src/adapter/outbound/cache-redis/build.gradle`
+- Modify: `src/adapter/outbound/httpclient/build.gradle`
+- Modify: `src/adapter/outbound/messaging/build.gradle`
+- Modify: `src/adapter/outbound/notification/build.gradle`
+- Modify: `src/adapter/outbound/persistence-jpa/build.gradle`
+- Modify: `src/app-bootstrap/build.gradle`
+- Modify: `src/sample-portfolio/build.gradle`
+
+- [ ] **Step 1: Close settings behavior-test gaps from the approved addendum**
+
+Execute the addendum's complete class-by-class settings-test section before processor edits. The
+section must map every main-source `@ConfigurationProperties` class to a binding/validation test and
+contain complete code for every gap; a leaf-wide smoke test is not a substitute.
+
+Run the literal focused commands recorded in that section.
+
+Expected: every settings behavior test passes and the addendum inventory has no uncovered class.
+
+- [ ] **Step 2: Apply the parity gate's exact additions and removal**
+
+Remove the processor from GraphQL because it has no main-source properties class. Add it to each
+listed settings-owning leaf:
+
+```groovy
+annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
+```
+
+Do not change modules already aligned by the gate:
+
+```text
+adapter:inbound:grpc
+adapter:inbound:websocket
+adapter:outbound:fileserver
+adapter:outbound:objectstorage
+adapter:outbound:persistence-mongo
+```
+
+- [ ] **Step 3: Run processor parity and focused settings tests**
+
+Run:
+
+```bash
+./gradlew verifyConfigurationPropertiesProcessor --console=plain
+./gradlew :adapter:inbound:web:test --tests '*SettingsTest' --console=plain
+./gradlew :adapter:outbound:cache-redis:test --tests '*SettingsTest' --console=plain
+./gradlew :adapter:outbound:httpclient:test --tests '*SettingsTest' --console=plain
+./gradlew :adapter:outbound:messaging:test --tests '*SettingsTest' --console=plain
+./gradlew :app-bootstrap:test --tests '*SettingsTest' --console=plain
+```
+
+Expected: processor parity passes and every focused settings test passes. A no-match result fails
+the step and must be corrected against the approved class-to-test inventory; running a leaf-wide
+task does not satisfy missing binding coverage.
+
+### Task 6: Characterize and prune project edges in every leaf
+
+**Files:**
+
+- Modify: `.harness/project/modules.yaml`
+- Modify the following confirmed-candidate builds and their nearest `CLAUDE.md` files:
+ - `src/adapter/inbound/graphql/build.gradle`
+ - `src/adapter/inbound/grpc/build.gradle`
+ - `src/adapter/inbound/web/build.gradle`
+ - `src/adapter/inbound/websocket/build.gradle`
+ - `src/adapter/outbound/cache-redis/build.gradle`
+ - `src/adapter/outbound/httpclient/build.gradle`
+ - `src/adapter/outbound/identifier/build.gradle`
+ - `src/adapter/outbound/messaging/build.gradle`
+ - `src/adapter/outbound/notification/build.gradle`
+ - `src/adapter/outbound/persistence-jpa/build.gradle`
+ - `src/adapter/outbound/support/build.gradle`
+- Verify unchanged project edges:
+ - `src/domain-core/build.gradle`
+ - `src/application-core/build.gradle`
+ - `src/shared-contract/build.gradle`
+ - `src/adapter/outbound/fileserver/build.gradle`
+ - `src/adapter/outbound/objectstorage/build.gradle`
+ - `src/app-bootstrap/build.gradle`
+ - `src/sample-portfolio/build.gradle`
+- Handle Mongo separately in Task 8:
+ - `src/adapter/outbound/persistence-mongo/build.gradle`
+
+- [ ] **Step 1: Capture before-removal dependency reports**
+
+Run:
+
+```bash
+./gradlew \
+ :adapter:inbound:graphql:dependencies \
+ :adapter:inbound:grpc:dependencies \
+ :adapter:inbound:web:dependencies \
+ :adapter:inbound:websocket:dependencies \
+ :adapter:outbound:cache-redis:dependencies \
+ :adapter:outbound:httpclient:dependencies \
+ :adapter:outbound:identifier:dependencies \
+ :adapter:outbound:messaging:dependencies \
+ :adapter:outbound:notification:dependencies \
+ :adapter:outbound:persistence-jpa:dependencies \
+ :adapter:outbound:support:dependencies \
+ --configuration compileClasspath --console=plain
+```
+
+Expected: success and visibility of each declared candidate edge.
+
+- [ ] **Step 2: Capture the pre-removal compile and focused-test baseline**
+
+Run before editing any candidate declaration:
+
+```bash
+./gradlew \
+ :adapter:inbound:graphql:compileJava :adapter:inbound:graphql:test \
+ :adapter:inbound:grpc:compileJava :adapter:inbound:grpc:test \
+ :adapter:inbound:web:compileJava :adapter:inbound:web:test \
+ :adapter:inbound:websocket:compileJava :adapter:inbound:websocket:test \
+ :adapter:outbound:cache-redis:compileJava :adapter:outbound:cache-redis:test \
+ :adapter:outbound:httpclient:compileJava :adapter:outbound:httpclient:test \
+ :adapter:outbound:identifier:compileJava :adapter:outbound:identifier:test \
+ :adapter:outbound:messaging:compileJava :adapter:outbound:messaging:test \
+ :adapter:outbound:notification:compileJava :adapter:outbound:notification:test \
+ :adapter:outbound:persistence-jpa:compileJava :adapter:outbound:persistence-jpa:test \
+ :adapter:outbound:support:compileJava :adapter:outbound:support:test \
+ --console=plain
+```
+
+Expected: all eleven compilers and focused test tasks pass. Stop on any failure; a red leaf is not a
+dependency-removal candidate.
+
+- [ ] **Step 3: Remove only source-proven project edges**
+
+Remove these declarations and the corresponding registry `allowed_dependencies` entries:
+
+```text
+graphql: application-core, domain-core
+grpc: application-core, domain-core
+web: domain-core
+websocket: application-core, shared-contract
+cache-redis: domain-core, application-core
+httpclient: domain-core, application-core
+identifier: domain-core
+messaging: domain-core
+notification: domain-core
+persistence-jpa: domain-core
+support: domain-core, application-core, shared-contract
+```
+
+The approved reporter remains in `messaging`, so its `application-core`, `shared-contract`, and
+`support` edges remain. Do not alter the four adapter-to-support edges.
+
+- [ ] **Step 4: Compile and test each changed leaf**
+
+Run:
+
+```bash
+./gradlew \
+ :adapter:inbound:graphql:test \
+ :adapter:inbound:grpc:test \
+ :adapter:inbound:web:test \
+ :adapter:inbound:websocket:test \
+ :adapter:outbound:cache-redis:test \
+ :adapter:outbound:httpclient:test \
+ :adapter:outbound:identifier:test \
+ :adapter:outbound:messaging:test \
+ :adapter:outbound:notification:test \
+ :adapter:outbound:persistence-jpa:test \
+ :adapter:outbound:support:test \
+ --console=plain
+```
+
+Expected: all eleven focused tasks pass.
+
+- [ ] **Step 5: Verify all 19 leaf project graphs**
+
+Run:
+
+```bash
+./gradlew \
+ :domain-core:compileJava \
+ :application-core:compileJava \
+ :shared-contract:compileJava \
+ :adapter:inbound:web:compileJava \
+ :adapter:inbound:graphql:compileJava \
+ :adapter:inbound:grpc:compileJava \
+ :adapter:inbound:websocket:compileJava \
+ :adapter:outbound:support:compileJava \
+ :adapter:outbound:cache-redis:compileJava \
+ :adapter:outbound:fileserver:compileJava \
+ :adapter:outbound:httpclient:compileJava \
+ :adapter:outbound:identifier:compileJava \
+ :adapter:outbound:messaging:compileJava \
+ :adapter:outbound:notification:compileJava \
+ :adapter:outbound:objectstorage:compileJava \
+ :adapter:outbound:persistence-jpa:compileJava \
+ :adapter:outbound:persistence-mongo:compileJava \
+ :app-bootstrap:compileJava \
+ :sample-portfolio:compileJava \
+ verifyCleanArchitectureDependencies \
+ --console=plain
+```
+
+Expected: all 19 compilers and the registry-backed edge verifier pass.
+
+### Task 7: Remove unused plugins/libraries and narrow broad starters
+
+**Files:**
+
+- Modify: `src/adapter/outbound/cache-redis/build.gradle`
+- Modify: `src/adapter/outbound/messaging/build.gradle`
+- Modify: `src/adapter/outbound/notification/build.gradle`
+- Modify: `src/adapter/outbound/identifier/build.gradle`
+- Modify: `src/adapter/inbound/graphql/build.gradle`
+- Modify: `src/adapter/inbound/grpc/build.gradle`
+- Modify: `src/adapter/outbound/fileserver/build.gradle`
+- Modify: `src/adapter/outbound/objectstorage/build.gradle`
+- Modify: `src/adapter/outbound/persistence-jpa/build.gradle`
+
+- [ ] **Step 1: Remove deterministic unused test/tool dependencies**
+
+Remove Groovy plugin and Spock from:
+
+```text
+adapter:outbound:cache-redis
+adapter:outbound:messaging
+adapter:outbound:notification
+```
+
+Remove `com.github.f4b6a3:uuid-creator` from `adapter:outbound:identifier`.
+
+Run:
+
+```bash
+./gradlew \
+ :adapter:outbound:cache-redis:test \
+ :adapter:outbound:messaging:test \
+ :adapter:outbound:notification:test \
+ :adapter:outbound:identifier:test \
+ --console=plain
+```
+
+Expected: all four tasks pass and the first three no longer expose `compileGroovy` or
+`compileTestGroovy` work beyond `NO-SOURCE` tasks contributed elsewhere.
+
+- [ ] **Step 2: Characterize GraphQL and gRPC candidates**
+
+Run:
+
+```bash
+rg -n 'com\.fasterxml\.jackson|java\.time' \
+ adapter/inbound/graphql/src/main -g '*.java'
+rg -n 'io\.grpc\.stub|io\.grpc\.protobuf(?!\.services)|javax\.annotation|jakarta\.annotation' \
+ adapter/inbound/grpc/src/main -g '*.java' --pcre2
+./gradlew :adapter:inbound:graphql:test :adapter:inbound:grpc:test --console=plain
+```
+
+Expected: no GraphQL direct JSR-310 use; no gRPC generated-stub or generated-annotation use; focused
+tests pass before removal.
+
+- [ ] **Step 3: Remove no-source-use protocol candidates and retest**
+
+Remove GraphQL JSR-310 if the schema/controller characterization remains string-only. Remove gRPC
+stub and generated-annotation declarations. Remove direct `grpc-protobuf` only when
+`grpc-services` supplies every required health/reflection type on compile/runtime classpaths.
+
+Run:
+
+```bash
+./gradlew \
+ :adapter:inbound:graphql:compileJava :adapter:inbound:graphql:test \
+ :adapter:inbound:grpc:compileJava :adapter:inbound:grpc:test \
+ --console=plain
+```
+
+Expected: all four tasks pass. If `grpc-protobuf` is required by a directly referenced API, restore
+that one declaration and document the direct type in the module build comment.
+
+- [ ] **Step 4: Narrow starter candidates one leaf at a time**
+
+For gRPC, fileserver, and objectstorage, replace `spring-boot-starter` with only the compile APIs
+shown by `jdeps`/imports:
+
+```groovy
+implementation 'org.springframework.boot:spring-boot-autoconfigure'
+implementation 'org.springframework:spring-context'
+implementation 'org.slf4j:slf4j-api'
+```
+
+Keep each protocol/storage runtime dependency already declared. Run immediately after each leaf:
+
+```bash
+./gradlew :adapter:inbound:grpc:test --console=plain
+./gradlew :adapter:outbound:fileserver:test --console=plain
+./gradlew :adapter:outbound:objectstorage:test --console=plain
+```
+
+Expected: each task passes and its runtime report contains no Logback implementation contributed by
+that leaf.
+
+- [ ] **Step 5: Test explicit Flyway-core duplication**
+
+Temporarily remove only the explicit `org.flywaydb:flyway-core` declaration from
+`persistence-jpa`; retain `spring-boot-starter-flyway`.
+
+Run:
+
+```bash
+./gradlew \
+ :adapter:outbound:persistence-jpa:compileJava \
+ :adapter:outbound:persistence-jpa:test \
+ --console=plain
+```
+
+Expected: success means the explicit core declaration stays removed. A compile failure naming a
+direct Flyway API means restore it and retain a comment naming that source file.
+
+### Task 8: Remove the production Mongo example and generated jqwik state
+
+**Files:**
+
+- Delete:
+ `src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleRecord.java`
+- Delete:
+ `src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoDocument.java`
+- Delete:
+ `src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoMapper.java`
+- Delete:
+ `src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepository.java`
+- Delete:
+ `src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepositoryAdapter.java`
+- Delete:
+ `src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoMapperTest.java`
+- Delete:
+ `src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/ExampleMongoRepositoryIT.java`
+- Modify:
+ `src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceConfig.java`
+- Modify:
+ `src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceProperties.java`
+- Add:
+ `src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistencePropertiesBindingTest.java`
+- Add:
+ `src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceDisabledModeTest.java`
+- Add:
+ `src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceEnabledModeTest.java`
+- Add:
+ `src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/MongoPersistenceOwnershipTest.java`
+- Modify: `src/adapter/outbound/persistence-mongo/build.gradle`
+- Modify: `src/adapter/outbound/persistence-mongo/CLAUDE.md`
+- Modify: `src/adapter/outbound/persistence-mongo/README.md`
+- Modify: `.harness/project/modules.yaml`
+- Delete: `src/sample-portfolio/.jqwik-database`
+- Modify: `src/.gitignore`
+
+- [ ] **Step 1: Characterize binding/disabled/enabled behavior and add a red ownership test**
+
+The binding test loads:
+
+```properties
+ca-skeleton.persistence-mongo.enabled=true
+ca-skeleton.persistence-mongo.database=contract_db
+```
+
+and asserts `enabled == true` and `database == "contract_db"`.
+
+The disabled-mode test loads `MongoPersistenceConfig` without the enable property and asserts no
+Mongo client, repository, or adapter bean is created.
+
+Create `MongoPersistencePropertiesBindingTest.java` exactly as:
+
+```java
+package dev.caskeleton.adapter.outbound.mongo;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Configuration;
+
+class MongoPersistencePropertiesBindingTest {
+
+ private final ApplicationContextRunner runner =
+ new ApplicationContextRunner().withUserConfiguration(BindingConfig.class);
+
+ @Test
+ void bindsOptInAndDatabaseProperties() {
+ runner
+ .withPropertyValues(
+ "ca-skeleton.persistence-mongo.enabled=true",
+ "ca-skeleton.persistence-mongo.database=contract_db")
+ .run(
+ context -> {
+ assertThat(context).hasNotFailed();
+ MongoPersistenceProperties properties =
+ context.getBean(MongoPersistenceProperties.class);
+ assertThat(properties.isEnabled()).isTrue();
+ assertThat(properties.getDatabase()).isEqualTo("contract_db");
+ });
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @EnableConfigurationProperties(MongoPersistenceProperties.class)
+ static class BindingConfig {}
+}
+```
+
+Create `MongoPersistenceDisabledModeTest.java` exactly as:
+
+```java
+package dev.caskeleton.adapter.outbound.mongo;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.mongodb.client.MongoClient;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.repository.Repository;
+
+class MongoPersistenceDisabledModeTest {
+
+ private final ApplicationContextRunner runner =
+ new ApplicationContextRunner().withUserConfiguration(MongoPersistenceConfig.class);
+
+ @Test
+ void createsNoMongoInfrastructureWhenDisabledByDefault() {
+ runner.run(
+ context -> {
+ assertThat(context)
+ .hasNotFailed()
+ .doesNotHaveBean(MongoPersistenceConfig.class)
+ .doesNotHaveBean(MongoClient.class)
+ .doesNotHaveBean(MongoTemplate.class);
+ assertThat(context.getBeanNamesForType(Repository.class)).isEmpty();
+ });
+ }
+}
+```
+
+Create `MongoPersistenceOwnershipTest.java` exactly as:
+
+```java
+package dev.caskeleton.adapter.outbound.mongo;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.mongodb.repository.config.EnableMongoRepositories;
+
+class MongoPersistenceOwnershipTest {
+
+ @Test
+ void configurationOwnsNoRepositoryScanOrExampleAdapterFactory() {
+ assertThat(
+ MongoPersistenceConfig.class.isAnnotationPresent(EnableMongoRepositories.class))
+ .isFalse();
+ assertThat(
+ Arrays.stream(MongoPersistenceConfig.class.getDeclaredMethods())
+ .map(Method::getName))
+ .doesNotContain("exampleMongoRepositoryAdapter");
+ }
+}
+```
+
+Create `MongoPersistenceEnabledModeTest.java` exactly as:
+
+```java
+package dev.caskeleton.adapter.outbound.mongo;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+import com.mongodb.client.MongoClient;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.mongodb.core.MongoTemplate;
+
+class MongoPersistenceEnabledModeTest {
+
+ private final ApplicationContextRunner runner =
+ new ApplicationContextRunner()
+ .withUserConfiguration(MockMongoClientConfig.class, MongoPersistenceConfig.class)
+ .withPropertyValues(
+ "ca-skeleton.persistence-mongo.enabled=true",
+ "ca-skeleton.persistence-mongo.database=contract_db",
+ "spring.mongodb.database=contract_db");
+
+ @Test
+ void createsGenericMongoClientAndTemplateWhenEnabled() {
+ runner.run(
+ context -> {
+ assertThat(context)
+ .hasNotFailed()
+ .hasSingleBean(MongoClient.class)
+ .hasSingleBean(MongoTemplate.class);
+ assertThat(context.getBean(MongoPersistenceProperties.class).getDatabase())
+ .isEqualTo("contract_db");
+ });
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ static class MockMongoClientConfig {
+
+ @Bean
+ MongoClient mongoClient() {
+ return mock(MongoClient.class);
+ }
+ }
+}
+```
+
+The mock satisfies Boot 4's `MongoClient` back-off and lets `DataMongoAutoConfiguration` create a
+real `MongoTemplate` without opening a socket.
+
+Run:
+
+```bash
+./gradlew :adapter:outbound:persistence-mongo:test \
+ --tests '*MongoPersistencePropertiesBindingTest' \
+ --tests '*MongoPersistenceDisabledModeTest' \
+ --tests '*MongoPersistenceEnabledModeTest' \
+ --console=plain
+./gradlew :adapter:outbound:persistence-mongo:test \
+ --tests '*MongoPersistenceOwnershipTest' \
+ --console=plain
+```
+
+Expected: the binding, disabled-mode, and mock-backed enabled-mode characterization tests pass
+against the current opt-in behavior. The ownership test fails because current production config
+declares `@EnableMongoRepositories` and `exampleMongoRepositoryAdapter`.
+
+- [ ] **Step 2: Make Mongo configuration generic**
+
+Replace `MongoPersistenceConfig.java` exactly with:
+
+```java
+package dev.caskeleton.adapter.outbound.mongo;
+
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.data.mongodb.autoconfigure.DataMongoAutoConfiguration;
+import org.springframework.boot.mongodb.autoconfigure.MongoAutoConfiguration;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Generic opt-in Mongo client and template configuration.
+ *
+ * Consumers own their document, repository, mapper, adapter, and repository-scan boundary.
+ */
+@Configuration(proxyBeanMethods = false)
+@ConditionalOnProperty(
+ prefix = "ca-skeleton.persistence-mongo",
+ name = "enabled",
+ havingValue = "true")
+@EnableConfigurationProperties(MongoPersistenceProperties.class)
+@ImportAutoConfiguration({MongoAutoConfiguration.class, DataMongoAutoConfiguration.class})
+public class MongoPersistenceConfig {}
+```
+
+Replace `MongoPersistenceProperties.java` exactly with:
+
+```java
+package dev.caskeleton.adapter.outbound.mongo;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/** Module-owned opt-in settings; connection settings remain under Spring Boot's Mongo properties. */
+@ConfigurationProperties(prefix = "ca-skeleton.persistence-mongo")
+public class MongoPersistenceProperties {
+
+ private boolean enabled;
+ private String database = "ca_skeleton";
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public String getDatabase() {
+ return database;
+ }
+
+ public void setDatabase(String database) {
+ this.database = database;
+ }
+}
+```
+
+Delete the five `Example*` production files and their two tests. Do not copy them into
+`sample-portfolio`.
+
+Run:
+
+```bash
+./gradlew :adapter:outbound:persistence-mongo:test \
+ --tests '*MongoPersistenceOwnershipTest' \
+ --tests '*MongoPersistenceEnabledModeTest' \
+ --console=plain
+```
+
+Expected: the previously red ownership test passes and enabled mode still creates one generic
+`MongoClient` plus one `MongoTemplate` without a network connection.
+
+- [ ] **Step 3: Remove now-unused Mongo core edges**
+
+Remove `application-core` and `shared-contract` from:
+
+- `src/adapter/outbound/persistence-mongo/build.gradle`
+- the Mongo entry's registry allowed dependencies
+
+Remove the now-unused Testcontainers dependencies. The final dependency block is:
+
+```groovy
+dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
+ annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
+}
+```
+
+Run:
+
+```bash
+./gradlew \
+ :adapter:outbound:persistence-mongo:compileJava \
+ :adapter:outbound:persistence-mongo:test \
+ verifyCleanArchitectureDependencies \
+ --console=plain
+```
+
+Expected: all tasks pass and production Mongo source contains only generic configuration/properties.
+
+- [ ] **Step 4: Remove and ignore jqwik runtime state**
+
+Delete `src/sample-portfolio/.jqwik-database` and add this exact rule to `src/.gitignore`:
+
+```gitignore
+.jqwik-database
+```
+
+Run:
+
+```bash
+git ls-files '*/.jqwik-database' '*.jqwik-database'
+git check-ignore -v src/sample-portfolio/.jqwik-database
+./gradlew :sample-portfolio:test --tests '*WorkLogIdPropertyTest' --console=plain
+```
+
+Expected: `git ls-files` prints nothing, `git check-ignore` names `src/.gitignore`, and the property
+test passes without a committed database.
+
+### Task 9: Verify the addendum's architecture and runtime outcomes after graph cleanup
+
+The exact implementation and test code for this task is owned by the approved recovered-control-plane
+addendum from Task 2. This task changes no control-plane file; it proves that the addendum remains
+green after dependency and Mongo cleanup.
+
+**Files:**
+
+- Verify only: `.harness/project/modules.yaml`
+- Verify only: `src/build.gradle`
+- Verify only: `src/app-bootstrap/build.gradle`
+- Verify only:
+ `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/RegisteredLeafCoverageTest.java`
+- Verify only:
+ `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/RuntimeCompositionContractTest.java`
+- Verify only:
+ `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/SampleRemovalSmokeContractTest.java`
+- Verify only:
+ `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/ApplicationPackageScanContractTest.java`
+- Verify only:
+ `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java`
+
+- [ ] **Step 1: Re-run registered coverage, composition, sample isolation, and scan tests**
+
+Run:
+
+```bash
+./gradlew :app-bootstrap:test \
+ --tests '*RegisteredLeafCoverageTest' \
+ --tests '*RuntimeCompositionContractTest' \
+ --tests '*SampleRemovalSmokeContractTest' \
+ --tests '*ApplicationPackageScanContractTest' \
+ --console=plain
+./gradlew :sample-portfolio:test \
+ --tests '*SampleApplicationContextTest' \
+ --console=plain
+```
+
+Expected: every test passes; all registered production leaves are analyzed; GraphQL, gRPC,
+WebSocket, fileserver, objectstorage, and Mongo remain absent from the default runtime; the sample
+context remains bootable with only bootstrap/adapter package scans.
+
+- [ ] **Step 2: Run the complete architecture suite**
+
+Run:
+
+```bash
+./gradlew \
+ verifyCleanArchitectureDependencies \
+ verifyExternalDependencyPurity \
+ :app-bootstrap:test --tests '*CleanArchitectureTest' \
+ --console=plain
+```
+
+Expected: all registered production leaves are analyzed and every gate passes.
+
+### Task 10: Centralize remaining version ownership and gate locks
+
+**Files:**
+
+- Modify: `src/build.gradle`
+- Modify each leaf `build.gradle` that retains a non-BOM version:
+ - `src/adapter/inbound/web/build.gradle`
+ - `src/adapter/outbound/httpclient/build.gradle`
+ - `src/adapter/outbound/identifier/build.gradle`
+ - `src/app-bootstrap/build.gradle`
+ - `src/sample-portfolio/build.gradle`
+- Modify: `.github/workflows/ci-quality-gates.yml`
+
+- [ ] **Step 1: Inventory remaining explicit versions**
+
+Run:
+
+```bash
+rg -n "['\"][A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+:[^'\"]+['\"]" \
+ . -g 'build.gradle' | sort
+```
+
+Expected: one row per explicit non-project coordinate. BOM-managed coordinates have no version.
+
+- [ ] **Step 2: Give every remaining non-BOM library one root owner**
+
+Add this root map without changing a version:
+
+```groovy
+ext.libraryVersions = [
+ jacksonDatabindNullable : '0.2.6',
+ springdoc : '2.8.6',
+ resilience4j : '2.2.0',
+ spock : '2.4-groovy-5.0',
+ uuidCreator : '6.1.1',
+ springDotenv : '4.0.0',
+ logstashEncoder : '8.0',
+ approvalTests : '31.0.0',
+ archunit : '1.3.0',
+ springCloudContextFixture: '4.1.4',
+ jqwik : '1.9.1'
+]
+```
+
+Replace the matching leaf literals with `${libraryVersions.key}` interpolation, for example:
+
+```groovy
+implementation "org.openapitools:jackson-databind-nullable:${libraryVersions.jacksonDatabindNullable}"
+implementation "org.springdoc:springdoc-openapi-starter-webmvc-api:${libraryVersions.springdoc}"
+implementation "io.github.resilience4j:resilience4j-retry:${libraryVersions.resilience4j}"
+testImplementation "org.spockframework:spock-core:${libraryVersions.spock}"
+implementation "com.github.f4b6a3:uuid-creator:${libraryVersions.uuidCreator}"
+implementation "me.paulschwarz:spring-dotenv:${libraryVersions.springDotenv}"
+implementation "net.logstash.logback:logstash-logback-encoder:${libraryVersions.logstashEncoder}"
+testImplementation "com.approvaltests:approvaltests:${libraryVersions.approvalTests}"
+testImplementation "com.tngtech.archunit:archunit-junit5:${libraryVersions.archunit}"
+testCompileOnly "org.springframework.cloud:spring-cloud-context:${libraryVersions.springCloudContextFixture}"
+testImplementation "net.jqwik:jqwik:${libraryVersions.jqwik}"
+```
+
+Use the same `resilience4j` key for retry, circuit-breaker, and Micrometer coordinates. Keep
+`grpcVersion`, `protobufVersion`, and `awsSdkVersion` as the existing BOM owners. The root-owned
+FindSecBugs and Error Prone tool versions remain where they are. Do not add `libs.versions.toml`,
+`buildSrc`, or a convention plugin.
+
+Run:
+
+```bash
+./gradlew projects --console=plain
+./gradlew compileJava compileTestJava compileTestGroovy --console=plain
+```
+
+Expected: both commands pass with no selected dependency version change.
+
+- [ ] **Step 3: Regenerate strict locks once after all graph changes**
+
+Run:
+
+```bash
+./gradlew resolveAndLockAll --write-locks --console=plain
+./gradlew verifyDependencyLocks --console=plain
+```
+
+Expected: success; removed Spring/application, unused Groovy/Spock/UUID, example Mongo, and pruned
+project coordinates no longer appear in affected production configurations.
+
+- [ ] **Step 4: Make lock verification release-blocking**
+
+Add `verifyDependencyLocks` to the CI quality-gate job and its release-gate dependency chain. If
+project `check` is the repository's single local release entrypoint after CI recovery, also make the
+root/leaf check aggregation depend on `verifyDependencyLocks` exactly once.
+
+Run:
+
+```bash
+lock_dry_run="$(./gradlew check --dry-run --console=plain)"
+printf '%s\n' "${lock_dry_run}"
+lock_task_count="$(
+ printf '%s\n' "${lock_dry_run}" \
+ | grep -Ec '^:verifyDependencyLocks([[:space:]]|$)'
+)"
+test "${lock_task_count}" -eq 1
+```
+
+Expected: the dry run succeeds and the explicit count assertion proves exactly one scheduled root
+`verifyDependencyLocks` task.
+
+### Task 11: Synchronize module guidance with the verified graph
+
+**Files:**
+
+- Modify: `src/application-core/CLAUDE.md`
+- Modify: `src/application-core/README.md`
+- Modify: `src/adapter/inbound/graphql/CLAUDE.md`
+- Modify: `src/adapter/inbound/graphql/README.md`
+- Modify: `src/adapter/inbound/grpc/CLAUDE.md`
+- Modify: `src/adapter/inbound/grpc/README.md`
+- Modify: `src/adapter/inbound/web/CLAUDE.md`
+- Modify: `src/adapter/inbound/web/README.md`
+- Modify: `src/adapter/inbound/websocket/CLAUDE.md`
+- Modify: `src/adapter/inbound/websocket/README.md`
+- Modify: `src/adapter/outbound/cache-redis/CLAUDE.md`
+- Modify: `src/adapter/outbound/cache-redis/README.md`
+- Modify: `src/adapter/outbound/httpclient/CLAUDE.md`
+- Modify: `src/adapter/outbound/httpclient/README.md`
+- Modify: `src/adapter/outbound/identifier/CLAUDE.md`
+- Modify: `src/adapter/outbound/identifier/README.md`
+- Modify: `src/adapter/outbound/messaging/CLAUDE.md`
+- Modify: `src/adapter/outbound/messaging/README.md`
+- Modify: `src/adapter/outbound/notification/CLAUDE.md`
+- Modify: `src/adapter/outbound/notification/README.md`
+- Modify: `src/adapter/outbound/persistence-jpa/CLAUDE.md`
+- Modify: `src/adapter/outbound/persistence-jpa/README.md`
+- Modify: `src/adapter/outbound/persistence-mongo/CLAUDE.md`
+- Modify: `src/adapter/outbound/persistence-mongo/README.md`
+- Modify: `src/adapter/outbound/support/CLAUDE.md`
+- Modify: `src/adapter/outbound/support/README.md`
+- Modify: `src/app-bootstrap/CLAUDE.md`
+- Modify: `src/app-bootstrap/README.md`
+- Modify: `src/sample-portfolio/CLAUDE.md`
+- Modify: `src/sample-portfolio/README.md`
+- Modify: `src/README.md`
+
+- [ ] **Step 1: Remove stale dependency claims**
+
+Replace each changed leaf's production-project-dependency statement with the matching row below and
+state immediately after it that `.harness/project/modules.yaml` is the SSOT:
+
+```text
+application-core: domain-core, shared-contract; external production dependencies: none
+adapter-inbound-graphql: shared-contract
+adapter-inbound-grpc: shared-contract
+adapter-inbound-web: application-core, shared-contract
+adapter-inbound-websocket: domain-core
+adapter-outbound-cache-redis: shared-contract, adapter-outbound-support
+adapter-outbound-httpclient: shared-contract, adapter-outbound-support
+adapter-outbound-identifier: application-core
+adapter-outbound-messaging: application-core, shared-contract, adapter-outbound-support
+adapter-outbound-notification: application-core, shared-contract, adapter-outbound-support
+adapter-outbound-persistence-jpa: application-core, shared-contract
+adapter-outbound-persistence-mongo: none
+adapter-outbound-support: none
+```
+
+Delete any application-core sentence allowing Spring stereotypes, any identifier sentence claiming
+a domain or `uuid-creator` dependency, and every Mongo `Example*` reference.
+
+Use this exact Mongo responsibility paragraph in both Mongo documents:
+
+```text
+This opt-in leaf owns generic Mongo client/template configuration and typed enablement properties.
+Consumers own documents, repositories, mappers, repository adapters, and repository scanning.
+The leaf contains no sample business model and has no production project dependency.
+```
+
+- [ ] **Step 2: Document runtime and analysis classpaths separately**
+
+Add this exact glossary to `src/app-bootstrap/README.md` and link to it from
+`src/app-bootstrap/CLAUDE.md` and `src/README.md`:
+
+```text
+app-default: present in production boot runtime
+opt-in: absent from production boot runtime until explicitly selected
+architectureAnalysis: test-only coverage of every registered production leaf
+sampleFixture: test-only sample analysis
+```
+
+Add this exact sentence to both sample documents:
+
+```text
+Property-test discovery state (`.jqwik-database`) is generated locally, ignored, and never committed.
+```
+
+Do not copy the 19-leaf list into root prose; link to `.harness/project/modules.yaml`.
+
+- [ ] **Step 3: Verify guidance and executable commands**
+
+Run:
+
+```bash
+./gradlew verifyReadmeCommands --console=plain
+rg -n 'spring-boot-starter.*application-core|sample-ticket|ExampleMongo|PRODUCTION_MODULES' \
+ AGENTS.md CLAUDE.md src docs/superpowers \
+ -g '*.md' -g '*.java' -g '*.gradle'
+```
+
+Expected: README command verification passes. The grep finds no active stale claim or hard-coded
+production-module list; historical evidence inside the approved design documents is allowed only
+when explicitly labeled as audit evidence.
+
+### Task 12: Run final verification and review
+
+**Files:**
+
+- Verify all files changed by Tasks 2-11
+
+- [ ] **Step 1: Run registry and harness validation**
+
+Run:
+
+```bash
+python3 -m unittest discover -s .harness/tests -v
+python3 .harness/validators/validate_modules.py
+```
+
+Expected: all tests pass and validator reports exactly 19 valid leaves.
+
+- [ ] **Step 2: Run focused module tests for all 19 leaves**
+
+Run:
+
+```bash
+cd src
+./gradlew \
+ :domain-core:test \
+ :application-core:test \
+ :shared-contract:test \
+ :adapter:inbound:web:test \
+ :adapter:inbound:graphql:test \
+ :adapter:inbound:grpc:test \
+ :adapter:inbound:websocket:test \
+ :adapter:outbound:support:test \
+ :adapter:outbound:cache-redis:test \
+ :adapter:outbound:fileserver:test \
+ :adapter:outbound:httpclient:test \
+ :adapter:outbound:identifier:test \
+ :adapter:outbound:messaging:test \
+ :adapter:outbound:notification:test \
+ :adapter:outbound:objectstorage:test \
+ :adapter:outbound:persistence-jpa:test \
+ :adapter:outbound:persistence-mongo:test \
+ :app-bootstrap:test \
+ :sample-portfolio:test \
+ --console=plain
+```
+
+Expected: all 19 tasks pass. Docker-dependent tests may be reported as skipped only through their
+existing `disabledWithoutDocker` contract.
+
+- [ ] **Step 3: Run architecture, purity, composition, and lock gates**
+
+Run:
+
+```bash
+./gradlew \
+ verifyCleanArchitectureDependencies \
+ verifyExternalDependencyPurity \
+ verifyConfigurationPropertiesProcessor \
+ verifyDependencyLocks \
+ :app-bootstrap:sampleOffTest \
+ :app-bootstrap:test --tests '*CleanArchitectureTest' \
+ --console=plain
+```
+
+Expected: every gate passes; sample-off has no sample class, and architecture analysis covers all
+registered production leaves.
+
+- [ ] **Step 4: Run repository-wide release verification**
+
+Run:
+
+```bash
+./gradlew test --console=plain
+./gradlew check --console=plain
+git diff --check
+git status --short
+```
+
+Expected: both Gradle commands pass, `git diff --check` prints nothing, and status contains only
+authorized working-tree changes.
+
+- [ ] **Step 5: Perform human-only handoff review**
+
+Review the working-tree diff in this order:
+
+```text
+1. registry topology and runtime membership
+2. application/core external purity
+3. architecture-analysis versus production runtime separation
+4. leaf dependency and test-scope removals
+5. Mongo/sample isolation and jqwik cleanup
+6. lockfile/version-owner changes
+7. module guidance parity
+```
+
+Expected: every removal has a matching compile/focused-test result, every static candidate that
+remained has a direct-use explanation, and no agent has staged or committed the changes.
diff --git a/docs/superpowers/specs/2026-07-25-application-outbox-failure-reporting-design.md b/docs/superpowers/specs/2026-07-25-application-outbox-failure-reporting-design.md
new file mode 100644
index 00000000..e5d93dd2
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-25-application-outbox-failure-reporting-design.md
@@ -0,0 +1,431 @@
+# Application Outbox Failure Reporting Refactoring Design
+
+- **Date:** 2026-07-25
+- **Status:** Approved
+- **Scope:** `application-core` outbox failure reporting, its messaging adapter, bootstrap wiring,
+ dependency purity enforcement, tests, and affected module documentation
+- **Source:** user-requested Clean Architecture refactoring review plus repository evidence gathered
+ on 2026-07-25
+
+## 1. Problem Statement
+
+`application-core` declares `org.springframework.boot:spring-boot-starter`, although its production
+sources use no Spring type or annotation. The only external observability types in the module are
+`org.slf4j.Logger` and `org.slf4j.LoggerFactory` in
+`PublishPendingOutboxEventsUseCase`. The broad starter consequently places Boot autoconfiguration,
+Spring Context/AOP, Micrometer Observation, Logback, Log4j bridges, JUL bridges, and SnakeYAML on a
+core application classpath for two logging calls.
+
+This contradicts the module's framework-free design statement and weakens the dependency direction
+the template is intended to teach. It also hides an important semantic distinction: the two log
+lines are not arbitrary diagnostic messages. They report confirmed `FAILED` and `DEAD` outbox state
+transitions that feed operational alerts and runbooks.
+
+The build currently cannot resolve dependencies or run tests because `src/settings.gradle` fails
+when `.harness/project/modules.yaml` is absent. Harness registry recovery is therefore a prerequisite
+for implementation and verification, not part of this refactoring.
+
+## 2. Evidence
+
+- `src/application-core/build.gradle:14` declares `spring-boot-starter`.
+- `src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java:17-18`
+ imports the only production observability framework types in the module.
+- The same use case creates an SLF4J logger at lines 38-39 and emits the two failure records at
+ lines 139-165.
+- `src/application-core/README.md:10-13` says the module is framework-free and accesses
+ infrastructure only through `*Port` interfaces.
+- `src/application-core/CLAUDE.md:27-30` and `src/application-core/build.gradle:3-5` instead claim
+ the starter is retained for optional `@Service` registration, although application-core contains
+ no Spring stereotype.
+- The relay is manually constructed and must not be a Spring bean:
+ `src/application-core/README.md:332-337`.
+- The outbox registry assigns `OUTBOX_PUBLISH_FAILED` and `OUTBOX_DEAD_LETTER` to the infrastructure
+ owner layer and ERROR severity: `docs/registries/error-codes.yaml:724-749`.
+- The runbooks require structured `error.code`, `event_id`, `event_type`, and `correlation_id`
+ fields: `docs/runbooks/outbox-publish-failed.md:17-27` and
+ `docs/runbooks/outbox-dead-letter.md:17-24`.
+- The current project-dependency verifier inspects only `ProjectDependency` instances, so it cannot
+ reject an external starter added to a core module: `src/build.gradle:612-619`.
+
+## 3. Goals
+
+1. Make `application-core` free of Spring, SLF4J, Logback, Log4j, JUL logging, and Micrometer types
+ and main/test classpath dependencies.
+2. Express confirmed outbox publication failures as a typed application-owned outbound port.
+3. Keep report data safe by construction: no payload, idempotency key, arbitrary field map, log
+ level, message template, or framework logger crosses the port.
+4. Implement structured failure reporting in `adapter:outbound:messaging`.
+5. Keep `app-bootstrap` limited to final wiring and runtime logging configuration.
+6. Preserve outbox state-machine behavior, transaction boundaries, per-event continuation, and
+ at-least-once semantics.
+7. Ensure a reporting backend failure cannot change a persisted `FAILED`/`DEAD` outcome or stop the
+ remaining relay batch.
+8. Remove the duplicate, misleading fail-open WARN currently emitted by the fail-closed outbox
+ publisher.
+9. Add source- and dependency-level guardrails that prevent framework observability from returning
+ to application-core.
+
+## 4. Non-Goals
+
+- This change does not redesign outbox claiming, FIFO ordering, retry backoff, in-flight recovery,
+ or broker selection.
+- It does not turn operational failures into domain events or business audit records.
+- It does not deliver a failure report through the same broker/outbox path; that would recurse when
+ the broker is the failing dependency.
+- It does not add a generic `LoggerPort`, severity API, string template API, or untyped field map.
+- It does not add a twentieth module or a general observability adapter family.
+- It does not recover the missing `.harness` policy registry; the implementation waits for that
+ independently governed recovery.
+- It does not change public HTTP response contracts.
+
+## 5. Semantic Classification
+
+An outbox publish failure is an **application operational event**:
+
+- the application state machine decides whether the confirmed result is retryable `FAILED` or
+ terminal `DEAD`;
+- the persistence transition is authoritative;
+- an infrastructure adapter renders that fact as a structured operational record;
+- metrics and runbooks consume the result for operations.
+
+It is not a business audit event. It has no actor/action audit semantics, is not retained as an
+immutable audit ledger, and must not be used as proof of a business transaction. It is also not a
+domain event: feeding it into the same outbox publisher would recursively fail.
+
+## 6. Architecture
+
+```text
+PublishPendingOutboxEventsUseCase
+ │
+ ├── OutboxStorePort ──────────────> persistence adapter
+ ├── OutboxMessagePublishPort ─────> messaging publisher adapter
+ └── OutboxRelayFailureReportPort ─> SLF4J structured reporter adapter
+ (adapter:outbound:messaging)
+
+app-bootstrap
+ └── injects all three ports when manually constructing the relay use case
+```
+
+Dependency direction remains:
+
+```text
+app-bootstrap
+ -> adapter:outbound:messaging
+ -> application-core
+ -> shared-contract
+ -> domain-core
+```
+
+`application-core` owns the port and safe report value. The messaging module owns the concrete
+rendering because its local responsibility explicitly includes outbox publication adaptation, it
+already depends on application/shared contracts, and it already has the SLF4J API. Bootstrap
+selects and injects the adapter but does not implement the port.
+
+## 7. Application Contract
+
+### 7.1 Port
+
+```java
+@FunctionalInterface
+public interface OutboxRelayFailureReportPort {
+
+ /**
+ * Attempts to report a confirmed FAILED or DEAD relay transition.
+ *
+ * The implementation must not throw. The report is operational evidence, while the persisted
+ * outbox state and returned relay outcome remain authoritative.
+ */
+ void report(OutboxRelayFailureReport report);
+}
+```
+
+The use case also contains a defensive non-throwing invocation boundary. This makes the invariant
+explicit even if a custom implementation violates the port contract.
+
+### 7.2 Safe immutable report
+
+```java
+public record OutboxRelayFailureReport(
+ OperationalError code,
+ String eventId,
+ String eventType,
+ String aggregateId,
+ String correlationId,
+ int attemptCount,
+ Instant nextAttemptAt,
+ RuntimeException cause) {
+
+ public static OutboxRelayFailureReport retryableFailure(
+ String eventId,
+ String eventType,
+ String aggregateId,
+ String correlationId,
+ int attemptCount,
+ Instant nextAttemptAt,
+ RuntimeException cause);
+
+ public static OutboxRelayFailureReport deadLetter(
+ String eventId,
+ String eventType,
+ String aggregateId,
+ String correlationId,
+ int attemptCount,
+ RuntimeException cause);
+}
+```
+
+Invariants:
+
+- `code` is exactly `OUTBOX_PUBLISH_FAILED` or `OUTBOX_DEAD_LETTER`.
+- identifiers and type names are non-null and non-blank.
+- `attemptCount` is at least one.
+- `nextAttemptAt` is required for `OUTBOX_PUBLISH_FAILED` and absent for
+ `OUTBOX_DEAD_LETTER`.
+- `cause` is required.
+- the record has no `payload`, `idempotencyKey`, logger, severity, template, or arbitrary map.
+
+Static factories remove invalid combinations from ordinary call sites. The record deliberately
+accepts an allowlisted set of safe operational metadata plus the original cause rather than an
+`OutboxEvent`, whose full shape includes payload and idempotency data.
+
+## 8. Relay Flow and Failure Semantics
+
+The report attempt happens only after the corresponding write transaction succeeds:
+
+```text
+publish throws
+ ├── retry remains
+ │ ├── inWrite(markFailed) succeeds
+ │ ├── attempt OUTBOX_PUBLISH_FAILED report
+ │ └── return FAILED
+ └── attempts exhausted
+ ├── inWrite(markDead) succeeds
+ ├── attempt OUTBOX_DEAD_LETTER report
+ └── return DEAD
+```
+
+Behavior matrix:
+
+| Situation | Persistence | Report | Relay behavior |
+|---|---|---|---|
+| Publish succeeds, `markPublished` succeeds | `PUBLISHED` | none | return `PUBLISHED` |
+| Publish succeeds, `markPublished` fails | remains recoverable `IN_FLIGHT` | none | propagate store failure; scheduler retries later |
+| Publish fails, `markFailed` succeeds | `FAILED` | attempt retryable report | return `FAILED`; continue batch |
+| Publish fails, `markDead` succeeds | `DEAD` | attempt dead-letter report | return `DEAD`; continue batch |
+| Publish fails, status transition fails | no confirmed FAILED/DEAD transition | none | propagate store failure; do not emit a false report |
+| Reporter violates its contract and throws | already `FAILED` or `DEAD` | attempted | contain reporter exception; preserve outcome and continue batch |
+
+The report is mandatory as an **attempt** after every confirmed failure transition. A production
+NOOP binding is forbidden. No logging system can guarantee durable emission, so metrics and
+persistence remain independent evidence when the logging backend itself is impaired.
+
+## 9. Structured Logging Contract
+
+The messaging adapter emits ERROR through SLF4J 2's fluent key-value API. The runtime Logstash
+encoder serializes key-value pairs as top-level JSON fields, while the message also carries a short
+safe summary for local pattern output.
+
+Common fields:
+
+- `error.code`
+- `error.category`
+- `dependency_name`
+- `dependency_type=messaging`
+- `outcome`
+- `event_id`
+- `event_type`
+- `aggregate_id`
+- `correlation_id`
+- `attempt_count`
+- `runbook_link`
+
+Retry-only field:
+
+- `next_attempt_at`
+
+Mappings:
+
+| Code | Outcome | Runbook |
+|---|---|---|
+| `OUTBOX_PUBLISH_FAILED` | `FAILED` | `runbook://outbox/publish-failed` |
+| `OUTBOX_DEAD_LETTER` | `DEAD` | `runbook://outbox/dead-letter` |
+
+The throwable is attached as the log cause. Payload, idempotency key, message envelope, recipient,
+and arbitrary exception-derived key/value fields are never added. Existing runtime masking remains
+defense in depth rather than the primary privacy boundary.
+
+## 10. Duplicate Logging Removal
+
+`OutboxMessagePublishAdapter` currently uses `FailOpenDependencyLogger` for a fail-closed operation:
+it emits WARN and rethrows. That logger's documented meaning is an optional dependency failure where
+the use case still succeeds. The relay then emits a second ERROR after deciding `FAILED` or `DEAD`.
+
+After this refactoring:
+
+- `OutboundMessagePublisher` keeps `FailOpenDependencyLogger` because its contract is genuinely
+ fail-open.
+- `OutboxMessagePublishAdapter` maps/sends and surfaces failures without logging.
+- `Slf4jOutboxRelayFailureReportAdapter` emits the single canonical ERROR after the application
+ state transition succeeds.
+
+This removes duplicate records and makes severity match the confirmed outcome.
+
+## 11. Dependency Purity
+
+### 11.1 Gradle declarations
+
+`application-core` production declarations become:
+
+```groovy
+dependencies {
+ implementation project(':domain-core')
+ implementation project(':shared-contract')
+}
+```
+
+The root test baseline gives application-core JUnit Jupiter and AssertJ directly rather than Spring
+Boot test starters. Spring dependency-management may remain build tooling, but Spring/logging/
+Micrometer artifacts must not appear on application-core main or test compile/runtime classpaths.
+Static-analysis tool configurations are outside this classpath rule.
+
+### 11.2 Gradle verification
+
+A blocking `verifyApplicationCoreDependencyPurity` task checks both:
+
+1. application-core production configurations contain no declared external module dependency;
+2. `compileClasspath`, `runtimeClasspath`, `testCompileClasspath`, and `testRuntimeClasspath`
+ resolve no Spring, SLF4J, Logback, Log4j, or Micrometer component.
+
+The task is wired into `:application-core:check`.
+
+### 11.3 Source verification
+
+ArchUnit adds an application logger/metrics ban covering:
+
+- `org.slf4j..`
+- `java.util.logging..`
+- `ch.qos.logback..`
+- `org.apache.logging.log4j..`
+- `io.micrometer..`
+
+An intentional application-package fixture proves the rule is not vacuous. The Gradle task remains
+necessary because ArchUnit cannot detect an unused starter that is merely present on the classpath.
+
+## 12. Module Ownership Alternatives
+
+### 12.1 Messaging adapter — selected
+
+Advantages:
+
+- highest cohesion with outbox publication failure;
+- existing application/shared/support and SLF4J dependencies;
+- no new project edge;
+- reusable by composition roots other than app-bootstrap;
+- preserves bootstrap as wiring rather than an adapter collection.
+
+Counterargument: reporting is observability rather than broker transport. The selected design
+answers this by keeping the contract application-owned and the concrete class narrowly
+outbox-specific; generic logging support does not move into messaging.
+
+### 12.2 Shared outbound support — rejected
+
+Advantages:
+
+- already owns reusable correlation and fail-open dependency logging;
+- would centralize logging backend calls.
+
+Counterargument: the support module explicitly keeps feature-specific behavior in the owning leaf.
+Putting `FAILED`/`DEAD` outbox semantics there makes a low-level shared module feature-aware.
+Generalizing the interface would create the forbidden logger abstraction.
+
+### 12.3 App bootstrap — viable fallback, not selected
+
+Advantages:
+
+- owns runtime logging bootstrap and final wiring;
+- already contains outbox metrics and structured Logstash usage.
+
+Counterargument: each feature-specific reporter placed there expands the composition root into an
+adapter implementation module and prevents straightforward reuse by another composition root.
+
+### 12.4 Direct `slf4j-api` in application-core — rejected
+
+This is the smallest dependency diff and would remove Spring Boot transitive dependencies, but it
+retains framework coupling, contradicts the module rule, and tests formatting calls instead of
+application meaning.
+
+### 12.5 Generic operational event publisher — deferred
+
+A typed cross-feature operational event sink could become valuable when several application
+features need the same routing. Introducing it for two outbox outcomes is premature, risks an
+untyped field bag, and must never be implemented through the failing outbox broker.
+
+## 13. Testing Strategy
+
+### Application contract tests
+
+- report factory happy paths and invariant rejection;
+- record component whitelist proving payload and idempotency key are absent;
+- transient failure reports only after `markFailed`;
+- dead-letter failure reports only after `markDead`;
+- transition failure emits no report;
+- successful publish and `markPublished` failure emit no failure report;
+- a throwing reporter does not change `FAILED`/`DEAD` result and does not stop later events.
+
+### Messaging adapter tests
+
+- exactly one ERROR record;
+- code/category/outcome/runbook mapping;
+- required snake_case identifiers and attempt fields;
+- retry-only `next_attempt_at`;
+- throwable preservation;
+- no payload or idempotency key;
+- reporter bean exists when messaging is disabled and when a broker is active;
+- outbox publisher propagates runtime and checked broker failures without emitting the old
+ fail-open WARN.
+
+### Architecture and Gradle tests
+
+- intentional application logger fixture is rejected;
+- production application packages pass the new rule;
+- application-core dependency purity task passes only with clean main/test classpaths;
+- lock verification passes after regeneration.
+
+### Regression tests
+
+- focused application, messaging, and bootstrap tests;
+- outbox PostgreSQL lifecycle tests when Docker is available;
+- full `test` and `check`.
+
+## 14. Migration Sequence
+
+1. Recover and validate the harness module registry so Gradle can configure.
+2. Add red tests for safe report contracts and relay semantics.
+3. Add the application report value and port.
+4. Inject the port into the relay and make report failures outcome-neutral.
+5. Add red messaging adapter and wiring tests.
+6. Implement the structured messaging reporter.
+7. Remove fail-open logging from the fail-closed outbox publisher.
+8. Remove application-core Boot/Spring/logging dependencies and give it a pure test baseline.
+9. Add source and resolved-classpath purity guards.
+10. Regenerate dependency locks and update module/runbook documentation.
+11. Run focused, architecture, dependency, full test, and full check gates.
+12. Capture implementation evidence in the LLM Wiki as required by repository policy.
+
+## 15. Acceptance Criteria
+
+- No application-core main or test source imports Spring, SLF4J, Logback, Log4j, JUL logging, or
+ Micrometer.
+- No forbidden framework artifact appears on application-core main/test compile/runtime
+ classpaths.
+- `spring-boot-starter` is absent from `src/application-core/build.gradle`.
+- Every confirmed `FAILED`/`DEAD` transition attempts exactly one typed report.
+- A status-transition failure emits no success-like failure report.
+- A reporter exception cannot change a relay outcome or stop the next claimed event.
+- Production wiring contains exactly one non-NOOP `OutboxRelayFailureReportPort`.
+- Structured ERROR fields and runbook links match the documented registry conventions.
+- Payload and idempotency key cannot cross the report contract and do not appear in adapter logs.
+- The fail-closed publisher no longer uses `FailOpenDependencyLogger`.
+- Application logger ArchUnit mutation and dependency purity checks are blocking.
+- Focused tests, dependency locks, architecture checks, full tests, and `check` pass after harness
+ registry recovery, or any environmental blocker is reported with its remaining risk.
diff --git a/docs/superpowers/specs/2026-07-25-ci-control-plane-recovery-design.md b/docs/superpowers/specs/2026-07-25-ci-control-plane-recovery-design.md
new file mode 100644
index 00000000..e34b294a
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-25-ci-control-plane-recovery-design.md
@@ -0,0 +1,476 @@
+# CI Control Plane Recovery Design
+
+- **Date:** 2026-07-25
+- **Status:** Approved
+- **Scope:** repository control-plane recovery, Gitea Actions enablement, Gradle/CI gates,
+ container build context, dependency automation, and documentation parity
+- **Depends on:**
+ [`2026-07-20-harness-policy-engine-design.md`](2026-07-20-harness-policy-engine-design.md)
+- **Current revision audited:** `821fe00c323b5335980f271c7ee47b92ac2168f2`
+- **Task-packet state:** unavailable. `.harness/validators/resolve_task.py` and its policy inputs
+ are absent from the audited revision, so no task-packet hash or formal evidence profile can be
+ produced before control-plane recovery.
+
+## 1. Problem Statement
+
+The repository describes a high-assurance CI and architecture-governance control plane, but the
+audited Git tree does not contain the hidden root paths that implement it. The current revision
+contains no `.harness`, `.agents`, `.claude`, `.codex`, or `.github` tree. It also lacks the root
+`.tool-versions`, `.trivyignore.yaml`, and `.gitattributes` contracts referenced by the tracked
+guidance and Gradle build.
+
+This is not a Gradle wrapper failure. The tracked wrapper downloads and starts Gradle 9.0.0 under
+Java 21, but every project task stops while evaluating `src/settings.gradle` because
+`.harness/project/modules.yaml` is missing. The same missing registry also prevents the architecture
+dependency gate and task-packet resolution from running.
+
+The CI host is Gitea 1.27.0, not GitHub. The public repository API reports `has_actions: false`, so
+the repository Actions unit is disabled. An unauthenticated request to the runner API returns
+`401`, which proves that runner state must be checked with repository or administrator
+authorization; it does not prove that a usable runner exists. Gitea Actions requires both the
+repository Actions unit and an online runner.
+
+The container build has an additional independent defect. Compose and both Dockerfile examples use
+`src/` as the build context, while Gradle resolves the registry from the repository root. Even after
+the hidden assets are restored, a build with the current context cannot copy the registry into the
+builder.
+
+## 2. Audit Baseline
+
+| Surface | Command or source | Observed result |
+| --- | --- | --- |
+| Git revision | `git rev-parse HEAD` | `821fe00c323b5335980f271c7ee47b92ac2168f2` |
+| Hidden assets | `git cat-file -e HEAD:.harness` and equivalent checks | `.harness`, `.agents`, `.claude`, `.codex`, `.github` absent |
+| Ignore rules | `git check-ignore -v --no-index ...` | exit `1`; missing paths are not ignored |
+| Recoverable local objects | `git fsck --full --no-reflogs --unreachable` | exit `0`; no unreachable objects reported |
+| Gradle launcher | `cd src && ./gradlew --version` | exit `0`; Gradle 9.0.0 and Java 21 |
+| Gradle project task | `cd src && ./gradlew tasks --console=plain` | exit `1`; missing module registry at `src/settings.gradle:12` |
+| Gradle release gate | `cd src && ./gradlew check --console=plain` | exit `1`; same settings failure |
+| Task resolver | `python3 .harness/validators/resolve_task.py ...` | impossible; resolver file absent |
+| Compose syntax | `docker compose -f docker-compose.yml -f docker-compose.local.yml config --quiet` | exit `0` |
+| Gitea version | `GET /api/v1/version` | `1.27.0` |
+| Repository Actions | public repository API | `has_actions: false` |
+| Runner API | unauthenticated runner request | `401`; authorized runner inventory still required |
+
+The most recent commit added 971 files over a parent that contained only a two-line README. The
+root tree contains no dot-prefixed entry, while nested files such as `src/.env`,
+`src/.dockerignore`, and `src/.gitignore` were included. This is consistent with a top-level shell
+glob used during copying or staging. That is a falsifiable root-cause hypothesis, not proof of the
+exact command that was used.
+
+## 3. Goals
+
+1. Prefer byte-for-byte recovery of the authoritative hidden control-plane assets; when that source
+ is unavailable or incomplete, require an explicit human reconstruction decision and record new
+ provenance without presenting reconstruction as restoration.
+2. Restore the 19-leaf module registry and the harness behavior approved in the 2026-07-20 design.
+3. Make a fresh checkout fail early with a precise control-plane error before Gradle configuration.
+4. Enable repository Actions on Gitea 1.27.0 and provide an isolated, repository-scoped runner.
+5. Keep `.github/workflows` as the canonical workflow directory while preventing an accidental
+ `.gitea/workflows` shadow.
+6. Restore a single release-blocking fan-in status and the complementary vulnerability status.
+7. Make public-path, Trivy suppression, dependency-lock, and architecture gates fail closed.
+8. Make Docker builds consume the same root registry without duplicating the registry under `src/`.
+9. Pause Renovate automerge until required CI statuses and lockfile refresh behavior are proven.
+10. Bring README, gate-matrix, workflow, and physical-path claims back into parity.
+
+## 4. Non-Goals
+
+- This recovery does not change production Java behavior or module boundaries.
+- It does not redesign the 19-leaf registry approved in the 2026-07-20 harness policy design.
+- It does not silently synthesize hidden policy files or present reconstructed content as recovered
+ authority.
+- It does not store a Gitea API token, runner registration token, or repository secret in Git.
+- It does not enable deployment to a production environment. Release artifact construction and
+ scanning are restored, but a separate deployment decision remains human-owned.
+- It does not re-enable Renovate automerge merely because workflow files exist; branch protection
+ and a successful dependency-update exercise are also required.
+
+## 5. Governing Invariants
+
+### 5.1 Recovery mode is an explicit human decision
+
+The original working tree, archive, or source repository that produced the 2026-07-20 harness
+design is the preferred recovery authority. Before copying anything into this repository, the
+recovery source must be inventoried and hashed outside the worktree.
+
+The minimum authoritative set is:
+
+- `.harness/`
+- `.agents/`
+- `.claude/`
+- `.codex/`
+- `.github/`
+- `.tool-versions`
+- `.trivyignore.yaml`
+- `.gitattributes`
+
+The human owner chooses one of two modes and records it before repository writes:
+
+**Mode A — authoritative restore.** All minimum paths exist in the recovery source. The executor
+hashes them, copies them byte-for-byte, proves source/destination equality, and preserves their
+native provenance.
+
+**Mode B — controlled reconstruction.** The original source is unavailable or incomplete. The
+human records that fact and explicitly authorizes reconstruction from the approved
+[`2026-07-20-harness-policy-engine-design.md`](2026-07-20-harness-policy-engine-design.md),
+[`2026-07-20-harness-policy-engine.md`](../plans/2026-07-20-harness-policy-engine.md), the tracked
+Gradle/module sources, and this design. Reconstructed artifacts receive new hashes and a
+`controlled-reconstruction` provenance record. Schema, renderer parity, mutation coverage, Gradle
+project discovery, and architecture dependency checks must pass before the new artifacts can act as
+authority.
+
+The first Mode B inventory covers the reconstructed harness and generated platform assets only.
+After Tasks 3-6 and every later change to a covered path are final, the executor regenerates one
+complete, sorted SHA-256 inventory for `.harness`, `.agents`, `.claude`, `.codex`, `.github`,
+`.tool-versions`, `.trivyignore.yaml`, `.gitattributes`, `.dockerignore`, and
+`docs/security/public-paths-snapshot.txt`. The provenance record names that final evidence path.
+Only this post-change inventory is used for the human handoff.
+
+Implementation stops only until the human chooses Mode A or Mode B. An incomplete Mode A export
+must never be filled silently. It may instead cause the human to switch the recorded decision to
+Mode B. The tracked `AGENTS.md`, `CLAUDE.md`, and design documents are evidence of intended
+behavior, but reconstructed schemas, agents, registries, and workflows become authoritative only
+after the required new evidence passes.
+
+### 5.2 Human-only commit policy
+
+Recovery and implementation may leave reviewed changes in the working tree, but agents do not
+stage, commit, amend, or push. A human decides commit boundaries after reviewing recovery hashes,
+generated-file parity, test evidence, and Gitea status checks.
+
+### 5.3 One registry and one workflow source
+
+`.harness/project/modules.yaml` remains the only module-edge and focused-command registry.
+Container builds copy that file from the repository root; they do not create a second copy under
+`src/`.
+
+`.github/workflows` remains the canonical workflow directory because the repository documentation
+and portability contract already point there. Gitea's default `WORKFLOW_DIRS` value is
+`.gitea/workflows,.github/workflows`, and Gitea uses the first directory that exists. Therefore
+`.gitea/workflows` must remain absent unless the project later adopts a generated-mirror design
+with an explicit parity check and a separate approved specification.
+
+### 5.4 Fail closed before expensive work
+
+CI runs a repository control-plane preflight before invoking build or container work. Missing
+policies, workflow shadowing, a missing committed public-path baseline, or an incomplete structured
+Trivy contract fail immediately. Once Java/Gradle is available, that same blocking preflight runs
+`verifyTrivyignore`; the vulnerability scanner must explicitly consume `.trivyignore.yaml`.
+
+## 6. Target Architecture
+
+```text
+authoritative hidden-asset export
+ |
+ v
+recovery inventory + SHA-256 comparison
+ |
+ v
+.harness/.agents/.claude/.codex/.github restored
+ |
+ v
+control-plane preflight
+ |-- required paths
+ |-- generated-agent parity
+ |-- canonical workflow directory
+ |-- committed security baselines
+ `-- task-packet resolver availability
+ |
+ v
+resolved high-risk CI/deployment task packet
+ |
+ +-----------------------------+
+ | |
+ v v
+Gradle quality gates Docker root-context builds
+ |-- dependency locks |-- production bootJar
+ |-- architecture edges `-- sample bootJar
+ |-- focused/ArchUnit tests
+ |-- test/check
+ `-- public/env/security contracts
+ | |
+ +--------------+--------------+
+ v
+ CI quality release-gate
+ +
+ dependency-vulnerability required status
+ |
+ v
+ Gitea protected-branch decision
+```
+
+## 7. Design Decisions
+
+### 7.1 Recovery gate and physical control-plane manifest
+
+After Mode A restore parity or Mode B reconstruction evidence passes, the harness gains a small
+physical manifest at `.harness/project/control-plane.yaml`. It lists required files, required
+directories, the canonical workflow directory, and the forbidden shadow directory. The file uses
+JSON syntax, matching the 2026-07-20 design's stdlib-only JSON-as-YAML convention.
+
+`.harness/validators/validate_control_plane.py` reads the manifest and reports every missing path in
+one deterministic result. It also rejects `.gitea/workflows`. Its tests live at
+`.harness/tests/test_control_plane.py`.
+
+This validator checks physical availability only. It does not duplicate module edges, risk rules,
+or workflow gate semantics. Module semantics stay in `modules.yaml`; the gate matrix stays in
+`.github/ci-gate-matrix.yml`.
+
+### 7.2 Gitea Actions and runner control
+
+The repository owner enables `Enable Repository Actions` in the repository settings. The public API
+must then report `has_actions: true`.
+
+The runner is registered at repository scope, uses an isolated Docker execution mode, and exposes
+the exact `ubuntu-22.04` label used by the workflows. Registration credentials remain in the runner
+host's protected state or secret manager. They never enter workflow YAML, shell history captured by
+CI, Docker image layers, or repository files.
+
+An authenticated repository runner inventory must show at least one enabled, online runner before
+the first required workflow is treated as operational. The previous unauthenticated `401` remains
+an expected access-control result.
+
+### 7.3 Canonical workflow directory and Gitea shadowing
+
+The recovery restores canonical workflows under:
+
+- `.github/workflows/ci-quality-gates.yml`
+- `.github/workflows/build-release-supply-chain.yml`
+- `.github/workflows/dependency-vulnerability.yml`
+
+No workflow is copied to `.gitea/workflows`. With Gitea's default directory ordering, the mere
+existence of `.gitea/workflows` would cause `.github/workflows` to be ignored. The preflight
+validator makes that shadow a blocking failure.
+
+Instance administration must confirm that `[actions].WORKFLOW_DIRS` still contains
+`.github/workflows`. If the instance has a non-default value that excludes it, the administrator
+changes the instance setting or the project stops before enabling required checks.
+
+### 7.4 Preflight and release-gate topology
+
+`ci-quality-gates.yml` starts with `control-plane-preflight`. No Gradle, test, or Docker job runs
+unless preflight succeeds.
+
+The release-blocking fan-out includes:
+
+- restored harness unit and mutation suite
+- `validate_modules.py`, renderer parity, policy parity, and `verify-gate-matrix.sh`
+- structured Trivy validation through `verifyTrivyignore`
+- Gradle wrapper launch and Java 21 assertion
+- `verifyDependencyLocks`
+- `verifyCleanArchitectureDependencies`
+- focused ArchUnit coverage
+- `verifyPublicPathSnapshot`
+- `test`
+- `check`
+- production and sample Docker builds
+- reproducible artifact verification when owned by the restored workflow contract
+
+The workflow ends with a single `release-gate` job that uses `if: always()` and fails unless every
+release-blocking dependency succeeded. Quarantine remains non-blocking and is intentionally absent
+from the fan-in.
+
+Gitea cannot express `needs` across separate workflow files. The dependency vulnerability workflow
+therefore publishes its own blocking status. Protected branches require both the quality
+`release-gate` status and the vulnerability status.
+
+### 7.5 Missing contracts
+
+The recovery must restore `.tool-versions`, `.gitattributes`, `.trivyignore.yaml`, workflow scripts,
+gate matrix, CODEOWNERS, and vulnerability policy from the authoritative source.
+
+The structured empty Trivy contract is retained even when there are no suppressions:
+
+```yaml
+vulnerabilities: []
+licenses: []
+misconfigurations: []
+secrets: []
+```
+
+The quality preflight and release-tag preflight both run `verifyTrivyignore`. The independent
+vulnerability workflow also runs that verifier and passes
+`trivyignores: .trivyignore.yaml` to the pinned Trivy action, so a present-but-unconsumed or
+malformed suppression file cannot satisfy a required status.
+
+`docs/security/public-paths-snapshot.txt` becomes a committed baseline. The current approved value
+derived from `src/.env` is `/api/healthcheck`. A missing baseline is a failure, not an instruction to
+create one during verification.
+
+The read-only `verifyPublicPathSnapshot` task compares the committed baseline to `src/.env`.
+Generation moves to a separate, explicitly approved update task. `check` and the quality workflow
+both depend on the read-only verification task.
+
+### 7.6 Dependency locks
+
+All 19 leaf modules retain Gradle strict locking. CI runs `verifyDependencyLocks` before compile or
+test jobs so an incomplete Renovate update fails with a direct lock error.
+
+The only supported lock refresh command remains:
+
+```bash
+cd src
+./gradlew resolveAndLockAll --write-locks --console=plain
+```
+
+A dependency update is acceptable only when the declaration, all affected `gradle.lockfile` files,
+and the quality gate agree. CI never runs `--write-locks`.
+
+### 7.7 Docker root context
+
+Compose changes the app build context from `src/` to the repository root and addresses the
+Dockerfile as `src/Dockerfile`. Both Dockerfiles keep the Gradle project at `/build/src` and copy:
+
+1. `.harness/project/modules.yaml` to `/build/.harness/project/modules.yaml`;
+2. wrapper, build descriptors, and lockfiles to `/build/src`;
+3. the complete `src/` tree only after dependency verification.
+
+A root `.dockerignore` replaces the context role previously owned by `src/.dockerignore`. It
+excludes Git metadata, build output, IDE state, environment files, and secrets, while explicitly
+allowing the module registry, wrapper, build descriptors, lockfiles, and Java/resources trees.
+
+This preserves a single registry and makes local Compose, production image, sample image, and CI use
+the same context contract.
+
+### 7.8 Renovate safety state
+
+`renovate.json` sets `automerge: false` for every update type during recovery. The current comment
+already states that automerge requires trustworthy CI, while the repository currently has no
+operational Actions unit.
+
+Limited patch/pin/digest automerge can be reconsidered only after all of the following are observed:
+
+1. repository API reports `has_actions: true`;
+2. an authenticated runner inventory reports an online runner;
+3. protected branches require both blocking statuses;
+4. a real Renovate dependency pull request updates strict lock state and passes;
+5. a deliberately stale lockfile fails `verifyDependencyLocks`.
+
+The configuration description must also stop claiming that the project has
+`gradle/libs.versions.toml` unless the project separately adopts a version catalog.
+
+### 7.9 Documentation parity
+
+Root and `src/` README files must point to paths that exist in Git and commands that pass from a
+fresh checkout. The control-plane validator covers required physical paths, and the restored
+README-command and gate-matrix checks cover executable behavior.
+
+The documentation must distinguish:
+
+- Gitea repository Actions enablement from workflow files;
+- unauthenticated runner API access from authorized runner health;
+- `.github/workflows` as canonical from `.gitea/workflows` as a shadow risk;
+- lock verification from lock regeneration;
+- read-only public-path verification from approved baseline update.
+
+## 8. Phased Recovery
+
+### Phase 0 — Preserve evidence
+
+Capture the current revision, clean status, missing-path evidence, Gitea version, repository
+Actions state, and runner authorization behavior. Hash the authoritative recovery source before
+copying it.
+
+### Phase 1 — Establish authority
+
+The human chooses Mode A or Mode B. Mode A restores the hidden asset set byte-for-byte and verifies
+source equality. Mode B reconstructs from the two approved 2026-07-20 documents, records new
+provenance/hashes, and runs schema, renderer-parity, mutation, Gradle discovery, and architecture
+checks. The executor then writes a controller-approved overlay to the recorded recovery evidence
+path, invokes the recovered resolver with that file, persists the resolved packet plus packet/rule
+checksums, and proves deterministic re-resolution. Task 3 cannot start until those exact artifacts
+verify.
+
+### Phase 2 — Establish fail-fast local gates
+
+Add the physical control-plane manifest and validator. Restore missing security contracts and make
+the public-path baseline fail closed. Run harness checks before Gradle.
+
+### Phase 3 — Repair build paths
+
+Switch Docker to the repository-root context, add the root ignore contract, and verify production
+and sample images.
+
+### Phase 4 — Activate Gitea
+
+Enable repository Actions, register the isolated runner, confirm workflow directory configuration,
+and run the preflight workflow. Do not configure required statuses until job names are stable and a
+successful run exists.
+
+### Phase 5 — Enforce merge controls
+
+Enable the quality `release-gate` and vulnerability status as protected-branch requirements. Seed
+negative exercises for a missing required path, public-path drift, forbidden module edge, stale
+lockfile, and failed Docker build.
+
+### Phase 6 — Reassess automation
+
+Run a real Renovate dependency update with automerge disabled. Re-enable limited automerge only by a
+separate human decision backed by the acceptance evidence.
+
+## 9. Verification Strategy
+
+1. Mode A hashes match the authoritative export, or Mode B records the human decision, new hashes,
+ and `controlled-reconstruction` provenance.
+2. Harness unit, mutation, schema, renderer, and parity tests pass under the selected mode.
+3. Control-plane validator passes on the complete tree and fails on each seeded missing/shadow
+ mutation.
+4. Task-packet resolver emits a stable high-risk CI/deployment packet from the recorded overlay;
+ the packet and governing-rule checksums verify again at the Task 3 boundary.
+5. Gradle wrapper, project discovery, architecture dependency verification, focused ArchUnit,
+ dependency locks, `test`, and `check` pass.
+6. Public-path verification passes with the committed baseline and fails when it is absent or
+ changed.
+7. Production and sample Docker images build from repository-root context.
+8. Gitea reports repository Actions enabled and at least one authorized runner online.
+9. The preflight and quality fan-in statuses appear on a real pull request.
+10. Protected branches reject seeded failures.
+11. Renovate config validation and a real dependency update pass without automerge.
+
+## 10. Risks and Countermeasures
+
+| Risk | Countermeasure |
+| --- | --- |
+| Reconstructed policy differs from the lost authority | Require the human Mode B decision, label provenance as reconstruction, assign new hashes, and require schema/parity/mutation/Gradle evidence |
+| `.gitea/workflows` silently shadows canonical workflows | Block the directory in the physical preflight and confirm instance `WORKFLOW_DIRS` |
+| Runner can expose host Docker authority | Use a repository-scoped isolated runner, restrict fork execution, and keep registration credentials outside jobs |
+| Workflow exists but repository Actions remains disabled | Require API `has_actions: true` and a real run before branch-protection setup |
+| Required status name changes and bypasses protection | Keep stable job names in the gate matrix and verify protection after workflow changes |
+| Public-path baseline is regenerated in CI | Separate update and verify tasks; verification fails when the committed file is missing |
+| Docker root context sends secrets | Root `.dockerignore` excludes environment/secret paths and CI checks the context contract |
+| Renovate updates declarations without strict locks | Run `verifyDependencyLocks` before tests and keep automerge disabled through a real update exercise |
+| Restored workflows assume GitHub-only behavior | Exercise every event, context, action, and fan-in on Gitea 1.27.0 before making the status required |
+
+## 11. Acceptance Criteria
+
+- Mode A has a byte-identical authoritative inventory, or Mode B has a human-recorded reconstruction
+ decision, new provenance, preliminary hashes, and a post-change complete hash inventory.
+- The 2026-07-20 harness registry, validators, generated agents, mutation suite, and parity checks
+ pass under the selected mode.
+- A stable task packet is resolved after recovery; no implementation-complete claim relies on the
+ pre-recovery state. Its overlay, output packet, packet checksum, and rule checksums are retained in
+ the recovery evidence directory.
+- Fresh checkout control-plane preflight reports no missing required path.
+- `.gitea/workflows` is absent and `.github/workflows` is recognized by the Gitea instance.
+- Repository API reports `has_actions: true`.
+- An authorized runner inventory reports an enabled online runner with the workflow label.
+- Gradle `projects`, dependency locks, architecture gates, focused tests, `test`, and `check` pass.
+- Missing or changed public-path baseline fails read-only verification.
+- Production and sample images build from repository-root context without a duplicated registry.
+- The quality `release-gate` and vulnerability status are required on the protected branch.
+- Release-blocking preflights run the full harness unit/mutation, module validation, renderer/parity,
+ gate-matrix, and structured Trivy checks; Trivy explicitly consumes `.trivyignore.yaml`.
+- Renovate automerge remains disabled until the explicit five-part re-enable condition is met.
+- README, gate matrix, workflow jobs, and physical repository paths agree.
+- No agent stages, commits, amends, or pushes the recovery.
+
+## 12. External Authorities
+
+- [Gitea Actions quick start](https://docs.gitea.com/usage/actions/quickstart): repository Actions
+ enablement, runner requirement, and the `.gitea/workflows` quick-start location.
+- [Gitea configuration cheat sheet](https://docs.gitea.com/administration/config-cheat-sheet):
+ `[actions].ENABLED` and the default
+ `WORKFLOW_DIRS=.gitea/workflows,.github/workflows` first-existing-directory behavior.
+- [Gitea runner documentation](https://docs.gitea.com/usage/actions/act-runner): repository-scoped
+ registration, runner modes, credential handling, and Docker isolation trade-offs.
diff --git a/docs/superpowers/specs/2026-07-25-module-gradle-hygiene-design.md b/docs/superpowers/specs/2026-07-25-module-gradle-hygiene-design.md
new file mode 100644
index 00000000..2f6dd846
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-25-module-gradle-hygiene-design.md
@@ -0,0 +1,389 @@
+# Module and Gradle Hygiene Refactoring Design
+
+- **Date:** 2026-07-25
+- **Status:** Approved
+- **Scope:** all 19 Gradle leaf modules, their project/external dependencies, test conventions,
+ architecture-analysis classpath, runtime composition, and dependency locks
+- **Source:** repository audit performed on 2026-07-25 against commit `821fe00`
+
+## 1. Prerequisite
+
+CI recovery is a hard prerequisite, not part of this refactoring. The implementation may start only
+after the repository again contains the harness registry and CI contract assets and these commands
+reach task execution:
+
+```bash
+cd src
+./gradlew projects --console=plain
+./gradlew :app-bootstrap:test --tests \
+ 'dev.caskeleton.bootstrap.contract.DeveloperExperienceContractTest' --console=plain
+./gradlew :app-bootstrap:test --tests \
+ 'dev.caskeleton.bootstrap.contract.SampleRemovalSmokeContractTest' --console=plain
+./gradlew verifyTrivyignore --console=plain
+```
+
+At audit time `settings.gradle` fails before project configuration because
+`.harness/project/modules.yaml` is absent. `.tool-versions`, `.trivyignore.yaml`, and the workflow
+files read by the two contract tests are absent as well. Dependency removal must not be mixed with
+that recovery because a red baseline cannot distinguish a pre-existing CI failure from a refactoring
+regression.
+
+## 2. Problem Statement
+
+The module direction is broadly clean, but the declared Gradle graph is wider than the source graph:
+many leaves declare every allowed core dependency even when they use only one contract. Pure-core
+tests inherit Spring MVC from a global convention. `application-core` imports SLF4J for one outbox
+use case and therefore carries the complete Spring Boot starter at compile and runtime. Several
+leaves retain unused Groovy, Spock, generated-stub, UUID, or configuration-processor dependencies.
+
+The existing central ArchUnit suite analyzes whatever happens to be on the
+`app-bootstrap` test runtime classpath. Optional leaves are therefore not guaranteed to be analyzed.
+The sample-isolation contract also carries a hard-coded subset of modules instead of reading the
+19-leaf registry. Locking is strict, but the lock verifier is not a release-gate dependency and
+non-BOM version ownership is scattered.
+
+This design reduces the graph only after characterization, makes topology and architecture coverage
+registry-driven, restores pure-core test isolation, and separates application logging intent from
+the logging framework.
+
+## 3. Evidence Classification
+
+### 3.1 Observed facts
+
+The following findings are deterministic observations and do not need dependency-removal debate:
+
+1. `src/settings.gradle` cannot configure without `.harness/project/modules.yaml`.
+2. There are exactly 19 leaf `build.gradle` files and 19 leaf `gradle.lockfile` files.
+3. No production configuration depends on `:sample-portfolio`;
+ `app-bootstrap` has one test-only `sampleFixture` edge.
+4. The only adapter-to-adapter project edges are:
+ `messaging`, `cache-redis`, `notification`, and `httpclient` to
+ `adapter:outbound:support`.
+5. `domain-core` and `shared-contract` main source contain no Spring, JPA, Jackson, or SLF4J imports.
+6. `application-core` main source contains no Spring import. Its only framework imports are SLF4J in
+ `PublishPendingOutboxEventsUseCase`.
+7. The root build adds Spring Boot test and Spring MVC test starters to every leaf.
+8. `cache-redis`, `messaging`, and `notification` have no Groovy tests although their builds apply
+ Groovy and add Spock.
+9. `adapter:outbound:identifier` does not use `uuid-creator`.
+10. `src/sample-portfolio/.jqwik-database` is a tracked Java-serialization runtime artifact.
+11. `persistence-mongo` owns adapter-local `Example*` domain/document/repository/mapper types and its
+ repository adapter implements no application/domain port.
+
+### 3.2 Static candidates
+
+The following are source-reference candidates, not approved removals. Each must first pass a
+leaf-specific compile/test characterization:
+
+| Leaf | Candidate project edges |
+| --- | --- |
+| `adapter:inbound:graphql` | `application-core`, `domain-core` |
+| `adapter:inbound:grpc` | `application-core`, `domain-core` |
+| `adapter:inbound:web` | `domain-core` |
+| `adapter:inbound:websocket` | `application-core`, `shared-contract` |
+| `adapter:outbound:cache-redis` | `domain-core`, `application-core` |
+| `adapter:outbound:httpclient` | `domain-core`, `application-core` |
+| `adapter:outbound:identifier` | `domain-core` |
+| `adapter:outbound:messaging` | `domain-core` |
+| `adapter:outbound:notification` | `domain-core` |
+| `adapter:outbound:persistence-jpa` | `domain-core` |
+| `adapter:outbound:persistence-mongo` | `application-core`, `shared-contract` |
+| `adapter:outbound:support` | `domain-core`, `application-core`, `shared-contract` |
+
+The same characterization rule applies to these external candidates:
+
+- GraphQL configuration processor and JSR-310 module.
+- gRPC protobuf/stub/annotations dependencies in the no-generated-stub skeleton.
+- broad `spring-boot-starter` usage in gRPC, fileserver, and objectstorage.
+- explicit Flyway core where the starter already supplies the required API.
+- duplicate starter/test declarations in app-bootstrap and sample-portfolio.
+
+An allowed registry edge is permission, not a requirement to declare that edge.
+
+## 4. Goals
+
+1. Keep all module paths and allowed edges in `.harness/project/modules.yaml` only.
+2. Make the actual project DAG the smallest graph required by source, tests, and runtime
+ composition.
+3. Preserve the approved outbox failure-reporting refactor's removal of Spring and logging
+ frameworks from `application-core` compile/runtime classpaths.
+4. Give `domain-core`, `application-core`, and `shared-contract` framework-free test conventions.
+5. Enforce external dependency purity for core modules from resolved compile/runtime graphs.
+6. Analyze every registered production leaf with the architecture suite regardless of runtime
+ composition.
+7. Apply the Spring configuration processor exactly where main source declares
+ `@ConfigurationProperties`.
+8. Verify strict locks in the release gate and assign one owner to every non-BOM version.
+9. State which optional adapters are in the default app runtime and which are opt-in.
+10. Remove generated jqwik state from source control.
+11. Remove the Mongo adapter-local example domain from production without creating a duplicate
+ sample implementation.
+
+## 5. Non-Goals
+
+- No feature behavior, endpoint, persistence schema, or public contract change.
+- No conversion to convention plugins, `buildSrc`, an included build, or a version catalog in this
+ change. Build-logic migration starts only from a green post-refactoring baseline.
+- No automatic inclusion of every optional adapter in the production runtime.
+- No new Mongo business port or second Mongo sample in `sample-portfolio`.
+- No relocation of the shared ThreadLocal implementation in this change.
+- No LLM Wiki write as part of this documentation-only design task.
+
+## 6. Target Topology and Registry Policy
+
+The registry remains the only topology authority. Every leaf entry must continue to own:
+
+- stable id
+- source path
+- Gradle path
+- role/family
+- allowed project dependencies
+- focused command
+- nearest module guidance
+
+Each runtime-capable leaf also receives one explicit runtime membership:
+
+- `core`: contract/core leaf consumed by registered adapters or bootstrap.
+- `app-default`: present on the default `app-bootstrap` runtime classpath.
+- `opt-in`: built and architecture-analyzed but absent from the default application runtime.
+- `sample-only`: used only by the sample fixture/runtime.
+- `composition-root`: `app-bootstrap` or `sample-portfolio` itself.
+
+The registry validator rejects missing membership, unknown dependency ids, duplicate Gradle paths,
+production edges to `sample-portfolio`, adapter peer edges not explicitly allowed by the source
+module's `allowed_dependencies`, and cycles. Gradle settings, the project-dependency verifier,
+sample-isolation checks, and architecture-analysis classpath all consume this data. No Java test
+keeps a copied module list.
+
+The current default runtime membership is preserved during graph cleanup. Optional adapters do not
+become runtime dependencies merely because architecture analysis needs their classes.
+
+## 7. Approved Application Logging Boundary
+
+The logging-boundary implementation is owned by
+`docs/superpowers/specs/2026-07-25-application-outbox-failure-reporting-design.md` and its matching
+implementation plan. That design is a prerequisite for dependency pruning in this plan and is not
+redefined here.
+
+The selected contract is:
+
+- `application-core` owns `OutboxRelayFailureReportPort`;
+- the port has one `report(OutboxRelayFailureReport)` method;
+- the safe immutable report carries the approved FAILED/DEAD operational fields and never carries
+ payload or idempotency data;
+- `adapter:outbound:messaging` owns the structured SLF4J reporter implementation;
+- `app-bootstrap` injects the port into the manually assembled relay use case;
+- application source and dependency guardrails prevent Spring and logging frameworks from returning
+ to `application-core`.
+
+Module hygiene begins only after that focused plan is green. This design then verifies the resulting
+application dependency purity and removes unrelated static-candidate edges; it does not introduce a
+second reporting port or relocate reporter ownership.
+
+## 8. Test Dependency Conventions
+
+Test dependencies are role-specific:
+
+| Role | Baseline |
+| --- | --- |
+| `domain-core` | JUnit Jupiter API/engine and AssertJ only when tests exist |
+| `application-core` | JUnit Jupiter, AssertJ; hand-written fakes; no Spring context |
+| `shared-contract` | JUnit Jupiter and AssertJ; no Spring context |
+| inbound web/GraphQL/WebSocket/gRPC | transport test modules required by that protocol only |
+| persistence adapters | mapping/unit baseline plus datastore Testcontainers only where vendor behavior is tested |
+| other outbound adapters | JUnit/Spock selected by actual test language; fake external systems |
+| `app-bootstrap` | Spring Boot context/slice support, ArchUnit, and integration-test dependencies |
+| `sample-portfolio` | feature, property, slice, and integration-test dependencies owned by the sample |
+
+The root build may supply JUnit platform launch/runtime configuration, but it must not supply Spring
+MVC or Spring context libraries to every leaf. A test dependency belongs in the leaf that uses it.
+
+## 9. External Dependency Purity Gate
+
+The logging-boundary plan first introduces `verifyApplicationCoreDependencyPurity`. This refactoring
+then replaces that task with the registry-wide `verifyExternalDependencyPurity`; the two tasks do
+not remain as overlapping gates. The replacement preserves the application main/test classpath
+rules and `:application-core:check` wiring, then resolves each registered leaf's production
+`compileClasspath` and `runtimeClasspath` and applies the broader role rules:
+
+- `domain-core` and `shared-contract`: no external production module at all.
+- `application-core`: no Spring, SLF4J/logging backend, JPA/Hibernate, servlet, transport, database,
+ cloud, or adapter implementation dependency.
+- inbound/outbound adapters: no logging implementation dependency; SLF4J API is allowed.
+- all production leaves: no test framework on production configurations.
+
+The task reports `module → configuration → forbidden coordinate → rule`. It checks resolved
+coordinates so transitive framework leakage is visible. Existing project-edge verification remains
+separate and registry-driven.
+
+Because the registry and harness API were absent at design time, the exact Python/Groovy/Java
+implementation of registry membership, purity/processor gates, and architecture classpath wiring is
+written in a post-recovery implementation addendum after the stable task packet is resolved. The
+addendum must contain complete code against the recovered API and pass review before any of those
+control-plane files are changed. Entry is fail-closed on a concrete overlay, an actual resolver
+invocation, matching overlay/packet content hashes, and the resolved rule hash; `--help` output or a
+prose-only confirmation is not packet evidence.
+
+## 10. Registry-Driven Architecture Analysis
+
+`app-bootstrap` gets an `architectureAnalysis` dependency bucket populated from every registered
+production leaf, independent of `app-default` runtime membership. The architecture test runtime
+extends this bucket; the application production runtime does not.
+
+`CleanArchitectureTest` therefore sees GraphQL, gRPC, WebSocket, fileserver, objectstorage, Mongo,
+and every other registered leaf. `SampleRemovalSmokeContractTest` reads production module paths from
+the same registry instead of its current hard-coded list.
+
+The architecture configuration is non-consumable and non-resolvable itself; only the dedicated test
+runtime is resolvable. This prevents it from being published or accidentally used by `bootJar`.
+
+## 11. Configuration Processor Consistency
+
+The rule is mechanical:
+
+- a leaf with main-source `@ConfigurationProperties` declares
+ `annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'`;
+- a leaf without it does not.
+
+A verification task scans registered main source roots and compares the result with the declared
+annotation-processor dependency. This removes the unused GraphQL processor and adds missing
+processors to settings-owning leaves without relying on a copied module list.
+
+Binding/validation tests remain required for every settings class; generated metadata is not a
+substitute for behavior tests. The post-recovery control-plane addendum inventories that mapping and
+contains complete focused test code for every uncovered class before processor declarations change.
+
+## 12. Runtime Composition and Component Scanning
+
+`app-bootstrap` keeps only registry members marked `app-default` on its production runtime.
+`opt-in` modules remain independently buildable and architecture-analyzed. Adoption of an opt-in
+module is an explicit registry and composition-root change with its own focused tests.
+
+`CaSkeletonApplication` narrows component and configuration-properties scanning to:
+
+- `dev.caskeleton.bootstrap`
+- `dev.caskeleton.adapter`
+
+It removes `dev.caskeleton.application`, `dev.caskeleton.domain`, and `dev.caskeleton.shared` from
+both scans. Those core packages own no Spring component or configuration-properties class, and the
+outbox use case remains manually composed. A context test pins this boundary.
+
+This is the smallest safe scan change in this refactoring. Converting all adapter configuration to
+explicit `@Import` or auto-configuration is a separate design change.
+
+## 13. Leaf-Specific Cleanup
+
+All 19 leaves receive a characterization record and focused command:
+
+| Leaf | Target decision |
+| --- | --- |
+| `domain-core` | preserve zero-framework main; isolate pure tests |
+| `application-core` | consume the approved outbox-reporting result; verify Boot/SLF4J remain absent |
+| `shared-contract` | preserve stdlib-only production graph |
+| `adapter:inbound:web` | remove only compile-proven unused core edge; retain transport dependencies |
+| `adapter:inbound:graphql` | remove compile-proven core/tooling candidates |
+| `adapter:inbound:grpc` | retain server/health/reflection runtime; remove no-stub candidates only after compile |
+| `adapter:inbound:websocket` | retain domain-event and WebSocket dependencies; prune unused core edges |
+| `adapter:outbound:support` | retain only compile-proven core edges and SLF4J API/autoconfigure |
+| `adapter:outbound:cache-redis` | remove unused core edges and unused Groovy/Spock |
+| `adapter:outbound:fileserver` | keep application/shared ports; narrow starter only after characterization |
+| `adapter:outbound:httpclient` | keep shared/support and actual Groovy/Spock tests; prune unused core edges |
+| `adapter:outbound:identifier` | keep application pseudonymizer port; remove unused domain/uuid-creator |
+| `adapter:outbound:messaging` | retain the approved outbox reporter plus application/shared/support; remove unused domain and Groovy/Spock |
+| `adapter:outbound:notification` | keep application/shared/support; remove unused domain and Groovy/Spock |
+| `adapter:outbound:objectstorage` | keep application/shared/AWS SDK; narrow starter only after characterization |
+| `adapter:outbound:persistence-jpa` | keep application/shared/JPA/vendor runtime; test domain/Flyway candidates |
+| `adapter:outbound:persistence-mongo` | remove all adapter-local `Example*` types/tests; keep generic opt-in config/properties and binding/disabled-mode tests; remove application/shared edges |
+| `app-bootstrap` | preserve composition role; separate architecture classpath; narrow scans and duplicate tests |
+| `sample-portfolio` | preserve fixture-only isolation; remove generated jqwik state and own sample-only dependencies |
+
+## 14. Generated jqwik State
+
+`src/sample-portfolio/.jqwik-database` is deleted from version control.
+`src/.gitignore` ignores `.jqwik-database` at any module working directory. Property tests remain
+deterministic from committed seeds/configuration rather than a developer-machine serialization
+cache.
+
+## 15. Shared ThreadLocal Decision
+
+`ThreadLocalDomainContextPropagator` and `DomainContextPropagatorFactory` stay in
+`shared-contract` for this refactoring. They are Java-stdlib-only operational infrastructure, and
+moving them changes concurrency composition rather than dependency hygiene.
+
+This is a deliberate secondary decision, not an accidental omission. The purity gate pins their
+zero-external-dependency status. Relocation to bootstrap or an adapter requires a separate design
+with virtual-thread/context-propagation characterization and is not bundled into graph cleanup.
+
+## 16. Persistence Mongo Decision
+
+The production Mongo leaf removes:
+
+- `ExampleRecord`
+- `ExampleMongoDocument`
+- `ExampleMongoMapper`
+- `ExampleMongoRepository`
+- `ExampleMongoRepositoryAdapter`
+- their example mapping/repository tests
+
+`MongoPersistenceConfig` and `MongoPersistenceProperties` remain as generic opt-in Spring Mongo
+machinery. Tests cover properties binding, disabled-by-default behavior, and mock-backed
+enabled-mode creation of one Boot 4 `MongoClient` and `MongoTemplate` without a network connection
+or sample repository.
+
+No duplicate Mongo domain is added to `sample-portfolio`; the WorkLog JPA sample remains the sole
+reference business domain. The Mongo leaf then removes its unused application/shared project edges.
+
+## 17. Locking and Version Ownership
+
+After each leaf cleanup:
+
+1. run its compile and focused test;
+2. regenerate its lock state through the repository `resolveAndLockAll --write-locks` entrypoint;
+3. run `verifyDependencyLocks`;
+4. inspect that removed coordinates disappeared from production configurations.
+
+`check` or the CI release gate invokes `verifyDependencyLocks`. Spring Boot BOM owns managed Spring,
+Jackson, Micrometer, Testcontainers, and related versions. Existing root extension values own gRPC,
+protobuf, and AWS BOM versions. Every remaining non-BOM direct version has one root-level owner.
+
+The build remains Groovy DSL with the current root configuration during this work. A version catalog
+or convention-plugin migration is considered only after the complete refactoring and full `check`
+are green, so build-system migration cannot mask dependency-removal regressions.
+
+## 18. Verification Strategy
+
+Verification proceeds from narrow to broad:
+
+1. CI/harness prerequisite commands.
+2. Registry schema, cycle, membership, and 19-leaf parity tests.
+3. Before/after dependency reports for each candidate leaf.
+4. Leaf `compileJava`, focused test, and configuration-processor check.
+5. Pure-core external dependency gate.
+6. Registry-driven project dependency and architecture tests.
+7. Sample-off and optional-runtime composition tests.
+8. Dependency lock verification.
+9. Full `test` and `check`.
+
+No removal is accepted when a focused command is skipped without a named environmental reason and
+recorded residual risk.
+
+## 19. Acceptance Criteria
+
+- All four prerequisite commands pass before hygiene edits begin.
+- Registry validation reports exactly 19 unique, acyclic leaves and owns runtime membership.
+- No copied production-module list remains in architecture or sample-isolation tests.
+- The approved outbox failure-reporting plan is green before hygiene pruning starts.
+- `application-core` production dependencies continue to contain no Spring or logging coordinate.
+- Domain, application, and shared tests run without Spring MVC/context dependencies.
+- Every production leaf is present on the architecture-analysis test runtime.
+- No production configuration depends on `sample-portfolio`.
+- Every adapter peer edge is explicitly allowed by the source module's registry entry; the current
+ graph's peer edges all target `adapter:outbound:support`.
+- Configuration processor declarations exactly match main-source properties classes.
+- Every removed project/external dependency has before/after compile and focused-test evidence.
+- Mongo production source contains no `Example*` domain/document/repository/mapper type.
+- `.jqwik-database` is untracked and ignored.
+- `verifyExternalDependencyPurity`, `verifyCleanArchitectureDependencies`, architecture tests,
+ `verifyDependencyLocks`, `test`, and `check` pass.
+- No convention-plugin or version-catalog migration is included.
+- Agents do not stage, commit, amend, or push; commit policy remains human-only.