ADR-014: transactional outbox enqueue — outbox_enqueue_tx on TxHandle (OQ-13 resolved)
This commit is contained in:
1 parent
d401908f13
commit
04a04651dc
8 files changed
+359
-122
No files matched your search
@@ -24,9 +24,9 @@ pending architecture review and OQ resolution.
|
||||
| Doc | Status | Purpose | Key OQs |
|
||||
|---|---|---|---|
|
||||
| [overview.md](overview.md) | draft | Crate family, feature surface, non-goals, evidence base | — |
|
||||
| [core-contract.md](core-contract.md) | draft | The unified trait surface, delivery guarantees, tx seam | OQ-08, OQ-10, OQ-12, OQ-13 |
|
||||
| [engine-sqlite.md](engine-sqlite.md) | draft | SQLite engine: forked-substrate/rusqlite mapping | OQ-06 (resolved), OQ-12, OQ-13 |
|
||||
| [engine-postgres.md](engine-postgres.md) | draft | Postgres engine: tokio-postgres/LISTEN mapping | OQ-05 (resolved), OQ-08, OQ-12, OQ-13 |
|
||||
| [core-contract.md](core-contract.md) | draft | The unified trait surface, delivery guarantees, tx seam | OQ-08, OQ-10, OQ-12 |
|
||||
| [engine-sqlite.md](engine-sqlite.md) | draft | SQLite engine: forked-substrate/rusqlite mapping | OQ-06 (resolved), OQ-12 |
|
||||
| [engine-postgres.md](engine-postgres.md) | draft | Postgres engine: tokio-postgres/LISTEN mapping | OQ-05 (resolved), OQ-08, OQ-12 |
|
||||
| [queues.md](queues.md) | draft | Queue/scheduler/outbox semantics depth (resolved: ADR-009/ADR-010) | OQ-06 (resolved) |
|
||||
| [deployment.md](deployment.md) | draft | Host semantics, connection budgets, knobs, matrix | OQ-08 |
|
||||
| [open-questions.md](open-questions.md) | draft | OQ tracker (promoted from OQ-ST register) | — |
|
||||
@@ -48,6 +48,7 @@ pending architecture review and OQ resolution.
|
||||
| [011](decisions/011-sqlite-substrate-fork.md) | SQLite substrate — fork honker-core into owned code | Accepted |
|
||||
| [012](decisions/012-forked-substrate-design.md) | Forked substrate design — contract-blind boundary, fidelity posture, port deltas | Accepted |
|
||||
| [013](decisions/013-fold-substrate-into-sqlite.md) | Fold the forked substrate into `alkstore-sqlite` — no fourth crate | Accepted |
|
||||
| [014](decisions/014-outbox-tx-enqueue.md) | Transactional outbox enqueue — `outbox_enqueue_tx` on the `TxHandle` trait | Accepted |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -58,10 +59,8 @@ suggested resolution order:
|
||||
|
||||
- **OQ-12** (high): streams depth — key semantics, `StreamEvent`
|
||||
shape, ordering guarantee row, retention (Phase 1 review find;
|
||||
evidence gathered in the OQ).
|
||||
- **OQ-13** (high): transactional outbox enqueue shape — the v1
|
||||
`TxHandle` surface cannot express the outbox's one load-bearing
|
||||
operation (Phase 1 review find; option set sketched in the OQ).
|
||||
evidence gathered in the OQ; check consumer rows for a
|
||||
per-key-ordering need before choosing among its options).
|
||||
- **OQ-08** (medium): capability-surface shape.
|
||||
- **OQ-10** (medium): contract versioning across engine crates.
|
||||
- **OQ-11** (medium): forked-substrate follow-through (provenance
|
||||
@@ -76,7 +75,9 @@ Resolved (kept with resolutions): OQ-01 (feature scope), OQ-02
|
||||
scheduler guarantee row pinned), ~~OQ-05~~ (queue semantics depth —
|
||||
[ADR-010](decisions/010-queue-semantics-depth.md)), ~~OQ-06~~
|
||||
(honker-core quality read — fork fired,
|
||||
[ADR-011](decisions/011-sqlite-substrate-fork.md)).
|
||||
[ADR-011](decisions/011-sqlite-substrate-fork.md)), ~~OQ-13~~
|
||||
(transactional outbox enqueue shape —
|
||||
[ADR-014](decisions/014-outbox-tx-enqueue.md)).
|
||||
|
||||
No deferred OQs: all open questions are actionable Phase 1 work with
|
||||
complete evidence bases.
|
||||
|
||||
@@ -13,7 +13,9 @@ inventory-confirmed features — [ADR-002](decisions/002-feature-scope.md)
|
||||
— and pinned against it. The base surface is pinned by
|
||||
[ADR-008](decisions/008-contract-v1-pinning.md) (contract v1 partition,
|
||||
`TxHandle` representation, wake type, reserved strings, error
|
||||
taxonomy, config split); ADR-009/ADR-010 add the first *post-v1
|
||||
taxonomy, config split — amended in place, pre-implementation, by
|
||||
[ADR-014](decisions/014-outbox-tx-enqueue.md): `outbox_enqueue_tx`
|
||||
joins the `TxHandle` trait); ADR-009/ADR-010 add the first *post-v1
|
||||
contract extensions* (scheduler collapse surface, `QueueOpts` depth —
|
||||
versioning discipline for such extensions is OQ-10's); the
|
||||
*obligations* are this document.
|
||||
@@ -57,10 +59,16 @@ trait TxHandle {
|
||||
publish_tx(stream, payload) -> event_id
|
||||
notify_tx(channel, payload)
|
||||
save_offset_tx(stream, consumer, offset)
|
||||
outbox_enqueue_tx(outbox, opts, payload) -> job_id
|
||||
commit(self: Box<Self>) -> Result<()> // or rollback
|
||||
}
|
||||
```
|
||||
|
||||
`outbox_enqueue_tx` takes the **outbox name** and derives the backing
|
||||
queue engine-side (the reserved prefix makes the derived name
|
||||
unreachable by `enqueue_tx` by design —
|
||||
[ADR-014](decisions/014-outbox-tx-enqueue.md)).
|
||||
|
||||
Mechanism handles come off the store (or, for transactional variants,
|
||||
off the handle):
|
||||
|
||||
@@ -68,9 +76,9 @@ off the handle):
|
||||
store.notify(channel, payload) handle.notify_tx(channel, payload)
|
||||
store.stream(name) -> Stream handle.publish_tx / save_offset_tx
|
||||
store.queue(name, opts) -> Queue handle.enqueue_tx
|
||||
store.outbox(name) -> Outbox handle.outbox_enqueue_tx(outbox, opts, payload)
|
||||
store.try_lock(name, owner, ttl) -> Option<Lock>
|
||||
store.listen(channel) -> Box<dyn WakeReceiver>
|
||||
store.outbox(name) -> Outbox
|
||||
store.schedule(name, spec, queue, payload, opts) -> Result<Schedule>
|
||||
store.unschedule(name) -> bool
|
||||
store.run_schedules(stop) -> Result<()>
|
||||
@@ -238,9 +246,22 @@ delivery slower than the backing queue's stamped visibility timeout
|
||||
(default 60 s for outbox-backed queues) can have its claim expire
|
||||
mid-delivery and be redelivered — the dual-execution window;
|
||||
idempotent delivery is the consumer's obligation, same as queues.
|
||||
The transactional *enqueue* side's shape is OQ-13's (the v1 surface
|
||||
has a real hole there — see
|
||||
[open-questions.md](open-questions.md)).
|
||||
|
||||
The transactional enqueue side is `outbox_enqueue_tx(outbox, opts,
|
||||
payload)` on the `TxHandle` trait
|
||||
([ADR-014](decisions/014-outbox-tx-enqueue.md)): it takes the outbox
|
||||
name (validated like `store.outbox(name)` — empty → `InvalidName`,
|
||||
reserved-prefixed → `ReservedName`), derives the backing queue name
|
||||
engine-side, and lands the job row in the caller's transaction with
|
||||
`EnqueueOpts` stamped per [ADR-010](decisions/010-queue-semantics-
|
||||
depth.md) §3a over the backing queue's derived `QueueOpts`. Commit
|
||||
makes the job visible to `run_once` exactly when the business write
|
||||
commits; rollback drops both (the no-ghosts property,
|
||||
[ADR-007](decisions/007-transactional-seam.md)). The derived backing
|
||||
queue name is not expressible through `enqueue_tx` (the reserved
|
||||
prefix is rejected on directly-supplied names) —
|
||||
`outbox_enqueue_tx` is the only transactional path into it, which is
|
||||
itself the guarantee that the derivation cannot be collided with.
|
||||
|
||||
### scheduler
|
||||
|
||||
@@ -384,6 +405,13 @@ before the engine specs are called `stable`:
|
||||
lapsed), the post-lapse-ack-refusal (at-least-once), and the
|
||||
ack-vs-reclaim race (one wins atomically); both engines' SQL pin
|
||||
identical outcomes in the contract suite.
|
||||
- **`outbox_enqueue_tx` commit-atomicity on both engines**
|
||||
([ADR-014](decisions/014-outbox-tx-enqueue.md)) — rollback drops
|
||||
the backing-queue job row together 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`); stamped opts visible via `get_job`
|
||||
equal for both engines.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
@@ -398,6 +426,7 @@ before the engine specs are called `stable`:
|
||||
| [010](decisions/010-queue-semantics-depth.md) | Queue depth (post-v1 extension) | visibility/renewal, opts stamping, backoff curve, dead-letter, sweep, layout |
|
||||
| [011](decisions/011-sqlite-substrate-fork.md) | Substrate fork | SQLite substrate owned (`__alkstore_*` naming); queue ops re-derived on contract v1 |
|
||||
| [012](decisions/012-forked-substrate-design.md) | Fork design | contract-blind substrate boundary; engine-side formula arithmetic pinned equivalent by the contract suite |
|
||||
| [014](decisions/014-outbox-tx-enqueue.md) | Outbox tx enqueue (amends 008) | `outbox_enqueue_tx` on `TxHandle`; outbox-name validation; derived backing queue reached only through the outbox surface |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -410,10 +439,9 @@ questions affecting this document:
|
||||
- **OQ-08**: capability-surface shape ([open](open-questions.md))
|
||||
- **OQ-12**: streams depth — key semantics, `StreamEvent` shape,
|
||||
ordering row, retention ([open](open-questions.md))
|
||||
- **OQ-13**: transactional outbox enqueue shape
|
||||
([open](open-questions.md))
|
||||
|
||||
Resolved on this document's surface: **OQ-09** (scheduler collapse —
|
||||
[ADR-009](decisions/009-scheduler-collapse.md)) and **OQ-05** (queue
|
||||
[ADR-009](decisions/009-scheduler-collapse.md)), **OQ-05** (queue
|
||||
semantics depth — [ADR-010](decisions/010-queue-semantics-depth.md)),
|
||||
2026-10-05.
|
||||
and **OQ-13** (transactional outbox enqueue shape —
|
||||
[ADR-014](decisions/014-outbox-tx-enqueue.md)), 2026-10-05.
|
||||
@@ -64,13 +64,17 @@ implement identically):
|
||||
and `release`.
|
||||
- outbox helper — `outbox(name)` with `enqueue` + `run_once` delivery
|
||||
worker. *(Depth annotation 2026-10-05: the transactional *enqueue*
|
||||
shape on the `TxHandle` seam is OQ-13's — the v1 method list has no
|
||||
outbox enqueue method and the derived backing queue's reserved
|
||||
prefix makes plain `enqueue_tx` illegal; see
|
||||
[open-questions.md](../open-questions.md). `run_once` worker
|
||||
semantics are pinned in core-contract.md's outbox section.)*
|
||||
shape was OQ-13's — this §1/§2 method list had no outbox enqueue
|
||||
method and the derived backing queue's reserved prefix made plain
|
||||
`enqueue_tx` illegal; resolved by
|
||||
[ADR-014](014-outbox-tx-enqueue.md) — `outbox_enqueue_tx` joins the
|
||||
`TxHandle` trait. `run_once` worker semantics are pinned in
|
||||
core-contract.md's outbox section.)*
|
||||
- the tx seam — `begin_tx` / commit / rollback and the `*_tx` methods
|
||||
([ADR-007](007-transactional-seam.md)).
|
||||
([ADR-007](007-transactional-seam.md)). *(Amended 2026-10-05 by
|
||||
[ADR-014](014-outbox-tx-enqueue.md):
|
||||
`outbox_enqueue_tx(outbox, opts, payload)` added to the
|
||||
`TxHandle` trait.)*
|
||||
- `subscribe(consumer) -> Box<dyn EventReceiver>` — the durable
|
||||
stream subscription handle, delivering `StreamEvent`s with an
|
||||
explicit `save_offset` on the receiver (no auto-checkpoint;
|
||||
@@ -113,6 +117,7 @@ trait TxHandle {
|
||||
publish_tx(stream, payload) -> event_id
|
||||
notify_tx(channel, payload)
|
||||
save_offset_tx(stream, consumer, offset)
|
||||
outbox_enqueue_tx(outbox, opts, payload) -> job_id // ADR-014
|
||||
commit(self: Box<Self>) -> Result<()> // or rollback
|
||||
}
|
||||
```
|
||||
@@ -195,7 +200,8 @@ struct Wake { channel: String }
|
||||
- **Reserved prefix: `__alkstore_`** (leading double underscore). The
|
||||
namespace exists per [ADR-006](006-wake-and-delivery-contract.md);
|
||||
this pins its string. Engine-independent — applies to channel,
|
||||
stream, queue, and lock names on every engine.
|
||||
stream, queue, lock, and outbox names on every engine *(outbox names
|
||||
added to the kinds list by [ADR-014](014-outbox-tx-enqueue.md))*.
|
||||
- **The one v1-reserved string:**
|
||||
`__alkstore_listener_reconnected__` — the Postgres forwarder's
|
||||
synthetic reconnect-wake channel ([ADR-004](004-postgres-driver.md)).
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# 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) -> event_id
|
||||
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).
|
||||
@@ -109,6 +109,7 @@ the record in [ADR-003](decisions/003-sqlite-driver.md).
|
||||
| [009](decisions/009-scheduler-collapse.md) | Scheduler collapse | schedule rows in the engine schema, re-derived tick, row-locked fire tx, `__alkstore_scheduler` leadership |
|
||||
| [010](decisions/010-queue-semantics-depth.md) | Queue depth | job-stamped opts, equal-jitter backoff, dead-letter move, no-stranded-rows sweep, one engine-owned schema |
|
||||
| [012](decisions/012-forked-substrate-design.md) | Fork design | contract-blind substrate (SQLite side); pg engine owns its own curve/stamps arithmetic, equivalence pinned by the contract suite |
|
||||
| [014](decisions/014-outbox-tx-enqueue.md) | Outbox tx enqueue | `outbox_enqueue_tx` on `TxHandle`; derivation engine-side inside the caller's tx |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -121,8 +122,12 @@ questions affecting this document:
|
||||
- **OQ-12**: streams depth (affects this engine's stream realization
|
||||
— event-shape/ordering/retention equivalents on the pg side)
|
||||
([open](open-questions.md))
|
||||
- **OQ-13**: transactional outbox enqueue shape (affects this
|
||||
engine's `TxHandle` impl) ([open](open-questions.md))
|
||||
- **OQ-13**: transactional outbox enqueue shape — **resolved**
|
||||
(2026-10-05, [ADR-014](decisions/014-outbox-tx-enqueue.md)):
|
||||
`outbox_enqueue_tx(outbox, opts, payload)` on the `TxHandle` trait;
|
||||
the pg engine validates the outbox name and derives
|
||||
`__alkstore_outbox:{name}` engine-side inside the caller's
|
||||
transaction.
|
||||
|
||||
Resolved: **OQ-09** (scheduler collapse —
|
||||
[ADR-009](decisions/009-scheduler-collapse.md)) and **OQ-05** (queue
|
||||
|
||||
@@ -125,6 +125,7 @@ family is `__alkstore_*` (ADR-010 §8's naming authorization).
|
||||
| [011](decisions/011-sqlite-substrate-fork.md) | Substrate fork | OQ-06's trigger fired; substrate owned, queue ops re-derived on contract v1 |
|
||||
| [012](decisions/012-forked-substrate-design.md) | Fork design | contract-blind substrate, fidelity posture, port deltas, panic-free watcher death |
|
||||
| [013](decisions/013-fold-substrate-into-sqlite.md) | Substrate packaging | folded into `alkstore-sqlite` (`src/substrate/`); no fourth crate |
|
||||
| [014](decisions/014-outbox-tx-enqueue.md) | Outbox tx enqueue | `outbox_enqueue_tx` on `TxHandle`; derivation engine-side through the writer-slot lease |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -137,8 +138,12 @@ questions affecting this document:
|
||||
[ADR-011](decisions/011-sqlite-substrate-fork.md); trigger fired).
|
||||
- **OQ-12**: streams depth (affects this engine's stream realization
|
||||
— key column, event shape, retention) ([open](open-questions.md))
|
||||
- **OQ-13**: transactional outbox enqueue shape (affects this
|
||||
engine's `TxHandle` impl) ([open](open-questions.md))
|
||||
- **OQ-13**: transactional outbox enqueue shape — **resolved**
|
||||
(2026-10-05, [ADR-014](decisions/014-outbox-tx-enqueue.md)):
|
||||
`outbox_enqueue_tx(outbox, opts, payload)` on the `TxHandle` trait;
|
||||
the SQLite handle routes the op through the writer-slot lease
|
||||
([ADR-007](decisions/007-transactional-seam.md)) into the forked
|
||||
substrate's `__alkstore_outbox:{name}` backing queue.
|
||||
|
||||
Resolved: **OQ-09** (scheduler collapse —
|
||||
[ADR-009](decisions/009-scheduler-collapse.md)) and **OQ-05** (queue
|
||||
|
||||
@@ -21,18 +21,19 @@ boundaries, fidelity posture, port deltas; its substrate-side residue
|
||||
is OQ-11); **the fork packaging folded** (2026-10-05,
|
||||
[ADR-013](decisions/013-fold-substrate-into-sqlite.md) — no
|
||||
`alkstore-substrate` crate; the forked machinery is a module subtree of
|
||||
`alkstore-sqlite`). Next: OQ-08 (rides the now-pinned trait shape and the
|
||||
ADR-011 substrate fork), OQ-10 (versioning discipline for contract
|
||||
extensions; note ADR-011 changes its substrate-side facts for SQLite —
|
||||
the forked machinery lives in-tree inside the engine crate per
|
||||
[ADR-013](decisions/013-fold-substrate-into-sqlite.md), so the
|
||||
engine/core contract pairing is what the discipline must track), OQ-11
|
||||
(fork follow-through items — substrate-side, non-consumer-facing),
|
||||
**OQ-12** (streams depth — a Phase 1 review find: key semantics,
|
||||
`StreamEvent` shape, ordering row, retention; evidence gathered), and
|
||||
**OQ-13** (the transactional outbox enqueue shape — a Phase 1 review
|
||||
find: the v1 `TxHandle` surface cannot express the outbox's one
|
||||
load-bearing operation).
|
||||
`alkstore-sqlite`); **OQ-13 resolved** (2026-10-05,
|
||||
[ADR-014](decisions/014-outbox-tx-enqueue.md) —
|
||||
`outbox_enqueue_tx` joins the `TxHandle` trait, the hole is closed).
|
||||
Next: **OQ-12** (streams depth — a Phase 1 review
|
||||
find: key semantics, `StreamEvent` shape, ordering row, retention;
|
||||
evidence gathered; check consumer rows for per-key-ordering need
|
||||
before choosing among its options), OQ-08 (rides the now-pinned trait
|
||||
shape and the ADR-011 substrate fork), OQ-10 (versioning discipline
|
||||
for contract extensions; note ADR-011 changes its substrate-side facts
|
||||
for SQLite — the forked machinery lives in-tree inside the engine
|
||||
crate per [ADR-013](decisions/013-fold-substrate-into-sqlite.md), so
|
||||
the engine/core contract pairing is what the discipline must track),
|
||||
OQ-11 (fork follow-through items — substrate-side, non-consumer-facing).
|
||||
|
||||
Resolved questions stay listed with their resolution; they are not
|
||||
deleted.
|
||||
@@ -127,9 +128,10 @@ narrowed to the pinning work its own record already scoped.)*
|
||||
surface; the contract is the trait it returns. (7) Locks guarantee
|
||||
row added to ADR-006's table (TTL-bounded mutual exclusion, silent
|
||||
expiry); the scheduler row is explicitly transferred to OQ-09's
|
||||
resolution. A verification backlog (core-contract.md §Verification
|
||||
resolution. A verification backlog (core-contract.md §Verification
|
||||
backlog) tracks the one-engine-pinned properties.
|
||||
- **Cross-references**: OQ-01, OQ-10, OQ-09, OQ-08.
|
||||
- **Cross-references**: OQ-01, OQ-10, OQ-09, OQ-08; OQ-13 (the one
|
||||
gap the v1 pinning's own Phase 1 review found in it).
|
||||
|
||||
## Theme: Queues and scheduling
|
||||
|
||||
@@ -232,42 +234,6 @@ narrowed to the pinning work its own record already scoped.)*
|
||||
[ADR-011](decisions/011-sqlite-substrate-fork.md); ADR-005's posture
|
||||
calculus applied, not renegotiated.
|
||||
|
||||
## Theme: Deployment and capabilities
|
||||
|
||||
### OQ-08: Where does the honest single-host/multi-host boundary live in the trait surface? *(== OQ-ST-08)*
|
||||
|
||||
- **Origin**: [deployment.md](deployment.md)
|
||||
- **Status**: open
|
||||
- **Priority**: medium
|
||||
- **Resolution**: open. The pg engine is natively multi-host (POC #2
|
||||
verified — no single-host assumption to remove); SQLite is
|
||||
single-machine by nature (file-backed, NFS-two-writers unsupported —
|
||||
honker's honesty posture, inherited by the forked substrate). The
|
||||
unified surface must not pretend SQLite is multi-host. Options:
|
||||
per-engine capability flags (`Store::capabilities()`), a documented
|
||||
deployment matrix only ([deployment.md](deployment.md) carries the
|
||||
facts), or compile-time knowledge only (a consumer choosing the
|
||||
SQLite engine knows). Rides the now-pinned contract shape
|
||||
([ADR-008](decisions/008-contract-v1-pinning.md)): the trait
|
||||
constrains where capability differences can surface.
|
||||
- **Cross-references**: OQ-04, [ADR-006](decisions/006-wake-and-delivery-contract.md), [ADR-012](decisions/012-forked-substrate-design.md) (the substrate inherits the honesty posture).
|
||||
|
||||
### OQ-10: How do engine crates track core-contract version changes?
|
||||
|
||||
- **Origin**: [overview.md](overview.md),
|
||||
[ADR-001](decisions/001-crate-split.md)
|
||||
- **Status**: open
|
||||
- **Priority**: medium
|
||||
- **Resolution**: open. The core crate's trait surface is a contract
|
||||
the engine crates must track ([ADR-001](decisions/001-crate-split.md) negative consequence). What
|
||||
is the versioning/sync discipline — semver-bump-only-when-
|
||||
contract-changes, engines pin core ranges, a contract-compatibility
|
||||
test suite the engines run against the core's trait definitions?
|
||||
What happens to a released engine crate when core makes a contract
|
||||
breaking change?
|
||||
- **Cross-references**: OQ-04 (the contract being versioned — now
|
||||
including its first post-v1 extensions, ADR-009/ADR-010), OQ-02.
|
||||
|
||||
### OQ-11: Forked-substrate follow-through — scaffold, provenance register, and cherry-pick discipline
|
||||
|
||||
- **Origin**: [ADR-012](decisions/012-forked-substrate-design.md)
|
||||
@@ -302,6 +268,46 @@ narrowed to the pinning work its own record already scoped.)*
|
||||
carries the fold's provenance duties in-tree), ADR-011, ADR-012,
|
||||
ADR-013.
|
||||
|
||||
### OQ-10: How do engine crates track core-contract version changes?
|
||||
|
||||
- **Origin**: [overview.md](overview.md),
|
||||
[ADR-001](decisions/001-crate-split.md)
|
||||
- **Status**: open
|
||||
- **Priority**: medium
|
||||
- **Resolution**: open. The core crate's trait surface is a contract
|
||||
the engine crates must track ([ADR-001](decisions/001-crate-split.md) negative consequence). What
|
||||
is the versioning/sync discipline — semver-bump-only-when-
|
||||
contract-changes, engines pin core ranges, a contract-compatibility
|
||||
test suite the engines run against the core's trait definitions?
|
||||
What happens to a released engine crate when core makes a contract
|
||||
breaking change?
|
||||
- **Cross-references**: OQ-04 (the contract being versioned — now
|
||||
including its first post-v1 extensions, ADR-009/ADR-010), OQ-02.
|
||||
|
||||
## Theme: Deployment and capabilities
|
||||
|
||||
### OQ-08: Where does the honest single-host/multi-host boundary live in the trait surface? *(== OQ-ST-08)*
|
||||
|
||||
- **Origin**: [deployment.md](deployment.md)
|
||||
- **Status**: open
|
||||
- **Priority**: medium
|
||||
- **Resolution**: open. The pg engine is natively multi-host (POC #2
|
||||
verified — no single-host assumption to remove); SQLite is
|
||||
single-machine by nature (file-backed, NFS-two-writers unsupported —
|
||||
honker's honesty posture, inherited by the forked substrate). The
|
||||
unified surface must not pretend SQLite is multi-host. Options:
|
||||
per-engine capability flags (`Store::capabilities()`), a documented
|
||||
deployment matrix only ([deployment.md](deployment.md) carries the
|
||||
facts), or compile-time knowledge only (a consumer choosing the
|
||||
SQLite engine knows). Rides the now-pinned contract shape
|
||||
([ADR-008](decisions/008-contract-v1-pinning.md)): the trait
|
||||
constrains where capability differences can surface.
|
||||
- **Cross-references**: OQ-04, [ADR-006](decisions/006-wake-and-delivery-contract.md), [ADR-012](decisions/012-forked-substrate-design.md) (the substrate inherits the honesty posture).
|
||||
|
||||
|
||||
|
||||
## Theme: Core contract (Phase 1 review finds)
|
||||
|
||||
### OQ-12: Streams depth — key semantics, `StreamEvent` shape, ordering row, retention
|
||||
|
||||
- **Origin**: [core-contract.md](core-contract.md) streams section,
|
||||
@@ -356,60 +362,47 @@ narrowed to the pinning work its own record already scoped.)*
|
||||
open), OQ-01 (scope row), ADR-002, ADR-006 (the guarantee table
|
||||
the ordering row extends), ADR-008 §1.
|
||||
|
||||
### OQ-13: Transactional outbox enqueue shape — the `TxHandle` surface cannot express it
|
||||
### OQ-13: Transactional outbox enqueue shape — the `TxHandle` surface cannot express it — **RESOLVED**
|
||||
|
||||
- **Origin**: [core-contract.md](core-contract.md) (outbox section),
|
||||
[ADR-008](decisions/008-contract-v1-pinning.md) §1 (Phase 1
|
||||
architecture review, 2026-10-05)
|
||||
- **Status**: open
|
||||
- **Status**: resolved (2026-10-05, Phase 1 —
|
||||
[ADR-014](decisions/014-outbox-tx-enqueue.md))
|
||||
- **Priority**: high (the outbox's *only reason to exist* is the
|
||||
commit-atomic enqueue; as pinned, the v1 surface cannot perform it
|
||||
— a decomposition off the current text would produce an
|
||||
unimplementable task)
|
||||
- **Resolution**: open. The hole, stated precisely: the outbox is
|
||||
"enqueue inside the business transaction + delivery workers"
|
||||
([ADR-002](decisions/002-feature-scope.md)); the delivery side is
|
||||
pinned (`run_once`, plus plain queue consumption of the backing
|
||||
queue), but the enqueue side has no method. The derived backing
|
||||
queue name `__alkstore_outbox:{name}`
|
||||
([ADR-008](decisions/008-contract-v1-pinning.md) §4) is
|
||||
reserved-prefix — every entry point **rejects** it, so
|
||||
`handle.enqueue_tx("__alkstore_outbox:x", …)` is contract-illegal
|
||||
by construction; and `Outbox` (the handle `store.outbox(name)`
|
||||
returns) is not in the `TxHandle` method set
|
||||
([ADR-008](decisions/008-contract-v1-pinning.md) §1, §2), so there
|
||||
is no `outbox_enqueue_tx` either. Honker solves it with a
|
||||
different seam: `outbox.enqueue_tx(&tx, …)` routes through the
|
||||
caller's raw `Transaction`
|
||||
(`packages/honker-rs/src/lib.rs:506-513`) — a seam our handle
|
||||
design ([ADR-007](decisions/007-transactional-seam.md),
|
||||
[ADR-008](decisions/008-contract-v1-pinning.md) §2 — the handle
|
||||
*is* the tx, no downcast) deliberately does not have. Options
|
||||
(starter set, not exhaustive):
|
||||
(a) `outbox_enqueue_tx(name, payload)` on the `TxHandle` trait —
|
||||
symmetric with the other `*_tx` methods, one method, engine resolves
|
||||
the derived name on the engine side (bypasses the entry-point
|
||||
rejection legitimately, since the *method* takes the outbox name,
|
||||
not the derived queue name);
|
||||
(b) an `OutboxHandle` obtained from the `TxHandle`
|
||||
(`handle.outbox(name) -> OutboxHandle` with enqueue) — more
|
||||
symmetrical with the store-side handle, two hops;
|
||||
(c) non-transactional only — `outbox.enqueue` auto-commit, dropping
|
||||
the commit-atomic property (defeats the purpose; listed to be
|
||||
rejected explicitly).
|
||||
Decision rule: smallest surface that restores the
|
||||
transactional-local-adjacency property (guiding principle 2) for
|
||||
the outbox without opening the reserved namespace; (a) reads
|
||||
strongest against ADR-008 §2's rationale and needs one contract
|
||||
suite row.
|
||||
- **Cross-references**: OQ-04 (the pinning that missed this),
|
||||
OQ-12 (the sibling depth gap found by the same review), ADR-002
|
||||
- **Resolution**: Pinned by
|
||||
[ADR-014](decisions/014-outbox-tx-enqueue.md): one method,
|
||||
`outbox_enqueue_tx(outbox, opts, payload) -> job_id`, added to the
|
||||
`TxHandle` trait. The method takes the **outbox name** (validated
|
||||
like `store.outbox(name)` — `InvalidName`/`ReservedName`), derives
|
||||
the backing queue name `__alkstore_outbox:{outbox}` engine-side,
|
||||
and lands the job row in the caller's transaction with
|
||||
`EnqueueOpts` stamped per [ADR-010](decisions/010-queue-semantics-
|
||||
depth.md) §3a over the backing queue's derived `QueueOpts` —
|
||||
commit-atomic with the business write, rollback drops both (the
|
||||
no-ghosts property). The reserved-prefix rejection is unchanged: it
|
||||
governs directly-supplied names; the derivation is legitimate
|
||||
engine-internal naming (the same status `run_once`'s consumption of
|
||||
the backing queue already had). Option (b) (an `OutboxHandle` off
|
||||
the tx handle) is rejected — a second boxed handle to deliver one
|
||||
op; option (c) (non-transactional only) is rejected — it drops the
|
||||
property the mechanism exists for. Honker's raw-`Transaction` seam
|
||||
is not inherited (no engine-neutral `Transaction` type exists; it
|
||||
would force a core→engine dependency or a downcast seam — the two
|
||||
things ADR-008 §2 dissolved). Framed as completing contract v1 in
|
||||
place, pre-implementation — no versioning event, OQ-10 untouched.
|
||||
Contract-suite row added (commit-atomicity on both engines).
|
||||
- **Cross-references**: OQ-04 (the pinning that missed this), OQ-12
|
||||
(the sibling depth gap found by the same review), ADR-002
|
||||
(outbox scope row), ADR-007/ADR-008 (the seam design), ADR-010 §3
|
||||
(the outbox's derived QueueOpts — unchanged by this).
|
||||
|
||||
## Deferred / Blocked
|
||||
|
||||
None currently. Every open OQ above is actionable Phase 1 work
|
||||
(streams depth, outbox enqueue shape, capability-surface shape,
|
||||
versioning discipline, fork-scaffold follow-through) with its evidence
|
||||
base complete — no external arrivals are being waited on.
|
||||
(streams depth, capability-surface shape, versioning discipline,
|
||||
fork-scaffold follow-through) with its evidence base complete — no
|
||||
external arrivals are being waited on.
|
||||
@@ -24,7 +24,10 @@ made under; the ADRs carry the WHY.
|
||||
[ADR-009](decisions/009-scheduler-collapse.md)).
|
||||
- **Transactional enqueue**: commit-atomic with the caller's business
|
||||
write on both engines ([ADR-007](decisions/007-transactional-seam.md));
|
||||
rollback drops the job row with no ghosts.
|
||||
rollback drops the job row with no ghosts. For the outbox's backing
|
||||
queue the tx path is `outbox_enqueue_tx` (the derived reserved names
|
||||
are unreachable by `enqueue_tx` —
|
||||
[ADR-014](decisions/014-outbox-tx-enqueue.md)).
|
||||
- **Exactly-once claim**: `FOR UPDATE SKIP LOCKED` (pg, POC-pinned
|
||||
under 4×4 concurrency) / the forked substrate's single-statement
|
||||
claim (SQLite, re-derived on [ADR-010](decisions/010-queue-semantics-depth.md)
|
||||
@@ -260,6 +263,7 @@ Collapsed into queues: no `Scheduler` handle, no schedule objects.
|
||||
| [010](decisions/010-queue-semantics-depth.md) | Queue depth | visibility/heartbeat rules, backoff curve, dead-letter, no-stranded-rows sweep, schema layout |
|
||||
| [011](decisions/011-sqlite-substrate-fork.md) | Substrate fork | SQLite-side queue ops re-derived in owned code; `__alkstore_*` naming |
|
||||
| [012](decisions/012-forked-substrate-design.md) | Fork design | contract-blind substrate boundary; fidelity posture; port deltas |
|
||||
| [014](decisions/014-outbox-tx-enqueue.md) | Outbox tx enqueue | `outbox_enqueue_tx` on `TxHandle`; derived backing queue reached only through the outbox surface |
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
Reference in new issue
Block a user