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

195 lines
9.5 KiB
Markdown

# 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<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](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).