# 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.