Files
alkstore/docs/architecture/decisions/014-outbox-tx-enqueue.md
T

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:

  1. The derived backing queue name __alkstore_outbox:{name} (ADR-008 §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 §1, §2), and TxHandle has 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 → else ReservedName — the same entry-point validation store.outbox(name) applies, per ADR-008 §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 §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). 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-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'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.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'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_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) 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 §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. (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 (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 — 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 EnqueueOpts rides.
  • ADR-002 — 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).