10 KiB
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 §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) — 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 §3/§3a). But the enqueue side
has no method. The hole, stated precisely:
- The derived backing queue name
__alkstore_outbox:{name}(ADR-008 §4) carries the reserved prefix, and every name-bearing entry point and its*_txcounterpart rejects that prefix withReservedName— sohandle.enqueue_tx("__alkstore_outbox:x", …)is contract-illegal by construction. Outbox(whatstore.outbox(name)returns) is not in theTxHandlemethod set (ADR-008 §1, §2), andTxHandlehas no outbox method of any name.
So as pinned by ADR-008, 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, 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;
ADR-008 §2).
The evidence base is complete — the contract surfaces (both ADRs), the honker source (read at revision), the reserved-namespace rules, and ADR-010 §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:
trait TxHandle {
enqueue_tx(queue, opts, payload) -> job_id
publish_tx(stream, payload) -> offset
publish_with_key_tx(stream, key, payload) -> offset // ADR-015,
// same day — not in this
// ADR's original sketch
notify_tx(channel, payload)
save_offset_tx(stream, consumer, offset)
outbox_enqueue_tx(outbox, opts, payload) -> job_id // the addition
commit(self: Box<Self>) -> 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 → elseReservedName— the same entry-point validationstore.outbox(name)applies, per ADR-008 §4's rule that*_txcounterparts validate identically) and derives the backing queue name__alkstore_outbox:{outbox}engine-side. No priorstore.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'srun_onceconsumption of the same backing queue already is. - Opts carry
EnqueueOptswhole — same type, same position (name, opts, payload) asenqueue_tx. Rationale: ADR-010 §3a makes enqueue the stamping point —EnqueueOpts.max_attemptsis the one per-job override, resolved over the backing queue's derivedQueueOpts(visibility 60 s, max_attempts 5, backoff base 5 s; ADR-010 §3) — and the auto-commit siblingoutbox.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 viaget_jobno 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). 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 §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-
Transactionseam is not inherited. There is no engine-neutralTransactiontype to put in a core-crate signature — rusqlite'sTransactionand tokio-postgres's client are different driver types — so routing through one would force either a core dependency on engine types (inverting ADR-001's dependency direction) or a per-engine downcast seam (exactly what ADR-008 §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.enqueueauto-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's seam. ADR-008's outbox bullet carries a pointer annotation, mirroring how ADR-010 §8's SQLite half recorded its supersession. (Codified 2026-10-06 as versioning class 1 by ADR-017 — and bounded by it: this amend-in-place frame terminates at the core crate's first release.)
- Contract-suite row:
outbox_enqueue_txcommit-atomicity on both engines — rollback drops the backing-queue job row with the business write (no ghost job); commit makes it claimable byrun_once; reserved/empty outbox names rejected identically on the tx path (ReservedName/InvalidName); the stamped opts visible viaget_jobidentical across engines (added to core-contract.md's verification backlog).
Consequences
Positive
- The outbox's scope row
(ADR-002) becomes implementable: both halves
(commit-atomic enqueue,
run_oncedelivery) 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 §5).
- New engines stay purely additive: one more trait method, same
implementation pattern as the other
*_txops.
Negative
TxHandlegrows 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_txby 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-commitoutbox.enqueueor txoutbox_enqueue_tx) — itself the guarantee that the derivation cannot be collided with. (Guarantee completed 2026-10-07 by ADR-021 §3:schedule()'s queue argument now rejects the reserved prefix too — without that pin, a schedule row storing__alkstore_outbox:{name}as its fire target would have been a third write path into the backing queue.)
References
- OQ-13 (
docs/architecture/open-questions.md) — this ADR's resolution; the evidence and option set sketched there. - ADR-008 §1 (outbox bullet, annotated),
§2 (
TxHandleshape — the method joins its list), §4 (reserved namespace — the rejection this method is designed around), §5 (error taxonomy — reused, not extended). - ADR-007 — the seam whose property this method restores for the outbox; the no-ghosts property.
- ADR-010 §3/§3a — the backing queue's
derived opts and the enqueue-time stamping this method's
EnqueueOptsrides. - ADR-002 — the outbox scope row whose enqueue half this completes.
- Honker's seam (
/workspace/honkerpackages/honker-rs/src/lib.rsOutbox::enqueue_tx@ f4e53c6) — the reference checkout's raw-Transactionshape, read and rejected (§3). - OQ-10 — unaffected (amendment framing, §4).