Files
clean-architecture-backend-…/docs/superpowers/specs/2026-09-18-outbox-transport-only-cutover-design.md
T

195 lines
7.5 KiB
Markdown

# Outbox Transport-Only Cutover Design
## Status
Approved implementation slice for MSG-015 transport-only cutover.
This design deliberately does **not** activate `POLLING_V2` and does not migrate the publication authority to the v2 delivery tables. The existing `outbox_event` writer/store/claim/status authority remains the only active authority. The change makes that legacy authority capable of carrying a canonical integration event without losing the exact platform envelope.
## Goal
Support both row generations under one legacy relay authority:
```text
business transaction
-> legacy NewOutboxEvent -> legacy row
-> canonical ValidatedIntegrationEvent -> canonical-compatible row
one OutboxStorePort claim authority
-> legacy claimed row -> MessageBroker compatibility path
-> canonical claimed row -> IntegrationEventPublishPort -> messaging platform
```
A row is published through exactly one branch. There is no dual write and no second relay scheduler.
## Application boundaries
### Canonical append
`OutboxAppendPort` becomes the canonical durable append boundary:
```java
void append(ValidatedIntegrationEvent event);
```
### Legacy append
Raw R0 payload append moves to an explicitly named compatibility port:
```java
LegacyOutboxAppendPort
void append(NewOutboxEvent event);
```
Existing sample/durable-operation code that still emits raw `NewOutboxEvent` uses only the legacy port. New canonical code must not call the legacy port.
### Claimed row model
The relay-facing row is a sealed application model:
```text
ClaimedOutboxEvent
|- OutboxEvent // legacy R0 claim model retained for compatibility
`- CanonicalClaimedOutboxEvent // reconstructs one ValidatedIntegrationEvent
```
`OutboxStorePort.claimBatch` returns `List<ClaimedOutboxEvent>`.
Common relay state is exposed by the sealed interface: event id, event type, aggregate id, occurred-at, status and attempt count. The canonical subtype also exposes the exact `ValidatedIntegrationEvent`.
A persisted row with a **partial** canonical metadata set is corrupt and fails closed during mapping. It is never downgraded to the legacy path.
## Storage compatibility projection
The existing PostgreSQL `outbox_event` remains authoritative. Add a forward migration after current legacy V12 that:
- widens `event_id` to `varchar(96)`;
- widens `correlation_id` to `varchar(128)`;
- adds nullable canonical columns to preserve existing rows;
- adds an all-or-none canonical-shape check;
- stores exact canonical envelope bytes in `bytea`;
- keeps the legacy required columns for the rollback window.
Canonical required columns:
```text
contract_id
envelope_version
payload_version
logical_destination
tenant_scope
aggregate_type
aggregate_sequence
event_index
partition_key
envelope_bytes
content_type
schema_set_hash
envelope_sha256
envelope_schema_hash
payload_schema_hash
contract_catalog_revision
destination_binding_revision
```
`causation_id` is optional by the application contract.
Existing legacy columns remain populated for canonical rows with this compatibility projection:
```text
event_id = canonical event id
aggregate_id = canonical aggregate id
event_type = contract id
payload = exact envelope bytes decoded as strict UTF-8
occurred_at = canonical occurred-at
status = PENDING
attempt_count = 0
next_attempt_at = occurred-at
correlation_id = canonical correlation id
idempotency_key = event id
```
The canonical encoder currently emits a UTF-8 JSON envelope. The append adapter verifies strict UTF-8 round-trip before storing the compatibility text. Invalid UTF-8 fails the business transaction; replacement characters are forbidden.
`partitionKeyBytes` is not stored separately because the canonical model already requires it to be exactly the US-ASCII bytes of `partitionKeyText`. The claimed model reconstructs those bytes from the stored canonical text.
## Persistence adapters
`OutboxStoreAdapter` remains the legacy claim/status store and implements `LegacyOutboxAppendPort`, not `OutboxAppendPort`.
A separate `CanonicalOutboxAppendAdapter` implements `OutboxAppendPort`. It participates in the caller's existing write transaction exactly like the legacy adapter and never opens a local transaction.
Both write the same `outbox_event` table; they are alternative semantic inputs, not dual writers for one business fact.
## Activation
Introduce:
```text
ca-skeleton.outbox.canonical-transport-enabled=false
```
Default remains false.
When false:
- existing legacy append/relay behavior is unchanged;
- canonical append bean is not exposed;
- canonical relay routing is not considered an active deployment capability.
When true:
- canonical append bean is exposed;
- startup requires an `IntegrationEventPublishPort`;
- the relay publisher can route canonical claimed rows to that port;
- legacy rows continue through `MessageBroker`;
- while mixed legacy rows may still exist, a relay-enabled deployment still requires the legacy broker. Canonical transport is an additional route, not permission to strand legacy backlog.
The gate is a compatibility/cutover gate only. It does not change DB publication authority and does not activate `POLLING_V2`.
## Publish routing
`OutboxMessagePublishPort` remains the one relay publish port and accepts `ClaimedOutboxEvent`.
Implementation behavior:
- `OutboxEvent` -> existing `OutboxEnvelopeJson` + `MessageBroker`.
- `CanonicalClaimedOutboxEvent` -> exact stored `ValidatedIntegrationEvent` -> `IntegrationEventPublishPort`.
The canonical branch blocks on the returned `CompletionStage` only at this legacy compatibility boundary, because the current legacy relay port is synchronous. The platform result is mapped unchanged into `OutboxPublishOutcome`.
The bridge does not re-encode canonical bytes.
If canonical transport is disabled or the canonical publisher is absent, canonical publication fails closed before broker/platform transmission. Startup validation prevents the normal configured case from reaching that state.
## Outcome policy
The existing legacy relay state machine remains authoritative in this slice:
- CONFIRMED -> mark PUBLISHED.
- AMBIGUOUS -> retryable legacy FAILED flow.
- REJECTED_BEFORE_SEND / REJECTED_AFTER_BROKER -> existing definite-refusal DEAD behavior.
This is intentionally the existing compatibility semantics. The richer v2 per-attempt state machine is a later storage-authority cutover.
## Non-goals
This slice does not:
- switch `OutboxPublicationAuthority` to `POLLING_V2`;
- mutate/reconcile `outbox_event_log_v2` or `outbox_delivery_v2`;
- implement CDC;
- remove `MessageBroker`, `KafkaSender`, `NewOutboxEvent`, `OutboxEvent`, or the legacy scheduler;
- migrate old rows into canonical rows;
- invent tenant, trace, schema or routing metadata for old rows.
## Verification
Required:
1. application port split compiles and old raw producers use `LegacyOutboxAppendPort`;
2. migration integration proves additive columns, exact BYTEA, constraints and legacy compatibility;
3. canonical append adapter round-trips every canonical field and exact bytes;
4. partial canonical row mapping fails closed;
5. legacy row mapping remains unchanged;
6. relay unit test proves canonical row invokes only `IntegrationEventPublishPort`;
7. legacy row invokes only `MessageBroker`;
8. canonical bytes reaching `PlatformIntegrationEventPublishAdapter` are byte-identical;
9. startup rejects canonical transport enabled without `IntegrationEventPublishPort`;
10. default-off composition preserves current behavior;
11. architecture/dependency checks and `git diff --check` pass.