# 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`. 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.