1854 lines
58 KiB
Markdown
1854 lines
58 KiB
Markdown
# 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.
|
|
*
|
|
* <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:
|
|
|
|
```java
|
|
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:
|
|
|
|
```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<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:
|
|
|
|
```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<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:
|
|
|
|
```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<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:
|
|
|
|
```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<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:
|
|
|
|
```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.
|