7.5 KiB
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:
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:
void append(ValidatedIntegrationEvent event);
Legacy append
Raw R0 payload append moves to an explicitly named compatibility port:
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:
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_idtovarchar(96); - widens
correlation_idtovarchar(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:
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:
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:
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-> existingOutboxEnvelopeJson+MessageBroker.CanonicalClaimedOutboxEvent-> exact storedValidatedIntegrationEvent->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
OutboxPublicationAuthoritytoPOLLING_V2; - mutate/reconcile
outbox_event_log_v2oroutbox_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:
- application port split compiles and old raw producers use
LegacyOutboxAppendPort; - migration integration proves additive columns, exact BYTEA, constraints and legacy compatibility;
- canonical append adapter round-trips every canonical field and exact bytes;
- partial canonical row mapping fails closed;
- legacy row mapping remains unchanged;
- relay unit test proves canonical row invokes only
IntegrationEventPublishPort; - legacy row invokes only
MessageBroker; - canonical bytes reaching
PlatformIntegrationEventPublishAdapterare byte-identical; - startup rejects canonical transport enabled without
IntegrationEventPublishPort; - default-off composition preserves current behavior;
- architecture/dependency checks and
git diff --checkpass.