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

432 lines
18 KiB
Markdown

# Application Outbox Failure Reporting Refactoring Design
- **Date:** 2026-07-25
- **Status:** Approved
- **Scope:** `application-core` outbox failure reporting, its messaging adapter, bootstrap wiring,
dependency purity enforcement, tests, and affected module documentation
- **Source:** user-requested Clean Architecture refactoring review plus repository evidence gathered
on 2026-07-25
## 1. Problem Statement
`application-core` declares `org.springframework.boot:spring-boot-starter`, although its production
sources use no Spring type or annotation. The only external observability types in the module are
`org.slf4j.Logger` and `org.slf4j.LoggerFactory` in
`PublishPendingOutboxEventsUseCase`. The broad starter consequently places Boot autoconfiguration,
Spring Context/AOP, Micrometer Observation, Logback, Log4j bridges, JUL bridges, and SnakeYAML on a
core application classpath for two logging calls.
This contradicts the module's framework-free design statement and weakens the dependency direction
the template is intended to teach. It also hides an important semantic distinction: the two log
lines are not arbitrary diagnostic messages. They report confirmed `FAILED` and `DEAD` outbox state
transitions that feed operational alerts and runbooks.
The build currently cannot resolve dependencies or run tests because `src/settings.gradle` fails
when `.harness/project/modules.yaml` is absent. Harness registry recovery is therefore a prerequisite
for implementation and verification, not part of this refactoring.
## 2. Evidence
- `src/application-core/build.gradle:14` declares `spring-boot-starter`.
- `src/application-core/src/main/java/dev/caskeleton/application/outbox/PublishPendingOutboxEventsUseCase.java:17-18`
imports the only production observability framework types in the module.
- The same use case creates an SLF4J logger at lines 38-39 and emits the two failure records at
lines 139-165.
- `src/application-core/README.md:10-13` says the module is framework-free and accesses
infrastructure only through `*Port` interfaces.
- `src/application-core/CLAUDE.md:27-30` and `src/application-core/build.gradle:3-5` instead claim
the starter is retained for optional `@Service` registration, although application-core contains
no Spring stereotype.
- The relay is manually constructed and must not be a Spring bean:
`src/application-core/README.md:332-337`.
- The outbox registry assigns `OUTBOX_PUBLISH_FAILED` and `OUTBOX_DEAD_LETTER` to the infrastructure
owner layer and ERROR severity: `docs/registries/error-codes.yaml:724-749`.
- The runbooks require structured `error.code`, `event_id`, `event_type`, and `correlation_id`
fields: `docs/runbooks/outbox-publish-failed.md:17-27` and
`docs/runbooks/outbox-dead-letter.md:17-24`.
- The current project-dependency verifier inspects only `ProjectDependency` instances, so it cannot
reject an external starter added to a core module: `src/build.gradle:612-619`.
## 3. Goals
1. Make `application-core` free of Spring, SLF4J, Logback, Log4j, JUL logging, and Micrometer types
and main/test classpath dependencies.
2. Express confirmed outbox publication failures as a typed application-owned outbound port.
3. Keep report data safe by construction: no payload, idempotency key, arbitrary field map, log
level, message template, or framework logger crosses the port.
4. Implement structured failure reporting in `adapter:outbound:messaging`.
5. Keep `app-bootstrap` limited to final wiring and runtime logging configuration.
6. Preserve outbox state-machine behavior, transaction boundaries, per-event continuation, and
at-least-once semantics.
7. Ensure a reporting backend failure cannot change a persisted `FAILED`/`DEAD` outcome or stop the
remaining relay batch.
8. Remove the duplicate, misleading fail-open WARN currently emitted by the fail-closed outbox
publisher.
9. Add source- and dependency-level guardrails that prevent framework observability from returning
to application-core.
## 4. Non-Goals
- This change does not redesign outbox claiming, FIFO ordering, retry backoff, in-flight recovery,
or broker selection.
- It does not turn operational failures into domain events or business audit records.
- It does not deliver a failure report through the same broker/outbox path; that would recurse when
the broker is the failing dependency.
- It does not add a generic `LoggerPort`, severity API, string template API, or untyped field map.
- It does not add a twentieth module or a general observability adapter family.
- It does not recover the missing `.harness` policy registry; the implementation waits for that
independently governed recovery.
- It does not change public HTTP response contracts.
## 5. Semantic Classification
An outbox publish failure is an **application operational event**:
- the application state machine decides whether the confirmed result is retryable `FAILED` or
terminal `DEAD`;
- the persistence transition is authoritative;
- an infrastructure adapter renders that fact as a structured operational record;
- metrics and runbooks consume the result for operations.
It is not a business audit event. It has no actor/action audit semantics, is not retained as an
immutable audit ledger, and must not be used as proof of a business transaction. It is also not a
domain event: feeding it into the same outbox publisher would recursively fail.
## 6. Architecture
```text
PublishPendingOutboxEventsUseCase
├── OutboxStorePort ──────────────> persistence adapter
├── OutboxMessagePublishPort ─────> messaging publisher adapter
└── OutboxRelayFailureReportPort ─> SLF4J structured reporter adapter
(adapter:outbound:messaging)
app-bootstrap
└── injects all three ports when manually constructing the relay use case
```
Dependency direction remains:
```text
app-bootstrap
-> adapter:outbound:messaging
-> application-core
-> shared-contract
-> domain-core
```
`application-core` owns the port and safe report value. The messaging module owns the concrete
rendering because its local responsibility explicitly includes outbox publication adaptation, it
already depends on application/shared contracts, and it already has the SLF4J API. Bootstrap
selects and injects the adapter but does not implement the port.
## 7. Application Contract
### 7.1 Port
```java
@FunctionalInterface
public interface OutboxRelayFailureReportPort {
/**
* Attempts to report a confirmed FAILED or DEAD relay transition.
*
* <p>The implementation must not throw. The report is operational evidence, while the persisted
* outbox state and returned relay outcome remain authoritative.
*/
void report(OutboxRelayFailureReport report);
}
```
The use case also contains a defensive non-throwing invocation boundary. This makes the invariant
explicit even if a custom implementation violates the port contract.
### 7.2 Safe immutable report
```java
public record OutboxRelayFailureReport(
OperationalError code,
String eventId,
String eventType,
String aggregateId,
String correlationId,
int attemptCount,
Instant nextAttemptAt,
RuntimeException cause) {
public static OutboxRelayFailureReport retryableFailure(
String eventId,
String eventType,
String aggregateId,
String correlationId,
int attemptCount,
Instant nextAttemptAt,
RuntimeException cause);
public static OutboxRelayFailureReport deadLetter(
String eventId,
String eventType,
String aggregateId,
String correlationId,
int attemptCount,
RuntimeException cause);
}
```
Invariants:
- `code` is exactly `OUTBOX_PUBLISH_FAILED` or `OUTBOX_DEAD_LETTER`.
- identifiers and type names are non-null and non-blank.
- `attemptCount` is at least one.
- `nextAttemptAt` is required for `OUTBOX_PUBLISH_FAILED` and absent for
`OUTBOX_DEAD_LETTER`.
- `cause` is required.
- the record has no `payload`, `idempotencyKey`, logger, severity, template, or arbitrary map.
Static factories remove invalid combinations from ordinary call sites. The record deliberately
accepts an allowlisted set of safe operational metadata plus the original cause rather than an
`OutboxEvent`, whose full shape includes payload and idempotency data.
## 8. Relay Flow and Failure Semantics
The report attempt happens only after the corresponding write transaction succeeds:
```text
publish throws
├── retry remains
│ ├── inWrite(markFailed) succeeds
│ ├── attempt OUTBOX_PUBLISH_FAILED report
│ └── return FAILED
└── attempts exhausted
├── inWrite(markDead) succeeds
├── attempt OUTBOX_DEAD_LETTER report
└── return DEAD
```
Behavior matrix:
| Situation | Persistence | Report | Relay behavior |
|---|---|---|---|
| Publish succeeds, `markPublished` succeeds | `PUBLISHED` | none | return `PUBLISHED` |
| Publish succeeds, `markPublished` fails | remains recoverable `IN_FLIGHT` | none | propagate store failure; scheduler retries later |
| Publish fails, `markFailed` succeeds | `FAILED` | attempt retryable report | return `FAILED`; continue batch |
| Publish fails, `markDead` succeeds | `DEAD` | attempt dead-letter report | return `DEAD`; continue batch |
| Publish fails, status transition fails | no confirmed FAILED/DEAD transition | none | propagate store failure; do not emit a false report |
| Reporter violates its contract and throws | already `FAILED` or `DEAD` | attempted | contain reporter exception; preserve outcome and continue batch |
The report is mandatory as an **attempt** after every confirmed failure transition. A production
NOOP binding is forbidden. No logging system can guarantee durable emission, so metrics and
persistence remain independent evidence when the logging backend itself is impaired.
## 9. Structured Logging Contract
The messaging adapter emits ERROR through SLF4J 2's fluent key-value API. The runtime Logstash
encoder serializes key-value pairs as top-level JSON fields, while the message also carries a short
safe summary for local pattern output.
Common fields:
- `error.code`
- `error.category`
- `dependency_name`
- `dependency_type=messaging`
- `outcome`
- `event_id`
- `event_type`
- `aggregate_id`
- `correlation_id`
- `attempt_count`
- `runbook_link`
Retry-only field:
- `next_attempt_at`
Mappings:
| Code | Outcome | Runbook |
|---|---|---|
| `OUTBOX_PUBLISH_FAILED` | `FAILED` | `runbook://outbox/publish-failed` |
| `OUTBOX_DEAD_LETTER` | `DEAD` | `runbook://outbox/dead-letter` |
The throwable is attached as the log cause. Payload, idempotency key, message envelope, recipient,
and arbitrary exception-derived key/value fields are never added. Existing runtime masking remains
defense in depth rather than the primary privacy boundary.
## 10. Duplicate Logging Removal
`OutboxMessagePublishAdapter` currently uses `FailOpenDependencyLogger` for a fail-closed operation:
it emits WARN and rethrows. That logger's documented meaning is an optional dependency failure where
the use case still succeeds. The relay then emits a second ERROR after deciding `FAILED` or `DEAD`.
After this refactoring:
- `OutboundMessagePublisher` keeps `FailOpenDependencyLogger` because its contract is genuinely
fail-open.
- `OutboxMessagePublishAdapter` maps/sends and surfaces failures without logging.
- `Slf4jOutboxRelayFailureReportAdapter` emits the single canonical ERROR after the application
state transition succeeds.
This removes duplicate records and makes severity match the confirmed outcome.
## 11. Dependency Purity
### 11.1 Gradle declarations
`application-core` production declarations become:
```groovy
dependencies {
implementation project(':domain-core')
implementation project(':shared-contract')
}
```
The root test baseline gives application-core JUnit Jupiter and AssertJ directly rather than Spring
Boot test starters. Spring dependency-management may remain build tooling, but Spring/logging/
Micrometer artifacts must not appear on application-core main or test compile/runtime classpaths.
Static-analysis tool configurations are outside this classpath rule.
### 11.2 Gradle verification
A blocking `verifyApplicationCoreDependencyPurity` task checks both:
1. application-core production configurations contain no declared external module dependency;
2. `compileClasspath`, `runtimeClasspath`, `testCompileClasspath`, and `testRuntimeClasspath`
resolve no Spring, SLF4J, Logback, Log4j, or Micrometer component.
The task is wired into `:application-core:check`.
### 11.3 Source verification
ArchUnit adds an application logger/metrics ban covering:
- `org.slf4j..`
- `java.util.logging..`
- `ch.qos.logback..`
- `org.apache.logging.log4j..`
- `io.micrometer..`
An intentional application-package fixture proves the rule is not vacuous. The Gradle task remains
necessary because ArchUnit cannot detect an unused starter that is merely present on the classpath.
## 12. Module Ownership Alternatives
### 12.1 Messaging adapter — selected
Advantages:
- highest cohesion with outbox publication failure;
- existing application/shared/support and SLF4J dependencies;
- no new project edge;
- reusable by composition roots other than app-bootstrap;
- preserves bootstrap as wiring rather than an adapter collection.
Counterargument: reporting is observability rather than broker transport. The selected design
answers this by keeping the contract application-owned and the concrete class narrowly
outbox-specific; generic logging support does not move into messaging.
### 12.2 Shared outbound support — rejected
Advantages:
- already owns reusable correlation and fail-open dependency logging;
- would centralize logging backend calls.
Counterargument: the support module explicitly keeps feature-specific behavior in the owning leaf.
Putting `FAILED`/`DEAD` outbox semantics there makes a low-level shared module feature-aware.
Generalizing the interface would create the forbidden logger abstraction.
### 12.3 App bootstrap — viable fallback, not selected
Advantages:
- owns runtime logging bootstrap and final wiring;
- already contains outbox metrics and structured Logstash usage.
Counterargument: each feature-specific reporter placed there expands the composition root into an
adapter implementation module and prevents straightforward reuse by another composition root.
### 12.4 Direct `slf4j-api` in application-core — rejected
This is the smallest dependency diff and would remove Spring Boot transitive dependencies, but it
retains framework coupling, contradicts the module rule, and tests formatting calls instead of
application meaning.
### 12.5 Generic operational event publisher — deferred
A typed cross-feature operational event sink could become valuable when several application
features need the same routing. Introducing it for two outbox outcomes is premature, risks an
untyped field bag, and must never be implemented through the failing outbox broker.
## 13. Testing Strategy
### Application contract tests
- report factory happy paths and invariant rejection;
- record component whitelist proving payload and idempotency key are absent;
- transient failure reports only after `markFailed`;
- dead-letter failure reports only after `markDead`;
- transition failure emits no report;
- successful publish and `markPublished` failure emit no failure report;
- a throwing reporter does not change `FAILED`/`DEAD` result and does not stop later events.
### Messaging adapter tests
- exactly one ERROR record;
- code/category/outcome/runbook mapping;
- required snake_case identifiers and attempt fields;
- retry-only `next_attempt_at`;
- throwable preservation;
- no payload or idempotency key;
- reporter bean exists when messaging is disabled and when a broker is active;
- outbox publisher propagates runtime and checked broker failures without emitting the old
fail-open WARN.
### Architecture and Gradle tests
- intentional application logger fixture is rejected;
- production application packages pass the new rule;
- application-core dependency purity task passes only with clean main/test classpaths;
- lock verification passes after regeneration.
### Regression tests
- focused application, messaging, and bootstrap tests;
- outbox PostgreSQL lifecycle tests when Docker is available;
- full `test` and `check`.
## 14. Migration Sequence
1. Recover and validate the harness module registry so Gradle can configure.
2. Add red tests for safe report contracts and relay semantics.
3. Add the application report value and port.
4. Inject the port into the relay and make report failures outcome-neutral.
5. Add red messaging adapter and wiring tests.
6. Implement the structured messaging reporter.
7. Remove fail-open logging from the fail-closed outbox publisher.
8. Remove application-core Boot/Spring/logging dependencies and give it a pure test baseline.
9. Add source and resolved-classpath purity guards.
10. Regenerate dependency locks and update module/runbook documentation.
11. Run focused, architecture, dependency, full test, and full check gates.
12. Capture implementation evidence in the LLM Wiki as required by repository policy.
## 15. Acceptance Criteria
- No application-core main or test source imports Spring, SLF4J, Logback, Log4j, JUL logging, or
Micrometer.
- No forbidden framework artifact appears on application-core main/test compile/runtime
classpaths.
- `spring-boot-starter` is absent from `src/application-core/build.gradle`.
- Every confirmed `FAILED`/`DEAD` transition attempts exactly one typed report.
- A status-transition failure emits no success-like failure report.
- A reporter exception cannot change a relay outcome or stop the next claimed event.
- Production wiring contains exactly one non-NOOP `OutboxRelayFailureReportPort`.
- Structured ERROR fields and runbook links match the documented registry conventions.
- Payload and idempotency key cannot cross the report contract and do not appear in adapter logs.
- The fail-closed publisher no longer uses `FailOpenDependencyLogger`.
- Application logger ArchUnit mutation and dependency purity checks are blocking.
- Focused tests, dependency locks, architecture checks, full tests, and `check` pass after harness
registry recovery, or any environmental blocker is reported with its remaining risk.