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.javasrc/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.javasrc/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.javasrc/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.javasrc/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.javasrc/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java
Modified documentation
src/application-core/CLAUDE.mdsrc/application-core/README.mdsrc/adapter/outbound/messaging/CLAUDE.mdsrc/adapter/outbound/messaging/README.mddocs/runbooks/outbox-publish-failed.mddocs/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)withnew 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
OutboxRelayFailureReportPortandSlf4jOutboxRelayFailureReportAdapteras the canonical reporting path; - retain
error.code,event_id,event_type,correlation_id, andrunbook_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 --checkexits0;- 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.