# ADR-014: Transactional outbox enqueue — `outbox_enqueue_tx` on the `TxHandle` trait ## Status Accepted (2026-10-05, Phase 1 — OQ-13's resolution; amends [ADR-008](008-contract-v1-pinning.md) §1's outbox and tx-seam bullets and §2's method list in place, pre-implementation) ## Context The outbox is "enqueue inside the business transaction + delivery workers" ([ADR-002](002-feature-scope.md)) — the commit-atomic enqueue is the mechanism's *only reason to exist*; the delivery side (`run_once` pull semantics, the backing queue's derived `QueueOpts`) is pinned (core-contract.md outbox section; [ADR-010](010-queue-semantics-depth.md) §3/§3a). But the enqueue side has no method. The hole, stated precisely: 1. The derived backing queue name `__alkstore_outbox:{name}` ([ADR-008](008-contract-v1-pinning.md) §4) carries the reserved prefix, and every name-bearing entry point *and its `*_tx` counterpart* rejects that prefix with `ReservedName` — so `handle.enqueue_tx("__alkstore_outbox:x", …)` is contract-illegal by construction. 2. `Outbox` (what `store.outbox(name)` returns) is not in the `TxHandle` method set ([ADR-008](008-contract-v1-pinning.md) §1, §2), and `TxHandle` has no outbox method of any name. So as pinned by [ADR-008](008-contract-v1-pinning.md), a consumer can open a transaction, write business rows, and have *no way* to put an outbox send inside it — the load-bearing property ([ADR-007](007-transactional-seam.md), guiding principle 2) is unreachable for this mechanism. Honker solves the same need with a different seam: `outbox.enqueue_tx(&tx, payload, opts)` routes through the caller's raw `Transaction` (`/workspace/honker @ f4e53c6`, `packages/honker-rs/src/lib.rs:506-513`) — a seam our handle design deliberately does not have: the handle *is* the tx, opaque, no downcast, no driver types in the contract ([ADR-007](007-transactional-seam.md); [ADR-008](008-contract-v1-pinning.md) §2). The evidence base is complete — the contract surfaces (both ADRs), the honker source (read at revision), the reserved-namespace rules, and [ADR-010](010-queue-semantics-depth.md) §3a's stamping resolution. No POC is needed: this is a contract-surface gap, not a semantics debate. ## Decision ### 1. `outbox_enqueue_tx(outbox, opts, payload) -> job_id` on the `TxHandle` trait One method, added to the `TxHandle` trait: ```text trait TxHandle { enqueue_tx(queue, opts, payload) -> job_id publish_tx(stream, payload) -> offset notify_tx(channel, payload) save_offset_tx(stream, consumer, offset) outbox_enqueue_tx(outbox, opts, payload) -> job_id // the addition commit(self: Box) -> Result<()> // or rollback } ``` - **The method takes the outbox name, not the derived queue name.** The engine validates the outbox name (non-empty → else `InvalidName`; reserved-prefix → else `ReservedName` — the same entry-point validation `store.outbox(name)` applies, per [ADR-008](008-contract-v1-pinning.md) §4's rule that `*_tx` counterparts validate identically) and derives the backing queue name `__alkstore_outbox:{outbox}` *engine-side*. No prior `store.outbox(name)` handle is required — outboxes are names, not registered objects (the queues posture). The outbox name is used verbatim in the derivation; no further charset/length rule is pinned in v1 (both engines treat the derived value as an opaque storage key). The reserved-prefix rejection is not bypassed: it governs *directly-supplied* names in the queue/channel/stream/lock namespaces, while the name this method takes is an **outbox name** — a distinct name kind whose derived name happens to land under the prefix. The derivation is legitimate engine-internal naming, exactly as the auto-commit path's `run_once` consumption of the same backing queue already is. - **Opts carry `EnqueueOpts` whole** — same type, same position (name, opts, payload) as `enqueue_tx`. Rationale: ADR-010 §3a makes enqueue the stamping point — `EnqueueOpts.max_attempts` is the one per-job override, resolved over the backing queue's derived `QueueOpts` (visibility 60 s, max_attempts 5, backoff base 5 s; [ADR-010](010-queue-semantics-depth.md) §3) — and the auto-commit sibling `outbox.enqueue(payload, opts)` carries opts (honker parity). A tx form that dropped opts would be asymmetric with its own auto-commit twin for no gain; and stamping is what makes the job's behavior inspectable via `get_job` no matter which form enqueued it. - **The property delivered**: the outbox send sits in the caller's transaction — commit makes the backing-queue job row visible to `run_once`/delivery exactly when the business write commits; rollback drops both (the no-ghosts property, [ADR-007](007-transactional-seam.md)). Transactional local-adjacency (guiding principle 2) is restored for the outbox — the mechanism exists to carry it. ### 2. Why not an `OutboxHandle` off the tx handle — option (b) `handle.outbox(name) -> OutboxHandle` (with enqueue) mirrors the store-side handle pattern, but it costs a second boxed mechanism handle carried across the transaction to deliver exactly one needed operation. Every new engine implements another trait for one method; the "no downcast, no extra hops" rationale of [ADR-008](008-contract-v1-pinning.md) §2 applies with force — a one-method handle is surface weight, not symmetry. Option (a) is the smallest surface that restores the property. ### 3. Explicit rejections - **Honker's raw-`Transaction` seam is not inherited.** There is no engine-neutral `Transaction` type to put in a core-crate signature — rusqlite's `Transaction` and tokio-postgres's client are different driver types — so routing through one would force either a core dependency on engine types (inverting [ADR-001](001-crate-split.md)'s dependency direction) or a per-engine downcast seam (exactly what [ADR-008](008-contract-v1-pinning.md) §2 dissolved). Honker's shape is evidence that the *property* must exist, not that this *seam* should. - **Non-transactional-only (option (c)) is rejected outright.** `outbox.enqueue` auto-commit alone drops the commit-atomic property — the outbox would degrade to a queues alias, defeating its scope row's purpose. Listed to be rejected, as the OQ required. ### 4. Amendment framing and verification - **This completes contract v1 in place, pre-implementation.** No artifact is released (no crate exists), so no versioning event occurs — OQ-10's discipline is untouched by this; the method joins the v1 surface partition's tx-seam bullet and [core-contract.md](../core-contract.md)'s seam. ADR-008's outbox bullet carries a pointer annotation, mirroring how ADR-010 §8's SQLite half recorded its supersession. - **Contract-suite row**: `outbox_enqueue_tx` commit-atomicity on both engines — rollback drops the backing-queue job row with the business write (no ghost job); commit makes it claimable by `run_once`; reserved/empty outbox names rejected identically on the tx path (`ReservedName`/`InvalidName`); the stamped opts visible via `get_job` identical across engines (added to core-contract.md's verification backlog). ## Consequences **Positive** - The outbox's scope row ([ADR-002](002-feature-scope.md)) becomes implementable: both halves (commit-atomic enqueue, `run_once` delivery) now have pinned surfaces — decomposition off the current text no longer produces an unperformable core operation. - Smallest possible addition: one method, one existing opts type, one job-id return — no new handle type, no new error variant (the validation failures reuse the pinned taxonomy, [ADR-008](008-contract-v1-pinning.md) §5). - New engines stay purely additive: one more trait method, same implementation pattern as the other `*_tx` ops. **Negative** - `TxHandle` grows by one engine-implemented method — the trait cost every engine inherits even if it never uses the outbox (the same per-method cost the rest of the seam carries; measured negligible against commit costs, ADR-008 §2). - A mild surface asymmetry: the tx handle enqueues into queues *by queue name* but into outboxes *by outbox name* (the derived backing queue is unreachable by `enqueue_tx` by design). This is the reserved-namespace rule doing its job, but it is a thing consumer documentation must state plainly: **there is no way to enqueue into the backing queue except through the outbox surface** (auto-commit `outbox.enqueue` or tx `outbox_enqueue_tx`) — itself the guarantee that the derivation cannot be collided with. ## References - OQ-13 (`docs/architecture/open-questions.md`) — this ADR's resolution; the evidence and option set sketched there. - [ADR-008](008-contract-v1-pinning.md) §1 (outbox bullet, annotated), §2 (`TxHandle` shape — the method joins its list), §4 (reserved namespace — the rejection this method is designed around), §5 (error taxonomy — reused, not extended). - [ADR-007](007-transactional-seam.md) — the seam whose property this method restores for the outbox; the no-ghosts property. - [ADR-010](010-queue-semantics-depth.md) §3/§3a — the backing queue's derived opts and the enqueue-time stamping this method's `EnqueueOpts` rides. - [ADR-002](002-feature-scope.md) — the outbox scope row whose enqueue half this completes. - Honker's seam (`/workspace/honker` `packages/honker-rs/src/lib.rs` `Outbox::enqueue_tx` @ f4e53c6) — the reference checkout's raw- `Transaction` shape, read and rejected (§3). - OQ-10 — unaffected (amendment framing, §4).