# Application Outbox Failure Reporting Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove Spring/logging framework coupling from `application-core` and report confirmed
outbox `FAILED`/`DEAD` transitions through a safe typed port implemented by the messaging adapter.
**Architecture:** `application-core` owns `OutboxRelayFailureReportPort` and an allowlisted,
immutable-shape report. `adapter:outbound:messaging` renders the report as one structured SLF4J
ERROR, while `app-bootstrap` only injects the port into the manually constructed relay.
Persistence transitions remain authoritative; reporting is attempted afterward and can never
change the relay outcome.
**Tech Stack:** Java 21, Spring Boot 4.0.0 at adapter/bootstrap boundaries, Gradle multi-project
build, JUnit Jupiter 6, AssertJ, ArchUnit, SLF4J 2 fluent key-value logging, Logback test appenders.
**Spec:** `docs/superpowers/specs/2026-07-25-application-outbox-failure-reporting-design.md`
**Working policy:** Commits are human-only. Agentic workers do not stage, commit, amend, or push.
Each task leaves reviewed changes in the working tree.
---
## Prerequisite Gate
The current checkout cannot configure Gradle because `.harness/project/modules.yaml` is absent.
Complete the independently governed harness-registry recovery before starting Task 1. Execute this
plan only from a controller turn that has resolved one stable task packet after recovery and retains
its packet/rule hashes for all tasks.
- [ ] **Gate 1: Confirm the module registry and task resolver exist**
Run from the repository root:
```bash
test -f .harness/project/modules.yaml
test -f .harness/validators/resolve_task.py
```
Expected after recovery: both commands exit `0` with no output. The current unrecovered checkout
exits `1`.
- [ ] **Gate 2: Confirm Gradle can evaluate settings**
Run:
```bash
cd src
./gradlew help --console=plain
```
Expected after recovery:
```text
BUILD SUCCESSFUL
```
Do not proceed when the output contains `Missing module registry`.
- [ ] **Gate 3: Record the human-owned baseline without changing it**
Run:
```bash
git status --short --branch
```
Expected: the controller records all pre-existing changes and preserves them. No task in this plan
uses a destructive Git command.
## File Map
### New files
- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReport.java`
— safe immutable application value with retryable/dead-letter factories.
- `src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportPort.java`
— non-throwing outbound reporting contract.
- `src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportTest.java`
— value invariants and privacy surface.
- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java`
— structured SLF4J adapter.
- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java`
— ERROR field, cause, and privacy contract.
- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/LoggerUsingApplicationFixture.java`
— intentional ArchUnit mutation.
### Modified production/build files
- `src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java`
— replace direct logger calls with the typed port.
- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java`
— remove misleading fail-open logging and retain fail-closed propagation.
- `src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java`
— bind the reporter and simplify outbox publisher construction.
- `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java`
— inject and pass the reporter port.
- `src/application-core/build.gradle`
— remove the Boot starter.
- `src/build.gradle`
— give application-core a pure JUnit/AssertJ test baseline and add dependency purity verification.
- `src/application-core/gradle.lockfile`
— regenerate after dependency removal.
- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`
— ban logger/metrics frameworks from application packages.
### Modified tests and support
- `src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java`
- `src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java`
- `src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java`
- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java`
- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java`
- `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java`
### Modified documentation
- `src/application-core/CLAUDE.md`
- `src/application-core/README.md`
- `src/adapter/outbound/messaging/CLAUDE.md`
- `src/adapter/outbound/messaging/README.md`
- `docs/runbooks/outbox-publish-failed.md`
- `docs/runbooks/outbox-dead-letter.md`
## Task 1: Safe Application Failure Report Contract
**Files:**
- Create:
`src/application-core/src/test/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportTest.java`
- Create:
`src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReport.java`
- Create:
`src/application-core/src/main/java/dev/caskeleton/application/outbox/OutboxRelayFailureReportPort.java`
- [ ] **Step 1: Write the failing value-contract test**
Create the complete test:
```java
package dev.caskeleton.application.outbox;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.shared.error.OperationalError;
import java.time.Instant;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
class OutboxRelayFailureReportTest {
private static final RuntimeException CAUSE = new RuntimeException("broker unavailable");
@Test
void retryableFailureCarriesOnlySafeOperationalFields() {
Instant nextAttemptAt = Instant.parse("2026-07-25T01:02:03Z");
OutboxRelayFailureReport report =
OutboxRelayFailureReport.retryableFailure(
"evt-1", "WorkLogReserved", "worklog-1", "corr-1", 1, nextAttemptAt, CAUSE);
assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_PUBLISH_FAILED);
assertThat(report.eventId()).isEqualTo("evt-1");
assertThat(report.eventType()).isEqualTo("WorkLogReserved");
assertThat(report.aggregateId()).isEqualTo("worklog-1");
assertThat(report.correlationId()).isEqualTo("corr-1");
assertThat(report.attemptCount()).isEqualTo(1);
assertThat(report.nextAttemptAt()).isEqualTo(nextAttemptAt);
assertThat(report.cause()).isSameAs(CAUSE);
}
@Test
void deadLetterHasNoNextAttempt() {
OutboxRelayFailureReport report =
OutboxRelayFailureReport.deadLetter(
"evt-2", "WorkLogReserved", "worklog-2", "corr-2", 3, CAUSE);
assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_DEAD_LETTER);
assertThat(report.nextAttemptAt()).isNull();
}
@Test
void recordSurfaceCannotCarryPayloadOrIdempotencyKey() {
assertThat(
Arrays.stream(OutboxRelayFailureReport.class.getRecordComponents())
.map(component -> component.getName())
.toList())
.containsExactly(
"code",
"eventId",
"eventType",
"aggregateId",
"correlationId",
"attemptCount",
"nextAttemptAt",
"cause")
.doesNotContain("payload", "idempotencyKey");
}
@Test
void retryableFailureRequiresNextAttempt() {
assertThatThrownBy(
() ->
new OutboxRelayFailureReport(
OperationalError.OUTBOX_PUBLISH_FAILED,
"evt-1",
"Event",
"agg-1",
"corr-1",
1,
null,
CAUSE))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("nextAttemptAt");
}
@Test
void deadLetterRejectsNextAttempt() {
assertThatThrownBy(
() ->
new OutboxRelayFailureReport(
OperationalError.OUTBOX_DEAD_LETTER,
"evt-1",
"Event",
"agg-1",
"corr-1",
3,
Instant.parse("2026-07-25T01:02:03Z"),
CAUSE))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("DEAD");
}
@Test
void unsupportedOperationalCodeIsRejected() {
assertThatThrownBy(
() ->
new OutboxRelayFailureReport(
OperationalError.INTERNAL_ERROR,
"evt-1",
"Event",
"agg-1",
"corr-1",
1,
null,
CAUSE))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("outbox failure code");
}
@Test
void blankMetadataAndNonPositiveAttemptAreRejected() {
assertThatThrownBy(
() ->
OutboxRelayFailureReport.deadLetter(
" ", "Event", "agg-1", "corr-1", 1, CAUSE))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("eventId");
assertThatThrownBy(
() ->
OutboxRelayFailureReport.deadLetter(
"evt-1", "Event", "agg-1", "corr-1", 0, CAUSE))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("attemptCount");
}
}
```
- [ ] **Step 2: Run the test to verify the red state**
Run:
```bash
cd src
./gradlew :application-core:test \
--tests 'dev.caskeleton.application.outbox.OutboxRelayFailureReportTest' \
--console=plain
```
Expected: `compileTestJava` fails because `OutboxRelayFailureReport` does not exist.
- [ ] **Step 3: Implement the safe immutable report**
Create the complete value:
```java
package dev.caskeleton.application.outbox;
import dev.caskeleton.shared.error.OperationalError;
import java.time.Instant;
import java.util.Objects;
/**
* Safe operational description of a confirmed outbox FAILED or DEAD transition.
*
*
The value deliberately excludes payload and idempotency data. Logging severity, field names,
* and rendering belong to the outbound adapter.
*/
public record OutboxRelayFailureReport(
OperationalError code,
String eventId,
String eventType,
String aggregateId,
String correlationId,
int attemptCount,
Instant nextAttemptAt,
RuntimeException cause) {
public OutboxRelayFailureReport {
Objects.requireNonNull(code, "code must not be null");
eventId = requireText(eventId, "eventId");
eventType = requireText(eventType, "eventType");
aggregateId = requireText(aggregateId, "aggregateId");
correlationId = requireText(correlationId, "correlationId");
Objects.requireNonNull(cause, "cause must not be null");
if (attemptCount < 1) {
throw new IllegalArgumentException("attemptCount must be >= 1, was " + attemptCount);
}
if (code == OperationalError.OUTBOX_PUBLISH_FAILED) {
Objects.requireNonNull(
nextAttemptAt, "nextAttemptAt must not be null for OUTBOX_PUBLISH_FAILED");
} else if (code == OperationalError.OUTBOX_DEAD_LETTER) {
if (nextAttemptAt != null) {
throw new IllegalArgumentException("DEAD outbox report must not have nextAttemptAt");
}
} else {
throw new IllegalArgumentException("unsupported outbox failure code: " + code);
}
}
public static OutboxRelayFailureReport retryableFailure(
String eventId,
String eventType,
String aggregateId,
String correlationId,
int attemptCount,
Instant nextAttemptAt,
RuntimeException cause) {
return new OutboxRelayFailureReport(
OperationalError.OUTBOX_PUBLISH_FAILED,
eventId,
eventType,
aggregateId,
correlationId,
attemptCount,
nextAttemptAt,
cause);
}
public static OutboxRelayFailureReport deadLetter(
String eventId,
String eventType,
String aggregateId,
String correlationId,
int attemptCount,
RuntimeException cause) {
return new OutboxRelayFailureReport(
OperationalError.OUTBOX_DEAD_LETTER,
eventId,
eventType,
aggregateId,
correlationId,
attemptCount,
null,
cause);
}
private static String requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be null or blank");
}
return value;
}
}
```
- [ ] **Step 4: Implement the typed outbound port**
Create the complete port:
```java
package dev.caskeleton.application.outbox;
/**
* Outbound port for reporting a confirmed FAILED or DEAD outbox relay transition.
*
*
Implementations must not throw. Persistence state and {@link OutboxRelayResult} are
* authoritative; operational reporting must not rewrite or interrupt relay processing.
*/
@FunctionalInterface
public interface OutboxRelayFailureReportPort {
void report(OutboxRelayFailureReport report);
}
```
- [ ] **Step 5: Run the focused value test**
Run:
```bash
./gradlew :application-core:test \
--tests 'dev.caskeleton.application.outbox.OutboxRelayFailureReportTest' \
--console=plain
```
Expected:
```text
BUILD SUCCESSFUL
```
## Task 2: Relay Uses the Typed Port Without Changing Outcomes
**Files:**
- Modify:
`src/application-core/src/test/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCaseTest.java`
- Modify:
`src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java`
- [ ] **Step 1: Add a recording reporter to the existing test fixture**
Add this field beside the existing fake ports:
```java
private RecordingOutboxRelayFailureReportPort failureReports;
```
Initialize and inject it in `setUp()`:
```java
failureReports = new RecordingOutboxRelayFailureReportPort();
useCase =
new PublishPendingOutboxEventsUseCase(
store,
publishPort,
failureReports,
tx,
backoffPolicy,
clock,
BATCH_SIZE,
IN_FLIGHT_TIMEOUT);
```
Add this complete fake at the bottom of the test class:
```java
static final class RecordingOutboxRelayFailureReportPort
implements OutboxRelayFailureReportPort {
final List reports = new ArrayList<>();
java.util.function.Consumer onReport = report -> {};
@Override
public void report(OutboxRelayFailureReport report) {
onReport.accept(report);
reports.add(report);
}
}
```
- [ ] **Step 2: Add red tests for ordering, false reports, and reporter isolation**
Add these test methods:
```java
@Test
void transientFailureReportsOnlyAfterFailedTransitionCommits() {
OutboxEvent event =
makeEvent("evt-report-failed", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
RuntimeException cause = new RuntimeException("broker down");
store.addClaimable(event);
publishPort.failOn(event.eventId(), cause);
failureReports.onReport =
report -> assertThat(store.failedEvents).containsKey(event.eventId());
OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
assertThat(result.outcomes().getFirst().outcome())
.isEqualTo(OutboxRelayResult.Outcome.FAILED);
assertThat(failureReports.reports).singleElement().satisfies(
report -> {
assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_PUBLISH_FAILED);
assertThat(report.eventId()).isEqualTo(event.eventId());
assertThat(report.eventType()).isEqualTo(event.eventType());
assertThat(report.aggregateId()).isEqualTo(event.aggregateId());
assertThat(report.correlationId()).isEqualTo(event.correlationId());
assertThat(report.attemptCount()).isEqualTo(event.attemptCount());
assertThat(report.nextAttemptAt()).isEqualTo(store.failedEvents.get(event.eventId()));
assertThat(report.cause()).isSameAs(cause);
});
}
@Test
void deadLetterReportsOnlyAfterDeadTransitionCommits() {
OutboxEvent event =
makeEvent("evt-report-dead", "UserCreated", "agg-1", NOW.minusSeconds(60), 3);
RuntimeException cause = new RuntimeException("broker still down");
store.addClaimable(event);
publishPort.failOn(event.eventId(), cause);
failureReports.onReport =
report -> assertThat(store.deadEvents).contains(event.eventId());
OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
assertThat(result.outcomes().getFirst().outcome())
.isEqualTo(OutboxRelayResult.Outcome.DEAD);
assertThat(failureReports.reports).singleElement().satisfies(
report -> {
assertThat(report.code()).isEqualTo(OperationalError.OUTBOX_DEAD_LETTER);
assertThat(report.nextAttemptAt()).isNull();
assertThat(report.cause()).isSameAs(cause);
});
}
@Test
void successfulPublishDoesNotReportPublishFailure() {
OutboxEvent success =
makeEvent("evt-success", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
store.addClaimable(success);
useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
assertThat(failureReports.reports).isEmpty();
}
@Test
void reporterExceptionDoesNotChangeOutcomeOrStopNextEvent() {
OutboxEvent failed =
makeEvent("evt-report-throws", "UserCreated", "agg-1", NOW.minusSeconds(120), 1);
OutboxEvent succeeds =
makeEvent("evt-after-report", "UserUpdated", "agg-2", NOW.minusSeconds(60), 1);
store.addClaimable(failed);
store.addClaimable(succeeds);
publishPort.failOn(failed.eventId(), new RuntimeException("broker down"));
failureReports.onReport = report -> {
throw new IllegalStateException("reporter unavailable");
};
OutboxRelayResult result = useCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
assertThat(result.outcomes())
.extracting(OutboxRelayResult.EventOutcome::outcome)
.containsExactly(OutboxRelayResult.Outcome.FAILED, OutboxRelayResult.Outcome.PUBLISHED);
assertThat(store.failedEvents).containsKey(failed.eventId());
assertThat(store.publishedEvents).contains(succeeds.eventId());
}
```
Add this import:
```java
import dev.caskeleton.shared.error.OperationalError;
```
For both existing tests that directly construct the use case,
`markPublishedFailurePropagatesAndDoesNotMisclassifyAsPublishFailure` and
`markPublishedFailureAbortsRemainingBatchForCurrentTick`, insert `failureReports` immediately after
`publishPort`:
```java
new PublishPendingOutboxEventsUseCase(
throwingStore,
publishPort,
failureReports,
tx,
backoffPolicy,
clock,
BATCH_SIZE,
IN_FLIGHT_TIMEOUT)
```
Add this assertion to both tests after the existing no-misclassification assertions:
```java
assertThat(failureReports.reports).isEmpty();
```
- [ ] **Step 3: Add a transition-failure fake and red test**
Add this complete fake:
```java
static final class ThrowingOnMarkFailedStorePort extends FakeOutboxStorePort {
private final RuntimeException failure;
ThrowingOnMarkFailedStorePort(RuntimeException failure) {
this.failure = failure;
}
@Override
public void markFailed(String eventId, Instant nextAttemptAt) {
throw failure;
}
}
static final class ThrowingOnMarkDeadStorePort extends FakeOutboxStorePort {
private final RuntimeException failure;
ThrowingOnMarkDeadStorePort(RuntimeException failure) {
this.failure = failure;
}
@Override
public void markDead(String eventId) {
throw failure;
}
}
```
If `FakeOutboxStorePort` is currently `final`, remove only that `final` modifier. Add both tests:
```java
@Test
void failedTransitionFailurePropagatesWithoutFalseReport() {
OutboxEvent event =
makeEvent("evt-store-failed", "UserCreated", "agg-1", NOW.minusSeconds(60), 1);
ThrowingOnMarkFailedStorePort throwingStore =
new ThrowingOnMarkFailedStorePort(new RuntimeException("DB down on markFailed"));
throwingStore.addClaimable(event);
publishPort.failOn(event.eventId(), new RuntimeException("broker down"));
PublishPendingOutboxEventsUseCase useCaseWithThrowingStore =
new PublishPendingOutboxEventsUseCase(
throwingStore,
publishPort,
failureReports,
tx,
backoffPolicy,
clock,
BATCH_SIZE,
IN_FLIGHT_TIMEOUT);
assertThatThrownBy(
() -> useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE))
.isInstanceOf(RuntimeException.class)
.hasMessage("DB down on markFailed");
assertThat(failureReports.reports).isEmpty();
}
@Test
void deadTransitionFailurePropagatesWithoutFalseReport() {
OutboxEvent event =
makeEvent("evt-store-dead", "UserCreated", "agg-1", NOW.minusSeconds(60), 3);
ThrowingOnMarkDeadStorePort throwingStore =
new ThrowingOnMarkDeadStorePort(new RuntimeException("DB down on markDead"));
throwingStore.addClaimable(event);
publishPort.failOn(event.eventId(), new RuntimeException("broker down"));
PublishPendingOutboxEventsUseCase useCaseWithThrowingStore =
new PublishPendingOutboxEventsUseCase(
throwingStore,
publishPort,
failureReports,
tx,
backoffPolicy,
clock,
BATCH_SIZE,
IN_FLIGHT_TIMEOUT);
assertThatThrownBy(
() -> useCaseWithThrowingStore.handle(PublishPendingOutboxEventsCommand.INSTANCE))
.isInstanceOf(RuntimeException.class)
.hasMessage("DB down on markDead");
assertThat(failureReports.reports).isEmpty();
}
```
- [ ] **Step 4: Run the relay test to verify the red state**
Run:
```bash
./gradlew :application-core:test \
--tests 'dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCaseTest' \
--console=plain
```
Expected: `compileTestJava` fails because the use-case constructor does not accept
`OutboxRelayFailureReportPort`; after a temporary constructor adjustment, behavioral tests still
fail because no report is emitted.
- [ ] **Step 5: Replace SLF4J with the typed reporter in the use case**
Remove:
```java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
```
Remove the static logger field. Add:
```java
private final OutboxRelayFailureReportPort failureReports;
```
Use this constructor signature and assignment:
```java
public PublishPendingOutboxEventsUseCase(
OutboxStorePort store,
OutboxMessagePublishPort publishPort,
OutboxRelayFailureReportPort failureReports,
TransactionPort tx,
OutboxBackoffPolicy backoffPolicy,
Clock clock,
int batchSize,
Duration inFlightTimeout) {
this.store = Objects.requireNonNull(store, "store must not be null");
this.publishPort = Objects.requireNonNull(publishPort, "publishPort must not be null");
this.failureReports =
Objects.requireNonNull(failureReports, "failureReports must not be null");
this.tx = Objects.requireNonNull(tx, "tx must not be null");
this.backoffPolicy = Objects.requireNonNull(backoffPolicy, "backoffPolicy must not be null");
this.clock = Objects.requireNonNull(clock, "clock must not be null");
if (batchSize <= 0) {
throw new IllegalArgumentException("batchSize must be > 0, was " + batchSize);
}
this.batchSize = batchSize;
this.inFlightTimeout =
Objects.requireNonNull(inFlightTimeout, "inFlightTimeout must not be null");
}
```
Replace `handlePublishFailure` with:
```java
private OutboxRelayResult.Outcome handlePublishFailure(
OutboxEvent event, Instant now, RuntimeException cause) {
if (event.attemptCount() >= backoffPolicy.maxAttempts()) {
tx.inWrite(() -> store.markDead(event.eventId()));
reportWithoutChangingOutcome(
OutboxRelayFailureReport.deadLetter(
event.eventId(),
event.eventType(),
event.aggregateId(),
event.correlationId(),
event.attemptCount(),
cause));
return OutboxRelayResult.Outcome.DEAD;
}
Instant nextAttemptAt = backoffPolicy.nextAttemptAt(event.attemptCount(), now);
tx.inWrite(() -> store.markFailed(event.eventId(), nextAttemptAt));
reportWithoutChangingOutcome(
OutboxRelayFailureReport.retryableFailure(
event.eventId(),
event.eventType(),
event.aggregateId(),
event.correlationId(),
event.attemptCount(),
nextAttemptAt,
cause));
return OutboxRelayResult.Outcome.FAILED;
}
private void reportWithoutChangingOutcome(OutboxRelayFailureReport report) {
try {
failureReports.report(report);
} catch (RuntimeException ignored) {
// The persisted FAILED/DEAD transition is authoritative. Outbox metrics still expose the
// outcome even when a custom reporter violates its non-throwing contract.
}
}
```
- [ ] **Step 6: Run the relay and contract tests**
Run:
```bash
./gradlew :application-core:test \
--tests 'dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCaseTest' \
--tests 'dev.caskeleton.application.outbox.OutboxRelayFailureReportTest' \
--console=plain
```
Expected:
```text
BUILD SUCCESSFUL
```
## Task 3: Structured Messaging Reporter Adapter
**Files:**
- Create:
`src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapterTest.java`
- Create:
`src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/Slf4jOutboxRelayFailureReportAdapter.java`
- [ ] **Step 1: Write the failing adapter contract test**
Create the complete test:
```java
package dev.caskeleton.adapter.outbound.messaging.outbox;
import static org.assertj.core.api.Assertions.assertThat;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import dev.caskeleton.application.outbox.OutboxRelayFailureReport;
import java.time.Instant;
import java.util.Map;
import java.util.stream.Collectors;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
class Slf4jOutboxRelayFailureReportAdapterTest {
private final ch.qos.logback.classic.Logger logger =
(ch.qos.logback.classic.Logger) LoggerFactory.getLogger("test.outbox.failure-report");
private ListAppender appender;
private Slf4jOutboxRelayFailureReportAdapter adapter;
@BeforeEach
void attachAppender() {
appender = new ListAppender<>();
appender.setContext((LoggerContext) LoggerFactory.getILoggerFactory());
appender.start();
logger.addAppender(appender);
logger.setLevel(Level.ERROR);
adapter = new Slf4jOutboxRelayFailureReportAdapter(logger, "kafka");
}
@AfterEach
void detachAppender() {
logger.detachAppender(appender);
}
@Test
void retryableFailureEmitsCanonicalStructuredError() {
RuntimeException cause = new RuntimeException("broker unavailable");
adapter.report(
OutboxRelayFailureReport.retryableFailure(
"evt-1",
"WorkLogReserved",
"worklog-1",
"corr-1",
1,
Instant.parse("2026-07-25T01:02:03Z"),
cause));
ILoggingEvent event = singleEvent();
assertThat(event.getLevel()).isEqualTo(Level.ERROR);
assertThat(event.getThrowableProxy().getClassName())
.isEqualTo(RuntimeException.class.getName());
assertThat(fields(event))
.containsEntry("error.code", "OUTBOX_PUBLISH_FAILED")
.containsEntry("error.category", "TRANSIENT_DEPENDENCY")
.containsEntry("dependency_name", "kafka")
.containsEntry("dependency_type", "messaging")
.containsEntry("outcome", "FAILED")
.containsEntry("event_id", "evt-1")
.containsEntry("event_type", "WorkLogReserved")
.containsEntry("aggregate_id", "worklog-1")
.containsEntry("correlation_id", "corr-1")
.containsEntry("attempt_count", "1")
.containsEntry("next_attempt_at", "2026-07-25T01:02:03Z")
.containsEntry("runbook_link", "runbook://outbox/publish-failed");
}
@Test
void deadLetterUsesDeadRunbookAndHasNoRetryTimestamp() {
adapter.report(
OutboxRelayFailureReport.deadLetter(
"evt-2",
"WorkLogReserved",
"worklog-2",
"corr-2",
3,
new RuntimeException("broker unavailable")));
Map fields = fields(singleEvent());
assertThat(fields)
.containsEntry("error.code", "OUTBOX_DEAD_LETTER")
.containsEntry("error.category", "INTERNAL")
.containsEntry("outcome", "DEAD")
.containsEntry("runbook_link", "runbook://outbox/dead-letter")
.doesNotContainKey("next_attempt_at");
}
@Test
void logCannotContainPayloadOrIdempotencyData() {
adapter.report(
OutboxRelayFailureReport.deadLetter(
"evt-safe",
"SafeEvent",
"agg-safe",
"corr-safe",
3,
new RuntimeException("safe cause")));
ILoggingEvent event = singleEvent();
assertThat(event.getFormattedMessage())
.doesNotContain("payload", "idempotency")
.contains("evt-safe", "SafeEvent", "corr-safe");
assertThat(fields(event).keySet()).doesNotContain("payload", "idempotency_key");
}
private ILoggingEvent singleEvent() {
assertThat(appender.list).hasSize(1);
return appender.list.getFirst();
}
private static Map fields(ILoggingEvent event) {
return event.getKeyValuePairs().stream()
.collect(
Collectors.toMap(
pair -> pair.key,
pair -> String.valueOf(pair.value),
(left, right) -> right));
}
}
```
- [ ] **Step 2: Run the adapter test to verify the red state**
Run:
```bash
./gradlew :adapter:outbound:messaging:test \
--tests 'dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapterTest' \
--console=plain
```
Expected: `compileTestJava` fails because `Slf4jOutboxRelayFailureReportAdapter` does not exist.
- [ ] **Step 3: Implement the structured SLF4J adapter**
Create the complete adapter:
```java
package dev.caskeleton.adapter.outbound.messaging.outbox;
import dev.caskeleton.application.outbox.OutboxRelayFailureReport;
import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;
import dev.caskeleton.shared.error.OperationalError;
import java.util.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.spi.LoggingEventBuilder;
/** SLF4J structured adapter for confirmed outbox relay failure reports. */
public final class Slf4jOutboxRelayFailureReportAdapter
implements OutboxRelayFailureReportPort {
private static final String DEPENDENCY_TYPE = "messaging";
private final Logger log;
private final String dependencyName;
public Slf4jOutboxRelayFailureReportAdapter(String dependencyName) {
this(
LoggerFactory.getLogger(Slf4jOutboxRelayFailureReportAdapter.class),
dependencyName);
}
Slf4jOutboxRelayFailureReportAdapter(Logger log, String dependencyName) {
this.log = Objects.requireNonNull(log, "log must not be null");
this.dependencyName =
dependencyName == null || dependencyName.isBlank() ? "disabled" : dependencyName;
}
@Override
public void report(OutboxRelayFailureReport report) {
Objects.requireNonNull(report, "report must not be null");
try {
LoggingEventBuilder event =
log.atError()
.setCause(report.cause())
.addKeyValue("error.code", report.code().code())
.addKeyValue("error.category", report.code().category().name())
.addKeyValue("dependency_name", dependencyName)
.addKeyValue("dependency_type", DEPENDENCY_TYPE)
.addKeyValue("outcome", outcome(report.code()))
.addKeyValue("event_id", report.eventId())
.addKeyValue("event_type", report.eventType())
.addKeyValue("aggregate_id", report.aggregateId())
.addKeyValue("correlation_id", report.correlationId())
.addKeyValue("attempt_count", report.attemptCount())
.addKeyValue("runbook_link", runbook(report.code()));
if (report.nextAttemptAt() != null) {
event.addKeyValue("next_attempt_at", report.nextAttemptAt());
}
event.log(
"outbox relay failure code={} event_id={} event_type={} correlation_id={} attempt_count={}",
report.code().code(),
report.eventId(),
report.eventType(),
report.correlationId(),
report.attemptCount());
} catch (RuntimeException ignored) {
// Reporting is secondary to the already committed outbox state and must not escape.
}
}
private static String outcome(OperationalError code) {
return code == OperationalError.OUTBOX_DEAD_LETTER ? "DEAD" : "FAILED";
}
private static String runbook(OperationalError code) {
return code == OperationalError.OUTBOX_DEAD_LETTER
? "runbook://outbox/dead-letter"
: "runbook://outbox/publish-failed";
}
}
```
- [ ] **Step 4: Run the adapter test**
Run:
```bash
./gradlew :adapter:outbound:messaging:test \
--tests 'dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapterTest' \
--console=plain
```
Expected:
```text
BUILD SUCCESSFUL
```
## Task 4: Messaging Binding, Duplicate Log Removal, and Bootstrap Wiring
**Files:**
- Modify:
`src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapter.java`
- Modify:
`src/adapter/outbound/messaging/src/test/java/dev/caskeleton/adapter/outbound/messaging/outbox/OutboxMessagePublishAdapterTest.java`
- Modify:
`src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/MessagingConfig.java`
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/adapter/outbound/OptionalAdapterBeanGatingTest.java`
- Modify:
`src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox/OutboxConfig.java`
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxContainerTestSupport.java`
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox/OutboxRowLifecycleContractTest.java`
- [ ] **Step 1: Make bean-gating tests require one production reporter**
Add imports to `OptionalAdapterBeanGatingTest`:
```java
import dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter;
import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;
```
In the disabled-default assertion block add:
```java
assertThat(context.getBeansOfType(OutboxRelayFailureReportPort.class)).hasSize(1);
assertThat(context.getBean(OutboxRelayFailureReportPort.class))
.isInstanceOf(Slf4jOutboxRelayFailureReportAdapter.class);
```
In the Kafka-enabled assertion block add the same two assertions. These assertions prove that
disabled messaging still has a real reporter rather than a production NOOP.
- [ ] **Step 2: Run the bean-gating test to verify the red state**
Run:
```bash
./gradlew :app-bootstrap:test \
--tests 'dev.caskeleton.adapter.outbound.OptionalAdapterBeanGatingTest' \
--console=plain
```
Expected: FAIL because `MessagingConfig` does not expose an
`OutboxRelayFailureReportPort` bean.
- [ ] **Step 3: Bind the reporter in MessagingConfig**
Add imports:
```java
import dev.caskeleton.adapter.outbound.messaging.outbox.Slf4jOutboxRelayFailureReportAdapter;
import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;
```
Add this bean:
```java
@Bean
public OutboxRelayFailureReportPort outboxRelayFailureReportPort(MessagingSettings settings) {
return new Slf4jOutboxRelayFailureReportAdapter(settings.broker());
}
```
Change `outboxMessagePublishPort` to:
```java
@Bean
public OutboxMessagePublishPort outboxMessagePublishPort(
ObjectProvider brokerProvider, MessagingSettings settings) {
MessageBroker active = resolveBroker(brokerProvider, settings);
return (active == null)
? new DisabledOutboxMessagePublisher()
: new OutboxMessagePublishAdapter(active);
}
```
Do not change `messagePublisher`; it remains genuinely fail-open and continues to receive
`FailOpenDependencyLogger`.
- [ ] **Step 4: Remove fail-open logging from the fail-closed publisher**
Replace `OutboxMessagePublishAdapter` with:
```java
package dev.caskeleton.adapter.outbound.messaging.outbox;
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage;
import dev.caskeleton.application.outbox.OutboxEvent;
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
/**
* Fail-closed outbox publisher that maps an application event to a broker envelope and surfaces
* every send failure to the relay state machine.
*/
public class OutboxMessagePublishAdapter implements OutboxMessagePublishPort {
private final MessageBroker broker;
public OutboxMessagePublishAdapter(MessageBroker broker) {
this.broker = broker;
}
@Override
public void publish(OutboxEvent event) {
String envelope = OutboxEnvelopeJson.toJson(event);
OutboundMessage message =
new OutboundMessage(event.eventType(), event.aggregateId(), envelope);
try {
broker.send(message);
} catch (RuntimeException ex) {
throw ex;
} catch (Exception ex) {
throw new RuntimeException(
"outbox publish failed for broker '" + broker.brokerId() + "'", ex);
}
}
}
```
In `OutboxMessagePublishAdapterTest`:
- remove `FailOpenDependencyLogger`, Logback appender, SLF4J, and MDC setup imports/fields;
- remove `publishFailureIsLoggedBeforePropagation`;
- remove `publishFailureLogCarriesDependencyAndOperation`;
- replace every `new OutboxMessagePublishAdapter(broker, dependencyLogger)` with
`new OutboxMessagePublishAdapter(broker)`;
- retain success envelope tests, runtime propagation, and checked-exception wrapping tests.
- [ ] **Step 5: Wire the reporter through app-bootstrap**
Add the import to `OutboxConfig`:
```java
import dev.caskeleton.application.outbox.OutboxRelayFailureReportPort;
```
Add the parameter immediately after `OutboxMessagePublishPort publishPort`:
```java
OutboxRelayFailureReportPort failureReports,
```
Pass it in the manual constructor:
```java
new PublishPendingOutboxEventsUseCase(
store,
publishPort,
failureReports,
tx,
new OutboxBackoffPolicy(outboxRandomGenerator),
clock,
properties.batchSize(),
properties.inFlightTimeout())
```
`app-bootstrap` must not construct `Slf4jOutboxRelayFailureReportAdapter`; Spring injects the
messaging-owned port bean.
- [ ] **Step 6: Update direct test constructors with test-only reporters**
In `OutboxContainerTestSupport.relayUseCase`, insert this argument after `publisher`:
```java
report -> {}
```
In both direct constructors in `OutboxRowLifecycleContractTest`, insert the same test-only lambda
after the publisher argument:
```java
report -> {}
```
Use test-only lambdas only in integration fixtures. Production wiring must never bind a NOOP.
- [ ] **Step 7: Run messaging, bean-gating, and relay tests**
Run:
```bash
./gradlew :adapter:outbound:messaging:test --console=plain
./gradlew :app-bootstrap:test \
--tests 'dev.caskeleton.adapter.outbound.OptionalAdapterBeanGatingTest' \
--console=plain
./gradlew :application-core:test \
--tests 'dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCaseTest' \
--console=plain
```
Expected for each command:
```text
BUILD SUCCESSFUL
```
## Task 5: Remove Framework Dependencies and Add a Resolved-Classpath Guard
**Files:**
- Modify: `src/build.gradle`
- Modify: `src/application-core/build.gradle`
- Modify: `src/application-core/gradle.lockfile`
- [ ] **Step 1: Add the failing application-core dependency purity task**
Add this task to `src/build.gradle` after `verifyCleanArchitectureDependencies`:
```groovy
tasks.register('verifyApplicationCoreDependencyPurity') {
group = 'verification'
description = 'Verifies application-core has project-only production declarations and no framework observability on main/test classpaths.'
doLast {
Project applicationCore = project(':application-core')
List productionConfigurations = ['api', 'implementation', 'compileOnly', 'runtimeOnly']
Set declaredNonProject = productionConfigurations
.collect { applicationCore.configurations.findByName(it) }
.findAll { it != null }
.collectMany { configuration ->
configuration.dependencies
.findAll {
!(it instanceof org.gradle.api.artifacts.ProjectDependency)
}
.collect { dependency ->
String coordinate = dependency.group
? "${dependency.group}:${dependency.name}"
: "local:${dependency.name}"
"${configuration.name}:${coordinate}"
}
}
.toSet()
if (!declaredNonProject.isEmpty()) {
throw new GradleException(
"application-core production dependencies must be project-only; found " +
declaredNonProject.toSorted())
}
Closure forbiddenGroup = { String group ->
group == 'org.slf4j' ||
group == 'ch.qos.logback' ||
group == 'org.apache.logging.log4j' ||
group == 'io.micrometer' ||
group == 'org.springframework' ||
group.startsWith('org.springframework.')
}
List classpathConfigurations = [
'compileClasspath',
'runtimeClasspath',
'testCompileClasspath',
'testRuntimeClasspath'
]
Set forbiddenResolved = classpathConfigurations.collectMany { configurationName ->
def configuration = applicationCore.configurations.getByName(configurationName)
configuration.resolvedConfiguration.resolvedArtifacts.findResults { artifact ->
String group = artifact.moduleVersion.id.group
forbiddenGroup(group)
? "${configurationName}:${group}:${artifact.name}:${artifact.moduleVersion.id.version}"
: null
}
}.toSet()
if (!forbiddenResolved.isEmpty()) {
throw new GradleException(
"application-core main/test classpaths contain forbidden framework observability: " +
forbiddenResolved.toSorted())
}
logger.lifecycle(
'verifyApplicationCoreDependencyPurity: OK — production declarations are project-only and main/test classpaths are framework-observability-free.')
}
}
project(':application-core').tasks.named('check') {
dependsOn rootProject.tasks.named('verifyApplicationCoreDependencyPurity')
}
```
- [ ] **Step 2: Run the purity task to verify the red state**
Run:
```bash
./gradlew verifyApplicationCoreDependencyPurity --console=plain
```
Expected: FAIL listing at least
`org.springframework.boot:spring-boot-starter` as a declared external dependency and resolved
Spring/logging/Micrometer components.
- [ ] **Step 3: Give application-core a pure test baseline**
In the common leaf `dependencies` block of `src/build.gradle`, replace the unconditional Boot test
starter declarations with:
```groovy
if (path == ':application-core') {
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.assertj:assertj-core'
} else {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
}
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
```
Keep static-analysis dependencies unchanged. They live on analysis tool configurations, not
application main/test runtime classpaths.
- [ ] **Step 4: Remove the Boot starter from application-core**
Make `src/application-core/build.gradle` contain:
```groovy
// Framework-free application use-case and outbound-port contracts.
dependencies {
implementation project(':domain-core')
implementation project(':shared-contract')
}
```
- [ ] **Step 5: Regenerate only application-core dependency locks**
Run:
```bash
./gradlew :application-core:resolveAndLockAll --write-locks --console=plain
```
Expected:
```text
BUILD SUCCESSFUL
```
Review `src/application-core/gradle.lockfile`: Spring, SLF4J, Logback, Log4j, and Micrometer must be
absent from `compileClasspath`, `runtimeClasspath`, `testCompileClasspath`, and
`testRuntimeClasspath`. SLF4J entries used only by SpotBugs tool configurations may remain.
- [ ] **Step 6: Run the green dependency checks**
Run:
```bash
./gradlew verifyApplicationCoreDependencyPurity --console=plain
./gradlew :application-core:verifyDependencyLocks --console=plain
./gradlew :application-core:test --console=plain
```
Expected for all commands:
```text
BUILD SUCCESSFUL
```
Expected purity lifecycle line:
```text
verifyApplicationCoreDependencyPurity: OK — production declarations are project-only and main/test classpaths are framework-observability-free.
```
## Task 6: Add a Non-Vacuous Application Logger Architecture Rule
**Files:**
- Create:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/violations/application/LoggerUsingApplicationFixture.java`
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java`
- Modify:
`src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`
- [ ] **Step 1: Add the intentional violation fixture**
Create:
```java
package dev.caskeleton.bootstrap.architecture.violations.application;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Intentional application-layer logger violation used only by architecture mutation tests. */
public final class LoggerUsingApplicationFixture {
private static final Logger LOG =
LoggerFactory.getLogger(LoggerUsingApplicationFixture.class);
public void execute() {
LOG.info("application should report through a typed port");
}
}
```
- [ ] **Step 2: Add the failing mutation assertion**
Add this import and isolated fixture corpus to `ArchitectureViolationFixtureTest`:
```java
import dev.caskeleton.bootstrap.architecture.violations.application.LoggerUsingApplicationFixture;
```
```java
private static final JavaClasses LOGGER_USING_APPLICATION_FIXTURE_ONLY =
new ClassFileImporter().importClasses(LoggerUsingApplicationFixture.class);
```
Add this test:
```java
@Test
void applicationHasNoDiagnosticFrameworkCatchesSlf4jFixture() {
EvaluationResult result =
CleanArchitectureTest.APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK.evaluate(
LOGGER_USING_APPLICATION_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as(
"APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK must catch "
+ "LoggerUsingApplicationFixture")
.isTrue();
}
```
- [ ] **Step 3: Run the mutation test to verify the red state**
Run:
```bash
./gradlew :app-bootstrap:test \
--tests 'dev.caskeleton.bootstrap.architecture.ArchitectureViolationFixtureTest.applicationHasNoDiagnosticFrameworkCatchesSlf4jFixture' \
--console=plain
```
Expected: `compileTestJava` fails because
`APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK` does not exist.
- [ ] **Step 4: Implement the ArchUnit rule**
Add this rule beside the existing application boundary rules in `CleanArchitectureTest`:
```java
@ArchTest
static final ArchRule APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK =
noClasses()
.that()
.resideInAPackage("..application..")
.should()
.dependOnClassesThat()
.resideInAnyPackage(
"org.slf4j..",
"java.util.logging..",
"ch.qos.logback..",
"org.apache.logging.log4j..",
"io.micrometer..")
.as(
"application policy must report operational facts through typed ports, not logging "
+ "or metrics framework APIs")
.allowEmptyShould(true);
```
- [ ] **Step 5: Run mutation and production architecture tests**
Run:
```bash
./gradlew :app-bootstrap:test \
--tests 'dev.caskeleton.bootstrap.architecture.ArchitectureViolationFixtureTest.applicationHasNoDiagnosticFrameworkCatchesSlf4jFixture' \
--console=plain
./gradlew :app-bootstrap:test \
--tests 'dev.caskeleton.bootstrap.architecture.CleanArchitectureTest' \
--console=plain
```
Expected for both:
```text
BUILD SUCCESSFUL
```
## Task 7: Align Documentation and Operational Field Contracts
**Files:**
- Modify: `src/application-core/CLAUDE.md`
- Modify: `src/application-core/README.md`
- Modify: `src/adapter/outbound/messaging/CLAUDE.md`
- Modify: `src/adapter/outbound/messaging/README.md`
- Modify: `docs/runbooks/outbox-publish-failed.md`
- Modify: `docs/runbooks/outbox-dead-letter.md`
- [ ] **Step 1: Correct application-core dependency guidance**
In `src/application-core/CLAUDE.md`, make the Allowed production dependencies exactly:
```markdown
## Allowed
- `:domain-core`
- `:shared-contract`
- Java standard-library types.
Spring, SLF4J, Logback, Log4j, JUL logging, and Micrometer APIs are forbidden in
`application-core`. Use cases are registered or manually constructed by a composition root or
consumer module.
```
Remove `@Service` from the canonical application-core use-case example. Add these contract rows:
```markdown
| `outbox.OutboxRelayFailureReportPort` | Reports confirmed FAILED/DEAD transitions without exposing a logging framework. |
| `outbox.OutboxRelayFailureReport` | Safe operational metadata only; payload and idempotency key are forbidden. |
```
- [ ] **Step 2: Document the relay's new failure-report semantics**
Replace direct-logger language in `src/application-core/README.md` with:
```markdown
- Every confirmed publish failure performs both (a) the FAILED/DEAD state transition and (b) one
`OutboxRelayFailureReportPort` report attempt.
- The transition commits before reporting. If the transition fails, no report is emitted because
no FAILED/DEAD state was confirmed.
- A reporter failure is contained and cannot rewrite the persisted outcome or stop the remaining
batch. Production wiring must still provide a real reporter; a production NOOP is forbidden.
- The safe report carries code, event/aggregate/correlation identifiers, attempt count, retry time,
and cause. Payload and idempotency key never cross the port.
```
Retain the existing `markPublished` failure and in-flight recovery explanation.
- [ ] **Step 3: Document messaging ownership and duplicate-log removal**
Add to `src/adapter/outbound/messaging/CLAUDE.md` Responsibility:
```markdown
- Implement `OutboxRelayFailureReportPort` as the single structured ERROR renderer for confirmed
outbox FAILED/DEAD transitions.
```
Update `src/adapter/outbound/messaging/README.md` with:
```markdown
## Outbox failure reporting
`OutboxMessagePublishAdapter` is fail-closed and only surfaces broker failures. It does not use the
fail-open dependency logger. After application-core commits FAILED or DEAD,
`Slf4jOutboxRelayFailureReportAdapter` emits one structured ERROR with the registered runbook fields.
This separation prevents a WARN-before-rethrow plus ERROR-after-transition duplicate.
```
Also remove the stale claim that this module has no `CLAUDE.md`; the existing module guidance is
the local rule authority.
- [ ] **Step 4: Align runbook code and fields**
In both outbox runbooks:
- name `OutboxRelayFailureReportPort` and
`Slf4jOutboxRelayFailureReportAdapter` as the canonical reporting path;
- retain `error.code`, `event_id`, `event_type`, `correlation_id`, and `runbook_link`;
- state that payload and idempotency key are forbidden;
- remove stale concrete class names that do not exist in the repository.
Use this code-path text:
```markdown
- 코드: `application-core`의 `PublishPendingOutboxEventsUseCase`
(상태 전이 + typed report 생성) → `OutboxRelayFailureReportPort` →
`adapter:outbound:messaging`의 `Slf4jOutboxRelayFailureReportAdapter`
(structured ERROR + runbook fields).
```
- [ ] **Step 5: Verify documentation contains no old core-logger rationale**
Run from the repository root:
```bash
rg -n "spring-boot-starter.*@Service|LoggerFactory|log\\.error" \
src/application-core/CLAUDE.md \
src/application-core/README.md \
src/application-core/build.gradle
```
Expected: no matches.
Run:
```bash
rg -n "OutboxRelayFailureReportPort|Slf4jOutboxRelayFailureReportAdapter" \
src/application-core \
src/adapter/outbound/messaging \
docs/runbooks/outbox-publish-failed.md \
docs/runbooks/outbox-dead-letter.md
```
Expected: matches in application contracts/docs, messaging implementation/docs, and both runbooks.
## Task 8: Focused, Integration, and Full Verification
**Files:** No planned source additions; fix only findings within the file map above.
- [ ] **Step 1: Format and check the affected Java sources**
Run:
```bash
cd src
./gradlew \
:application-core:spotlessCheck \
:adapter:outbound:messaging:spotlessCheck \
:app-bootstrap:spotlessCheck \
--console=plain
```
Expected:
```text
BUILD SUCCESSFUL
```
If formatting fails, run the repository formatter only on the affected modules:
```bash
./gradlew \
:application-core:spotlessApply \
:adapter:outbound:messaging:spotlessApply \
:app-bootstrap:spotlessApply \
--console=plain
```
Then rerun the blocking `spotlessCheck`. `spotlessApply` is a local implementation step and must
never be substituted for the CI check.
- [ ] **Step 2: Run focused module tests**
Run:
```bash
./gradlew :application-core:test --console=plain
./gradlew :adapter:outbound:messaging:test --console=plain
./gradlew :app-bootstrap:test \
--tests 'dev.caskeleton.adapter.outbound.OptionalAdapterBeanGatingTest' \
--tests 'dev.caskeleton.bootstrap.architecture.CleanArchitectureTest' \
--tests 'dev.caskeleton.bootstrap.architecture.ArchitectureViolationFixtureTest' \
--console=plain
```
Expected for all:
```text
BUILD SUCCESSFUL
```
- [ ] **Step 3: Run the real PostgreSQL outbox lifecycle contract when Docker is available**
Run:
```bash
./gradlew :app-bootstrap:test \
--tests 'dev.caskeleton.bootstrap.integration.outbox.OutboxRowLifecycleContractTest' \
--console=plain
```
Expected with a reachable Docker daemon:
```text
BUILD SUCCESSFUL
```
If the test is skipped or Docker is unavailable, record that exact result and retain the
integration-risk item in the final report.
- [ ] **Step 4: Run dependency and architecture gates**
Run:
```bash
./gradlew verifyApplicationCoreDependencyPurity --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :application-core:verifyDependencyLocks --console=plain
```
Expected:
```text
BUILD SUCCESSFUL
```
- [ ] **Step 5: Prove the forbidden dependencies are absent**
Run:
```bash
./gradlew :application-core:dependencyInsight \
--dependency org.springframework \
--configuration runtimeClasspath \
--console=plain
./gradlew :application-core:dependencyInsight \
--dependency org.slf4j \
--configuration testRuntimeClasspath \
--console=plain
./gradlew :application-core:dependencyInsight \
--dependency io.micrometer \
--configuration testRuntimeClasspath \
--console=plain
```
Expected for each report:
```text
No dependencies matching given input were found
BUILD SUCCESSFUL
```
- [ ] **Step 6: Run full tests and checks**
Run:
```bash
./gradlew test --console=plain
./gradlew check --console=plain
```
Expected:
```text
BUILD SUCCESSFUL
```
`check` must transitively run dependency, environment-key, architecture, formatting, and static
analysis gates. Report every failed or unrun command rather than claiming completion.
- [ ] **Step 7: Review the final working-tree diff**
Run from the repository root:
```bash
git status --short
git diff --check
git diff -- \
src/application-core \
src/adapter/outbound/messaging \
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/outbox \
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture \
src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/integration/outbox \
src/build.gradle \
docs/runbooks
```
Expected:
- `git diff --check` exits `0`;
- no production file outside the declared file map changed;
- no payload/idempotency field entered the report API;
- no SLF4J/Spring/Micrometer import remains in application-core;
- no agent-created commit exists.
## Task 9: Required Implementation Evidence and LLM Wiki Capture
This task is performed only after production implementation and verification. It is not performed
while merely authoring this plan.
- [ ] **Step 1: Read the Wiki authorities before writing**
Read:
```text
/home/donghyeon/workspace/ai-tool/llm-wiki-private/AGENTS.md
/home/donghyeon/workspace/ai-tool/llm-wiki-private/CLAUDE.md
/home/donghyeon/workspace/clean-architecture-backend-template/.agents/plugins/ca-superpowers/rules/llm-wiki-capture.md
```
Expected: all authorities exist after harness recovery and are read in full.
- [ ] **Step 2: Update the branch note**
Create or update:
```text
/home/donghyeon/workspace/ai-tool/llm-wiki-private/raw/branch-notes/main.md
```
Record:
- typed report/port decision and messaging ownership;
- safe-field boundary and rejected alternatives;
- changed files;
- every verification command and result;
- the harness-registry prerequisite and any Docker limitation;
- evidence grade;
- remaining risk.
- [ ] **Step 3: Make the derived-document judgment explicit**
Create linked raw error/interview/blog-topic notes only when the completed implementation provides
genuine derived material. Otherwise write `추출할 별도 글감 없음` in the branch note's
`## Cluster / 묶음` section.
- [ ] **Step 4: Produce the final implementation report**
The final response lists:
- changed files;
- core architecture and behavior changes;
- focused/full verification commands and outcomes;
- failed or unrun checks;
- Wiki branch-note and derived-note result;
- remaining risks and human-only commit status.