Files
tech-log-backend/docs/superpowers/plans/2026-07-25-application-outbox-failure-reporting.md

58 KiB

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:

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:

cd src
./gradlew help --console=plain

Expected after recovery:

BUILD SUCCESSFUL

Do not proceed when the output contains Missing module registry.

  • Gate 3: Record the human-owned baseline without changing it

Run:

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:

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:

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:

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.
 *
 * <p>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:

package dev.caskeleton.application.outbox;

/**
 * Outbound port for reporting a confirmed FAILED or DEAD outbox relay transition.
 *
 * <p>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:

./gradlew :application-core:test \
  --tests 'dev.caskeleton.application.outbox.OutboxRelayFailureReportTest' \
  --console=plain

Expected:

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:

private RecordingOutboxRelayFailureReportPort failureReports;

Initialize and inject it in setUp():

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:

static final class RecordingOutboxRelayFailureReportPort
    implements OutboxRelayFailureReportPort {

  final List<OutboxRelayFailureReport> reports = new ArrayList<>();
  java.util.function.Consumer<OutboxRelayFailureReport> 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:

@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:

import dev.caskeleton.shared.error.OperationalError;

For both existing tests that directly construct the use case, markPublishedFailurePropagatesAndDoesNotMisclassifyAsPublishFailure and markPublishedFailureAbortsRemainingBatchForCurrentTick, insert failureReports immediately after publishPort:

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:

assertThat(failureReports.reports).isEmpty();
  • Step 3: Add a transition-failure fake and red test

Add this complete fake:

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:

@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:

./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:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Remove the static logger field. Add:

private final OutboxRelayFailureReportPort failureReports;

Use this constructor signature and assignment:

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:

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:

./gradlew :application-core:test \
  --tests 'dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCaseTest' \
  --tests 'dev.caskeleton.application.outbox.OutboxRelayFailureReportTest' \
  --console=plain

Expected:

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:

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<ILoggingEvent> 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<String, String> 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<String, String> 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:

./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:

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:

./gradlew :adapter:outbound:messaging:test \
  --tests 'dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapterTest' \
  --console=plain

Expected:

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:

import dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter;
import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;

In the disabled-default assertion block add:

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:

./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:

import dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter;
import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;

Add this bean:

@Bean
public OutboxRelayFailureReportPort outboxRelayFailureReportPort(MessagingSettings settings) {
  return new Slf4jOutboxRelayFailureReportAdapter(settings.broker());
}

Change outboxMessagePublishPort to:

@Bean
public OutboxMessagePublishPort outboxMessagePublishPort(
    ObjectProvider<MessageBroker> 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:

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:

import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;

Add the parameter immediately after OutboxMessagePublishPort publishPort:

OutboxRelayFailureReportPort failureReports,

Pass it in the manual constructor:

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:

report -> {}

In both direct constructors in OutboxRowLifecycleContractTest, insert the same test-only lambda after the publisher argument:

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:

./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:

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:

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<String> productionConfigurations = ['api', 'implementation', 'compileOnly', 'runtimeOnly']
        Set<String> 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<Boolean> 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<String> classpathConfigurations = [
                'compileClasspath',
                'runtimeClasspath',
                'testCompileClasspath',
                'testRuntimeClasspath'
        ]
        Set<String> 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:

./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:

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:

// 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:

./gradlew :application-core:resolveAndLockAll --write-locks --console=plain

Expected:

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:

./gradlew verifyApplicationCoreDependencyPurity --console=plain
./gradlew :application-core:verifyDependencyLocks --console=plain
./gradlew :application-core:test --console=plain

Expected for all commands:

BUILD SUCCESSFUL

Expected purity lifecycle line:

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:

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:

import dev.caskeleton.bootstrap.architecture.violations.application.LoggerUsingApplicationFixture;
private static final JavaClasses LOGGER_USING_APPLICATION_FIXTURE_ONLY =
    new ClassFileImporter().importClasses(LoggerUsingApplicationFixture.class);

Add this test:

@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:

./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:

@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:

./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:

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:

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

| `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:

- 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:

- Implement `OutboxRelayFailureReportPort` as the single structured ERROR renderer for confirmed
  outbox FAILED/DEAD transitions.

Update src/adapter/outbound/messaging/README.md with:

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

- 코드: `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:

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:

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:

cd src
./gradlew \
  :application-core:spotlessCheck \
  :adapter:outbound:messaging:spotlessCheck \
  :app-bootstrap:spotlessCheck \
  --console=plain

Expected:

BUILD SUCCESSFUL

If formatting fails, run the repository formatter only on the affected modules:

./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:

./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:

BUILD SUCCESSFUL
  • Step 3: Run the real PostgreSQL outbox lifecycle contract when Docker is available

Run:

./gradlew :app-bootstrap:test \
  --tests 'dev.caskeleton.bootstrap.integration.outbox.OutboxRowLifecycleContractTest' \
  --console=plain

Expected with a reachable Docker daemon:

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:

./gradlew verifyApplicationCoreDependencyPurity --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :application-core:verifyDependencyLocks --console=plain

Expected:

BUILD SUCCESSFUL
  • Step 5: Prove the forbidden dependencies are absent

Run:

./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:

No dependencies matching given input were found
BUILD SUCCESSFUL
  • Step 6: Run full tests and checks

Run:

./gradlew test --console=plain
./gradlew check --console=plain

Expected:

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:

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:

/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:

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