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.