--- status: stable last_updated: "2026-10-11 (the pre-release class-1 close-out pass — the publish→wake ordering tolerance recorded; ADR-023 §2's `heartbeat(extend)`/`save_offset` classifications; `EventReceiver::try_recv`'s closed shape; `get_job`'s id-global prose — then wave-8 gate: flipped stable with the close-out pass's suite legs landed; the contract text freezes at the gate, ADR-017 §2)" --- # Core contract The unified, engine-agnostic surface a `Store` exposes. This document specifies WHAT the contract is; the per-engine specs map it onto their machinery; ADRs carry the WHY. The starting artifact is the honker-rs surface (the Phase 0 §Interface finding) scoped to the 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 — amended in place, pre-implementation, by [ADR-014](decisions/014-outbox-tx-enqueue.md): `outbox_enqueue_tx` joins the `TxHandle` trait, and by [ADR-015](decisions/015-streams-depth.md): streams depth — key semantics, `StreamEvent` shape, the ordering row, `trim_to`, and `publish_with_key_tx`); ADR-009/ADR-010 add the first extensions beyond ADR-008's original v1 partition (scheduler collapse surface, `QueueOpts` depth — factually pre-release additions per ADR-017's class 1, shipped inside the initial contract v1 text; versioning discipline for the surface's changes is [ADR-017](decisions/017-contract-versioning.md)'s, resolved: the core crate's semver is the contract version); [ADR-016](decisions/016-deployment-honesty.md) decides the parked capability-surface question: **none, by default ever** — engine differences surface at compile time (engine-crate identity) and in the [deployment matrix](deployment.md), never as a runtime descriptor; the *obligations* are this document. ## Concepts - **Store** — what a consumer opens from a connection string or file path ([ADR-001](decisions/001-crate-split.md)): one handle, the engine behind it chosen at open time. Consumer code never branches on engine type (guiding principle 4). (The mem engine is the one documented exception to the open-prose: its constructor is engine-native — `MemStore::new()`, no connection string — per [ADR-024](decisions/024-mem-engine.md) §4; the engine choice remains a dependency-graph fact made before any `Store` exists.) - **Mechanism** — one of the surface's coordination families: notify, streams, queues (+outbox), locks, scheduler. Delivery guarantees are pinned per mechanism in [ADR-006](decisions/006-wake-and-delivery-contract.md)'s table (notify/streams/queues), extended with the locks row by [ADR-008](decisions/008-contract-v1-pinning.md), the scheduler row and collapse by [ADR-009](decisions/009-scheduler-collapse.md). *(Guiding principles are defined in `docs/research/phase-0.md` §Vision and cited by number throughout this directory.)* - **Wake** — the opaque "something changed, re-read" signal the wake contract delivers ([ADR-006](decisions/006-wake-and-delivery-contract.md)). The contract's central abstraction. - **TxHandle** — the caller-held transaction object whose `*_tx` methods make side effects commit-atomic with a business write ([ADR-007](decisions/007-transactional-seam.md)). Contract shape: the `*_tx` methods live on the `TxHandle` trait itself — engines implement it for their concrete handle, no downcast ([ADR-008](decisions/008-contract-v1-pinning.md) §2). ## The seam Per [ADR-008](decisions/008-contract-v1-pinning.md) §6, the trait is constructed by engine crates (durability knobs, pool sizing, watcher cadence are engine options — [deployment.md](deployment.md) carries the facts); the contract is the trait surface it returns: ```text Store::begin_tx() -> Box trait TxHandle { enqueue_tx(name, opts, payload) -> job_id publish_tx(stream, payload) -> offset publish_with_key_tx(stream, key, payload) -> offset notify_tx(channel, payload) save_offset_tx(stream, consumer, offset) get_job_tx(queue, job_id) -> Option // ADR-021 get_offset_tx(stream, consumer) -> i64 // ADR-021 read_since_tx(stream, offset, limit) -> Vec // ADR-021 read_from_consumer_tx(stream, consumer, limit) -> Vec // ADR-021 outbox_enqueue_tx(outbox, opts, payload) -> job_id commit(self: Box) -> Result<()> // or rollback } ``` The tx read methods (`get_job_tx` / `get_offset_tx` / `read_since_tx` / `read_from_consumer_tx`, [ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §1) make read-your-own-writes possible inside the business transaction — they take the mechanism name (mirroring `enqueue_tx`/`publish_tx`'s name-taking shape), read the caller's own transaction's rows, and are pure reads (no ops ride them). Reads and saves are tx-shaped; claim/maintenance ops never are (no `claim_tx` — a claim's visibility deadline must not be tied to a business transaction's lifetime; deliberate absence, not an omission). **Dropping a handle without `commit`/`rollback` rolls it back** ([ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §4) — the no-ghosts property holds through RAII paths, not only the explicit ones. `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)). `publish_with_key_tx` validates the key like the non-tx form (a present key must be non-empty → `InvalidName`) and is `publish_tx`'s keyed twin — commit-atomic keyed publishes are the point ([ADR-015](decisions/015-streams-depth.md) §2). Mechanism handles come off the store (or, for transactional variants, off the handle). **Payload encoding** ([ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md) §4, signature typed by [ADR-023](decisions/023-fourth-review-round.md) §1): the trait crosses `serde_json::Value`; engines serialize it with serde_json via core's `encode_payload` (fallible — `Codec` on serialization failure, typed at the helper; never a silent fallback form) and store exactly those bytes — `payload_as` decodes them, `Codec` on failure; round-trip (publish → read → decode → `Value` equality) is what the contract suite pins, on both engines. **Numeric-argument domains** ([ADR-023](decisions/023-fourth-review-round.md) §2) — classified by argument kind, uniform on both engines (no engine may reinterpret a caller-supplied numeric argument via SQL-dialect semantics; validation happens at the trait-impl entry point, before the engine's storage layer): - **Extents** ("at most this many": `claim_batch(n)`, `read_since(limit)`, `read_from_consumer(limit)`) — non-positive values yield the **empty value result** (a value, not an error — the no-work vocabulary); the SQLite `LIMIT -1` dialect artifact (a negative extent reaching the whole queue/stream) is dead by this rule. - **Durations** (`try_lock(ttl)`, `renew(ttl)`) — non-positive values are rejections (`Err`, opaque `Database`, detail in the source chain); an exclusion mechanism must not silently hand back a lease the caller believes it holds. - **Boundaries/offsets** (`trim_to(horizon)`, enqueue `delay`/ `run_at`/`expires`, `heartbeat(extend)`, `save_offset(offset)`) — total: any `i64` is well-defined (a negative horizon deletes nothing; a negative delay resolves to a ready time in the past, i.e. ready now; a negative `heartbeat(extend)` — the renewed claim deadline is `now + extend` — shortens or voids the claim, and the validity predicate then refuses the ops, never an error; a negative offset save is stored, reads resume forward, and saves at-or-below the checkpoint are the monotone no-ops) *(the two 2026-10-11 classifications annotated in [ADR-023](decisions/023-fourth-review-round.md) §2 by the close-out pass; before that date the table covered only the first three arguments)*. ```text store.notify(channel, payload) handle.notify_tx(channel, payload) store.stream(name) -> StreamHandle handle.publish_tx / publish_with_key_tx handle.save_offset_tx (the StreamHandle also carries trim_to — stream-side, outside the tx seam) 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 store.listen(channel) -> Box store.schedule(name, spec, queue, payload, opts) -> Result store.unschedule(name) -> bool store.run_schedules(stop: StopToken) -> Result<()> ``` The mechanism handles' full trait surfaces (`Queue { enqueue, claim_one, claim_batch, ack_batch, cancel, get_job, sweep_expired }`, `StreamHandle { publish, publish_with_key, read_since, read_from_consumer, save_offset, get_offset, trim_to, subscribe }`, `Outbox { enqueue, run_once }`, `Lock { renew, release }`, plus the boxed `JobHandle` a claim returns) are pinned by [ADR-019](decisions/019-mechanism-handle-surfaces.md); the `StopToken` parameter is a core-crate type (same ADR, §4). Payloads cross the trait as core value types (non-generic trait methods — object safety of the boxed handles, [ADR-008](decisions/008-contract-v1-pinning.md) §2); `payload_as` decodes on concrete returned values (`Job`, `StreamEvent`). The `with_tx` closure wrapper ships over the handle shape (the POC-verified composition direction, [ADR-007](decisions/007-transactional-seam.md); signature pinned there 2026-10-07: `with_tx(f) -> Result<()>` with `f: FnOnce(&mut dyn TxHandle) -> BoxedFuture>` — `Ok` ⇒ commit, `Err`/drop/panic ⇒ rollback; non-generic for object safety, values return through captured state, no handle escapes the closure). Mechanism handles (`Queue`, `StreamHandle`, `Outbox`, `Lock`) are core-owned trait objects like the tx handle — engine types never appear in consumer signatures. Their trait surfaces are pinned by [ADR-019](decisions/019-mechanism-handle-surfaces.md) (placement mirroring the honker-rs starting artifact: claim/maintenance ops on the `Queue` handle, publish/read/trim on the `StreamHandle`, the claim ops on the boxed `JobHandle`, `run_once` on the `Outbox`); trait-method additions on these traits post-release ride [ADR-017](decisions/017-contract-versioning.md) class 2 like any other trait surface. ## Mechanism contracts ### notify / listen Fire-and-forget signals, commit-atomic when sent in a transaction ([ADR-007](decisions/007-transactional-seam.md)). No durability, no replay, no per-listener retry; a listener attached after a commit never sees it. - `notify(channel, payload)` — payload ≤ 8000 bytes on Postgres (client-side checked, typed `PayloadTooLarge` before the round trip, verified by POC #2; the measured quantity is the serde_json serialization of the payload `Value` — ADR-020 §4's stored-bytes form, so check and storage measure one thing); no limit on SQLite. The error variant is contract-wide (callers match it identically on both engines), the *occurrence* is the documented engine asymmetry ([ADR-008](decisions/008-contract-v1-pinning.md) §5) — with no capability surface ([ADR-016](decisions/016-deployment-honesty.md)), this variant is the one runtime carriage of an engine asymmetry; its occurrence asymmetry is pinned in the verification backlog below. - `listen(channel) -> Box` — starts from "now"; delivers opaque `Wake { channel }` signals ([ADR-006](decisions/006-wake-and-delivery-contract.md)), never payloads or ids ([ADR-008](decisions/008-contract-v1-pinning.md) §3). Wakes are **best-effort hints, not per-commit delivery promises**: possibly coalesced (SQLite) or per-notify (Postgres), possibly repeated after reconnect, possibly absent entirely (SQLite burst coalescing; the pg no-replay hole) — recovery is the consumer's re-read plus the engine's reconnect surface, never a guaranteed deliver. **Consumers must be idempotent on wake.** - Failure surfaces as channel events, never silence: watcher death closes the receiver (`recv() -> None`, SQLite); the synthetic reconnect-wake (a reserved channel ([ADR-004](decisions/004-postgres-driver.md))) covers Postgres connection gaps. - Channel name is the one piece of semantic content a wake carries — the invalidation key for caching subscribers. ### streams Durable pub/sub with per-consumer offsets ([ADR-006](decisions/006-wake-and-delivery-contract.md)). The durable cousin of notify: publish is commit-atomic; every committed event is readable by every consumer whose offset hasn't passed it; replay-on-attach is the default; offsets are explicit and transaction-aware (`save_offset_tx` gives exactly-once-within-a- business-tx shape). Depth pinned by [ADR-015](decisions/015-streams-depth.md). - `publish` / `publish_tx` / `publish_with_key` / `publish_with_key_tx` — append to the stream's durable log; return the assigned offset. **Key semantics ([ADR-015](decisions/015-streams-depth.md) §1): the key is carried metadata** — stored on the event row, round-tripped on every read, `Option`-shaped on `StreamEvent` — with no engine-enforced behavioral role: **the ordering guarantee stays global FIFO by offset within the stream**; per-key in-order reading is the documented emergent pattern (filter by key, read in offset order), not a server promise. A present key must be non-empty (`InvalidName`). Server-enforced per-key ordering re-enters only via a consumer-inventory row naming it. - **`StreamEvent` shape** ([ADR-015](decisions/015-streams-depth.md) §3): `{ offset: i64, stream: String, key: Option, payload: Vec, created_at: i64 }` — `stream` renames honker's `topic` (one term everywhere, the ADR-008 §4 kinds list); `offset` is engine-assigned, monotone per stream, **immutable, never renumbered** (gaps after a trim are legal); `created_at` is unix-seconds-at- publish, informational, **not an ordering field** (offset is the only one); `payload_as` decodes (the error is `Codec`). - **Ordering guarantee row** ([ADR-015](decisions/015-streams-depth.md) §4, extending [ADR-006](decisions/006-wake-and-delivery-contract.md) §2's streams row): `read_since`/`subscribe` yield `offset ASC` — global FIFO per stream; committed-event visibility and replay-on-attach as already pinned; offsets immutable. The contract suite pins cross-engine equivalence against this row (same publish sequence → same offset sequence → same read order). - **Wake-fire ordering tolerance** *(documented tolerance, 2026-10-11 — the pre-release class-1 close-out pass, [ADR-017](decisions/017-contract-versioning.md) §2; disposes the wave-6 review gate's recorded observation)*: nothing in the contract pins wake-fire order to offset order. Offset allocation precedes insert+wake on a multi-threaded runtime (the mem engine; its documented current-thread posture never exercises it) and the offset is sequence-assigned pre-commit with the wake fired post-commit (the Postgres engine — commit order can invert sequence order), so two concurrent publishes can complete out of offset-allocation order. A subscriber's wake-driven drain that finishes between the two completions can checkpoint past the later-completing, lower-offset event: **the event stays in the log** — direct `read_since` always sees it, and a fresh `subscribe(consumer)` whose saved checkpoint is below it consumes it normally — but the live subscriber whose cursor passed it is not re-delivered it by the later wake. This is a documented consequence of the wake contract's best-effort posture (hints, not delivery promises); the offset-ASC read guarantee above is unaffected — reads always run over the committed log in offset order regardless of wake timing. Consumers requiring per-event completeness across concurrent same-stream publishers need the defensive re-read recipe (a lagged checkpoint, or a direct `read_since` sweep below it). Pinned as a tolerance, not an enforced guarantee (the heavier wake = offset-order amendment was weighed and declined with the user: enforcement against commit- ordered visibility is a redesign, and the wake mechanism's posture is hint-shaped by construction). - `read_since` / `read_from_consumer(offset)` — cursor-based reads; `get_offset(consumer)` — checkpoint inspection. A read from a trimmed-away offset region (below) resumes at the trim horizon's first remaining row. - `save_offset` / `save_offset_tx` — consumer checkpoint, explicit; **the contract's save is always explicit** (no auto-checkpoint cadence — honker's per-binding ambiguity is not inherited). The subscription handle exposes no `save_every` / auto-save-on-drop ([ADR-008](decisions/008-contract-v1-pinning.md) §8). Saves are monotone — a save below the stored checkpoint is a silent no-op — so the direct form and the receiver's no-arg form (`EventReceiver::save_offset(&mut self)`, which saves the last-yielded event's offset through the same op, [ADR-019](decisions/019-mechanism-handle-surfaces.md) §6) interleave freely and cannot regress one another. Both save forms' `Err` arm carries `Database` only ([ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §5) — regression refusal is a silent no-op, so a save result errs only on storage failure. A saved offset below the trim horizon ([ADR-015](decisions/015-streams-depth.md) §5) stays a valid position marker. - `trim_to(horizon) -> count` — delete events with `offset <= horizon` (exact-boundary; the horizon is a boundary argument, total per [ADR-023](decisions/023-fourth-review-round.md) §2 — a negative horizon deletes nothing, idempotently) ([ADR-015](decisions/015-streams-depth.md) §5; the return is the deleted-row count, [ADR-019](decisions/019-mechanism-handle-surfaces.md)). The stream-side bounded-growth op, consumer-invoked, no engine-default retention and no ambient sweeper (the ADR-010 §6 posture; the replay-forever default is the mechanism's purpose — growth is documented with the tool in-contract to bound it). Trim emits no dedicated wake and no notify — on SQLite the `data_version` watcher may still fire (any committed write bumps it; a spurious hint, contract-legal); surviving events keep their offsets. - `subscribe(consumer) -> Box` — durable consumption: attach, read to current tail, resume after restart from the stored offset; explicit `save_offset` on the receiver (shape in [ADR-008](decisions/008-contract-v1-pinning.md) §8). **Error and close arms pinned** ([ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §5): `recv()`'s `Err` arm carries `Database` only (payloads cross raw; `Codec` lives at `payload_as`; `Closed` is the `None` arm); the poll form's arms pin identically — `try_recv()`: `Ok(None)` = idle (no event pending right now), `Err(Closed)` = the receiver closed, `Database` otherwise (the closed shape annotated 2026-10-11, the close-out pass); close is terminal and its *cause* is the documented engine asymmetry — SQLite's receiver closes on watcher death (POC-pinned), the pg receiver stays open across forwarder reconnects (reconnect wakes arrive on it) and closes only at engine shutdown. A closed receiver never reopens; recovery is a fresh `subscribe(consumer)` resuming from the saved offset. Consumption is wake-driven with table re-read — the same mechanism split as queues (durable row, LISTEN/watcher wake, [ADR-006](decisions/006-wake-and-delivery-contract.md)); the receiver's engine-side trigger is engine-internal, but events never require polling to become visible (the queues row's pg re-poll safety net is an admission of LISTEN loss, not the posture). ### queues Durable at-least-once work ([ADR-002](decisions/002-feature-scope.md)). Depth pinned by [ADR-010](decisions/010-queue-semantics-depth.md); contract-level obligations: - `enqueue` / `enqueue_tx` — commit-atomic; `EnqueueOpts { delay, run_at, priority, max_attempts, expires }`; **field types pinned (2026-10-07, third review round follow-through)**: `delay`, `run_at`, `expires` are `Option` (honker-rs parity, `packages/honker-rs/src/lib.rs:439-447` — unset is distinguishable from zero, and the resolution rules below are the unset behavior), `priority` is a plain `i64` with default **0** (the claim-order key; the row schema's `DEFAULT 0`), and `max_attempts` is `Option` with `None` = the queue-level stamp decides (the §3a override; no honker counterpart — our field). Every field has a default-resolved meaning, so all are constructor-settable with `..Default::default()`. Queue-level `QueueOpts { visibility_timeout_s, max_attempts, backoff_base_s, dead_letter_retention_s }` ([ADR-010](decisions/010-queue-semantics-depth.md) §3/§3a): plain `i64` fields (`dead_letter_retention_s: Option`, `None` = forever) with a `Default` providing the pinned defaults 300/3/5/none (honker parities except retention, which is ours); `ScheduleOpts { priority, max_attempts, expires }` types identical to their `EnqueueOpts` twins. **Option semantics ([ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md))**: ready time resolves `delay` → `now + delay` (wins) over `run_at` → literal absolute over neither → now; `expires` is relative seconds from enqueue resolved to an absolute row `expires_at` (`None` = never); the resolved values — not the raw fields — are what `get_job` shows. The `QueueOpts` stamps land on each job row at enqueue (§3a), resolved per [ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md) §3 by call shape: the auto-commit `Queue::enqueue` (the one handle-carrying shape) resolves over that handle's opts; `enqueue_tx` (a `TxHandle` carries no queue opts) and every other no-handle-open path — outbox backing queues, scheduler boundary fires — stamp the engine's derived defaults (plain 300/3/5/none; the outbox's 60/5/5 set). - `claim_one` / `claim_batch` — exactly-once handout under concurrency (POC-pinned on both engines); take a caller-supplied `worker_id` (claimant identity — consumer-local, no reserved rule, non-empty; [ADR-019](decisions/019-mechanism-handle-surfaces.md) §2); claim ordering: priority DESC, then ready-time, then enqueue order (FIFO under equal priority). Claims return boxed `JobHandle`s — ops + row value (same ADR, §3). `claim_batch(n)`'s `n` is an extent ([ADR-023](decisions/023-fourth-review-round.md) §2): `n <= 0` claims nothing (the empty `Vec`, a value — never an error, never the whole queue). - Job handle: `ack / retry / fail / heartbeat` on the boxed `JobHandle` a claim returns ([ADR-019](decisions/019-mechanism-handle-surfaces.md) §3 — `job(&self) -> &Job` beside the ops; one-shot `self: Box` ops like the tx commit). The one-shot/repeatable split is deliberate: `ack`/`retry`/`fail` consume the handle (each is a terminal transition of the claim); **`heartbeat(self, extend)` takes `&self` and is repeatable** — renewal is inherently repeated within one claim; do not "fix" this asymmetry at implementation ([ADR-019](decisions/019-mechanism-handle-surfaces.md) §3). `ack` deletes the row; `heartbeat(extend)` is *renewal* — an absolute reset of the claim deadline (extend is the new full deadline from now, **not additive** to elapsed time); late heartbeat refused; **a reclaim consumes an attempt**; `retry(err, None)` computes the queue's equal-jitter exponential delay (range pinned by [ADR-010](decisions/010-queue-semantics-depth.md) §3), `retry(err, Some(d))` overrides; `fail` = immediate dead-letter. Handle-op validity (uniform predicate): an op succeeds only while the row is `processing` and the caller's claim deadline is unexpired — deadline lapse refuses all handle ops (heartbeats, acks included) and a reclaim does too; the dual-execution window's worker may still complete its work but its ack will not land (at-least-once, as documented); false, not error ([ADR-010](decisions/010-queue-semantics-depth.md) §2). Dead letters: move-to-dead storage, `get_job` sees dead rows (with `last_error`/`died_at`), retention via `dead_letter_retention_s` (default forever), no redrive API ([ADR-010](decisions/010-queue-semantics-depth.md) §1–§4). `get_job(job_id)` on the handle is an **id-global lookup** — the handle carries the queue, but the lookup keys on the id alone and resolves rows in any queue (uniform on all engines; annotated 2026-10-11, the close-out pass — the wave-6 review's recorded prose ambiguity); the tx-side `get_job_tx(queue, job_id)` takes the queue name and **is** queue-filtered ([ADR-019](decisions/019-mechanism-handle-surfaces.md) §1, [ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §1). `QueueOpts` stamp onto the job row at enqueue (§3a) — queues are names, not config owners. The remaining v1-skeleton ops (`ack_batch` — the batch ack applies the uniform predicate per id, lapsed or non-claimed ids silently not counted; `cancel` — unconditional delete, not an interrupt) pin in [ADR-010](decisions/010-queue-semantics-depth.md) §1. - `sweep_expired()` — moves *every* past-expiry row (any state) to dead + enforces dead-letter retention — the no-stranded-rows property ([ADR-010](decisions/010-queue-semantics-depth.md) §5); the queue-scoped handle form ([ADR-019](decisions/019-mechanism-handle-surfaces.md) §1 — the name rides the handle); cadence recipe in [queues.md](queues.md) (no ambient sweeper). ### named locks TTL-bounded coordination locks. Lock rows live in the same database as the caller's business data — coordination and data co-locate — but lock ops are **not** part of the tx seam: there is no `lock_tx`; a lock held across a business transaction is held by explicit acquisition before/inside it, not transaction-scoped (acquisition is a separate auto-commit op; rollback of the business tx does not release it — the TTL discipline governs). - `try_lock(name, owner, ttl)` — acquire or fail (`Option` — no-work is a value, not an error); `renew` and `release` on the lock handle. Release on explicit unlock or TTL expiry. Re-acquirable after expiry (POC-pinned on Postgres; on SQLite it rests on the forked substrate's lock machinery — the SQLite-side pin is in the verification backlog below). `ttl <= 0` is a rejection (duration kind, [ADR-023](decisions/023-fourth-review-round.md) §2) — opaque `Err`, never a silent immediately-expired lease. - **Guarantee row** ([ADR-008](decisions/008-contract-v1-pinning.md) §7): mutual exclusion bounded by TTL + renewal — after TTL expiry exclusion lapses *silently* (no revocation event); holders must `renew` within TTL; expiry is a loss of exclusivity, not an error. - Lock names are shared-namespace (reserved-prefix rules below, [ADR-008](decisions/008-contract-v1-pinning.md) §4). ### outbox A helper over queues, not a separate mechanism: enqueue inside the business transaction + the delivery/consumption worker entry points. Same evidence base and guarantee as queues ([ADR-002](decisions/002-feature-scope.md)). Worker semantics (honker's `run_once` shape, inherited as the pinned posture; the delivery callable's shape pinned by [ADR-019](decisions/019-mechanism-handle-surfaces.md) §5): `run_once(worker_id, delivery) -> bool` is a *pull* op — claim one job, run the delivery closure, `ack` on `Ok`, `retry(err, None)` (the queue's curve) on `Err`; the consumer calls it in its own loop. **No engine-issued heartbeat inside delivery** (honker parity, inherited deliberately — the helper does not auto-heartbeat on the caller's behalf; the boxed `JobHandle`'s own `heartbeat` remains available to the delivery closure, and renewal inside the deadline is the consumer's obligation): 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 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 Collapsed into queues per [ADR-009](decisions/009-scheduler-collapse.md): `schedule(name, spec, queue, payload, opts)` (upsert by name), `unschedule(name)`, and the opt-in `run_schedules(stop: StopToken)` runner (`StopToken` is a core-crate cloneable cancel handle — [ADR-019](decisions/019-mechanism-handle-surfaces.md) §4; the leader-elected leadership lock is the reserved name `__alkstore_scheduler`). Both name-bearing arguments validate at the entry point: the schedule *name* like a queue name (non-empty, reserved-prefix rejected — [ADR-009](decisions/009-scheduler-collapse.md) §1) and, since [ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §3, the **queue argument** identically (`InvalidName`/`ReservedName`) — a schedule cannot target a reserved name, so the outbox backing queue stays reachable only through the outbox surface ([ADR-014](decisions/014-outbox-tx-enqueue.md)). Schedules never fire without a runner (no ambient timers). v1 spec grammar: `@every ` only (`s|m|h|d`); cron strings are rejected (`InvalidSpec`) pending a consumer-inventory row naming wall-clock cron. Each boundary fire enqueues ordinary work stamped with `ScheduleOpts { priority, max_attempts, expires }` ([ADR-009](decisions/009-scheduler-collapse.md) §3) — resolved over the target queue's *derived engine defaults* (visibility/backoff/ retention; no handle is open), per [ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md) §3. Boundary guarantee: [ADR-009](decisions/009-scheduler-collapse.md) §4 (at-least-once per elapsed boundary, bounded catch-up with skip-forward past the cap). ## Cross-cutting contracts ### Delivery-guarantee table The per-mechanism table in [ADR-006](decisions/006-wake-and-delivery-contract.md) is the contract of record for notify/streams/queues; the locks row is [ADR-008](decisions/008-contract-v1-pinning.md) §7's and the scheduler row is [ADR-009](decisions/009-scheduler-collapse.md) §4's. This spec inherits them and adds the consumer obligations: - wake idempotence (notify's best-effort, coalescing behavior); - explicit offset saves (streams); wake-fire ordering is a documented tolerance, not a pinned guarantee — wake order need not match offset order and a wake-driven subscriber can drain past an event whose publish completed out of offset order (the streams section's tolerance paragraph, 2026-10-11); recovery rides the offset-ordered re-read the wake contract already obligates; - visibility-timeout budgeting — heartbeat inside the deadline for long work; the dual-execution window and reclaim-eats-attempt rules are documented consumer obligations ([ADR-010](decisions/010-queue-semantics-depth.md) §2); - running `run_schedules` for schedules to fire at all, and scheduling sweep cadences via the collapse recipe — timeliness is the consumer's, correctness-of-transition the engine's ([ADR-010](decisions/010-queue-semantics-depth.md) §6); - `*_tx` operations are *only* durable after the caller's commit — rollback drops job rows, event rows (keyed and unkeyed alike), notifications, and offset saves together (the no-ghosts property, POC-pinned on both engines). ### Errors `thiserror`-typed, per the family standard. Pinned by [ADR-008](decisions/008-contract-v1-pinning.md) §5: one top-level `Error`, with v1 variants `PayloadTooLarge` (universal; produced pg-side, contract-wide matchable), `ReservedName`, `InvalidName`, `Closed`, `Codec`, and the opaque `Database` fallback (engine detail preserved via the source chain). `InvalidSpec` (schedule spec grammar) and `LeadershipLost` (`run_schedules` return on leadership loss) — both from [ADR-009](decisions/009-scheduler-collapse.md) §6 — are part of contract v1's initial text (pre-release additions per [ADR-017](decisions/017-contract-versioning.md) class 1; the "post-v1 additions" framing an earlier draft used is retired). Pinning rule: a variant exists only when callers can act differently on it. Queue claim "no work" and job/lock boolean results are values, not errors; dead-letter is observable state (`get_job`), not an error. ### Capability surface None — decided by [ADR-016](decisions/016-deployment-honesty.md) (2026-10-06): `Store` carries no capabilities accessor, in v1 and by default ever. The honest single-host/multi-host boundary lives at compile time (the engine-crate dependency *is* the deployment statement — [ADR-001](decisions/001-crate-split.md)'s single-driver binaries) and in [deployment.md](deployment.md)'s matrix. Runtime carriage of the one caller-actionable engine asymmetry is the contract-wide matchable `PayloadTooLarge` variant (Errors above); re-entry requires a consumer-inventory row naming a runtime-adapt need. ### Naming / reserved namespace Consumer-visible names (channels, streams, queues, outboxes, locks, and schedule names) share engine-visible namespaces on Postgres (LISTEN channel names are server-global per database). The canonical kinds list is exactly these six (notify/stream/queue/outbox/lock/schedule; pinned by [ADR-008](decisions/008-contract-v1-pinning.md) §4, outbox added by [ADR-014](decisions/014-outbox-tx-enqueue.md), schedule names by [ADR-009](decisions/009-scheduler-collapse.md) §1): - Reserved prefix **`__alkstore_`**, engine-independent, applies across all name kinds; the one v1-reserved string is `__alkstore_listener_reconnected__` (the Postgres reconnect-wake channel, [ADR-004](decisions/004-postgres-driver.md)). - Reserved-prefix names are rejected at every entry point — the name-bearing methods *and their `*_tx` counterparts* — with the typed `ReservedName` error; empty names with `InvalidName` ([ADR-008](decisions/008-contract-v1-pinning.md) §4 — enforced as non-empty-after-whitespace-exclusion per that ADR's wave-1 review annotation: whitespace-only names are `InvalidName` on both name classes; names are not trimmed). Stream-consumer names are consumer-local identifiers, not a reserved-namespace kind. - Engine-derived names in consumer namespaces carry the reserved prefix (the outbox's backing queue is derived under the prefix — honker's `_outbox:{name}` scheme is not inherited verbatim). - SQLite: the substrate's `__alkstore_*` internal table family is storage-internal (not consumer namespace) — re-owned from upstream's `_honker_*` by the fork ([ADR-011](decisions/011-sqlite-substrate-fork.md), designed per [ADR-012](decisions/012-forked-substrate-design.md)). Postgres: engine tables are schema-scoped — one engine-owned schema ([ADR-010](decisions/010-queue-semantics-depth.md) §8) — the *channel* namespace is this section's. ## Verification backlog Contract properties POC-pinned on one engine only (or sketched rather than surface-verified) — the contract test suite must pin both engines before the engine specs are called `stable`: - **Named-lock TTL/expiry re-acquisition on SQLite** — pinned on Postgres (pg POC's lock probe); on SQLite it rests on the forked substrate's lock machinery (its `lock_renew` verified in the quality read; the re-acquire-does-not-refresh-TTL behavior is upstream's, inherited deliberately) — pin it in the contract suite. - **Wake semantics under `WakeReceiver` shapes** — POCs verified wake delivery/coalescing through their own probe types; the pinned `Wake { channel }` / recv forms (§3 of [ADR-008](decisions/008-contract-v1-pinning.md)) need the contract suite's own property tests on both engines. - **`save_offset_tx` exactly-once-within-a-business-tx shape** — both POCs verified it through their *sketch* implementations (SQLite's offset-save was a plain SQL upsert, not the forked substrate's full surface; the pg side likewise through its probe), so the property must be re-pinned against the real engines' `save_offset_tx` in the contract suite. - **Concurrent `try_lock` loser/error behavior** on SQLite (the pg side returns cleanly; the substrate's busy-path under lock contention is the thing to pin). - **Scheduler + depth properties on Postgres** — the SQLite side's tick/leader/catch-up machinery inherits the forked substrate's test-pinned implementation; the pg engine's re-derived tick (boundary advance, 64-cap skip-forward, leadership-loss discipline) and the [ADR-010](decisions/010-queue-semantics-depth.md) depth properties (visibility reclaim consuming attempts, dead-letter moves, the no-stranded-rows sweep) pin in the contract suite at implementation. - **Backoff-curve and stamp-resolution equivalence across engines** — the equal-jitter curve ([ADR-010](decisions/010-queue-semantics-depth.md) §3) and the opts-stamping resolution (§3a) are computed engine-side per [ADR-012](decisions/012-forked-substrate-design.md) §2's contract-blind boundary; the contract suite must pin both engines' arithmetic to identical outputs. - **Job-handle validity predicate on both engines** — the uniform processing-state + unexpired-deadline rule ([ADR-010](decisions/010-queue-semantics-depth.md) §2): the late-heartbeat boundary (refused exactly when the deadline has lapsed), the post-lapse-ack-refusal (at-least-once), and the ack-vs-reclaim race (one wins atomically); `ack_batch` applies the predicate per id (lapsed or non-claimed ids silently not counted); `retry`'s refusal is `false` with the row untouched; the engine-default strings land identically (`fail(None)` → `"failed"`, exhaustion → `"max attempts exceeded"`); 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. - **Cross-engine stream equivalence** ([ADR-015](decisions/015-streams-depth.md) §3/§4) — publish sequence → identical *(per-stream)* offset sequence → identical `read_since` output order on both engines, direct and subscriber reads alike (offsets are global counters engine-side — pg bigserial, SQLite AUTOINCREMENT — so equivalence is per-stream relative order, never absolute offset values); keyed/unkeyed interleavings preserve global FIFO; `key` round-trips exactly (`None` vs `Some`), `stream`/`created_at` fields equal. - **`publish_with_key_tx` commit-atomicity on both engines** ([ADR-015](decisions/015-streams-depth.md) §2) — rollback drops the keyed event row with the business write (no ghost event); commit makes it visible to `read_since`/`subscribe`; empty-`Some`-key `InvalidName` on both engines' tx paths. - **`PayloadTooLarge` occurrence asymmetry** ([ADR-016](decisions/016-deployment-honesty.md) §5) — with no capability surface, `PayloadTooLarge` is the one runtime carriage of an engine asymmetry, so its matchability pins in the suite: an oversized `notify`/`notify_tx` payload is rejected client-side by the Postgres engine before any round trip (the limit in the variant), and the SQLite engine never produces the variant at any size — engine-agnostic code writes the same match on both engines and the non-occurring arm simply never fires. - **`trim_to` semantics on both engines** ([ADR-015](decisions/015-streams-depth.md) §5) — exact-boundary trim (`<=`), surviving rows keep their offsets (gaps legal, never renumbered), reads resume at the trim horizon's first remaining row, saved offsets below the horizon stay valid, trim wakes nothing, and a concurrent subscriber never loses its place. - **Save-offset monotone composition** ([ADR-019](decisions/019-mechanism-handle-surfaces.md) §6) — direct-form and receiver-form saves interleave without regression on both engines; a lower-offset save is a silent no-op; receiver saves land the last-yielded event's offset exactly. - **Payload encode/decode round-trip on both engines** ([ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md) §4) — same `Value` published/enqueued on both engines stores byte-identical rows and decodes back to `Value` equality (`Codec` only on non-serializable target types). - **Enqueue-opts resolution equivalence** ([ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md) §1/§2/§3) — delay-over-run_at precedence, relative-expires resolution, and the scheduler-fired jobs' derived-default stamps (`get_job`-visible via the row) are identical on both engines; plain `enqueue_tx` stamps the same plain-queue derived defaults (300/3/5/none) on both engines — auto-commit-handle enqueues stamp the handle's opts, no-handle-open shapes stamp derived defaults (the outbox's 60/5/5 set being the one exception, ADR-014 §1). - **In-tx read-your-own-writes on both engines** ([ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §1) — reads issued on the tx handle see the transaction's own writes (`get_job_tx` a row the same tx enqueued; `read_since_tx` an event the same tx published); another engine connection sees none of it pre-commit; post-rollback the reads' subjects are gone (no-ghosts for reads). - **Drop = rollback on both engines** ([ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §4) — a handle dropped before commit leaves no job/event/notify/offset residue (no-ghosts via RAII); afterward the SQLite writer slot is reusable and the pg client re-poolable. - **`schedule()` queue-argument validation on both engines** ([ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §3) — reserved-prefix and empty queue arguments rejected at registration with `ReservedName`/`InvalidName`; stored schedules fire only into non-reserved queue names. - **Receiver error/close arms on both engines** ([ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) §5) — `recv()`'s `Err` carries `Database` only; `save_offset`'s `Err` carries `Database` only; regression saves are silent no-ops; SQLite's receiver closes on watcher death (terminal `None`); the pg receiver stays open across forwarder reconnects (reconnect-wakes arrive; no close) and closes only at engine shutdown. - **Numeric-domain semantics on both engines** ([ADR-023](decisions/023-fourth-review-round.md) §2) — extents clamp to empty (`claim_batch(n <= 0)` claims nothing; stream reads with `limit <= 0` read nothing, never the whole queue/stream), so the SQLite `LIMIT -1` dialect artifact is dead on both engines; durations reject (`try_lock`/`renew` with `ttl <= 0` are opaque `Err`s on both engines); boundaries are total (a negative `trim_to` horizon deletes nothing, idempotently; the same totality over non-positive values covers `heartbeat(extend)`'s relative-deadline argument and `save_offset`'s offset — both classified 2026-10-11 by the close-out pass, the ADR-023 §2 annotation; their suite legs landed at the wave-8 gate in `job_handle_validity_predicate` and `receiver_close_and_save_arms`, uniformly green on all three engines). - **`encode_payload` typed failure** ([ADR-023](decisions/023-fourth-review-round.md) §1) — the helper returns `Result, Error::Codec>`; enqueue/publish store and decode the exact serialization of the input `Value` (byte-identical rows cross-engine, the §4 round-trip unchanged), and the failure arm is typed at the helper — no silent empty-byte fallback exists. - **Plain-path SQLite open** ([ADR-023](decisions/023-fourth-review-round.md) §3) — the SQLite engine's `open` path argument is a plain filesystem path: a string carrying a `?name=value` suffix is treated as a filename, no URI interpretation, no connection- semantics mutation outside the constructor's opts. - **Watcher cadence default + knob** ([ADR-023](decisions/023-fourth-review-round.md) §4) — the SQLite store's watcher runs at the inherited 1 ms default; `SqliteOpts::poll_interval` changes the configured cadence (config-surface accounting — wall-clock cadence measurements stay out of the contract suite's deterministic posture). ## Design Decisions | ADR | Decision | Summary | |---|---|---| | [001](decisions/001-crate-split.md) | Crate split | core + per-engine crates; single-driver binaries | | [002](decisions/002-feature-scope.md) | Feature scope | inventory-confirmed features only; cut-flags explicit | | [006](decisions/006-wake-and-delivery-contract.md) | Wake contract | opaque wake + re-read; notify-vs-streams guarantee split | | [007](decisions/007-transactional-seam.md) | Tx seam | caller-held handle, `*_tx` methods, native commit-atomicity | | [008](decisions/008-contract-v1-pinning.md) | Contract v1 pinning | surface partition, `TxHandle` trait shape, `Wake` type, reserved strings, error taxonomy, config split, locks guarantee row | | [009](decisions/009-scheduler-collapse.md) | Scheduler collapse (pre-release extension) | queues + `schedule()`/`run_schedules`, `@every`-only v1, boundary guarantee row | | [010](decisions/010-queue-semantics-depth.md) | Queue depth (pre-release 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 | | [015](decisions/015-streams-depth.md) | Streams depth (amends 006/008) | key = carried metadata, global-FIFO ordering row, `StreamEvent` shape, `publish_with_key_tx`, `trim_to` | | [016](decisions/016-deployment-honesty.md) | Deployment honesty (decides 008's parked question) | no runtime capability surface — compile-time engine identity + documented matrix; `PayloadTooLarge` occurrence asymmetry pinned | | [017](decisions/017-contract-versioning.md) | Contract versioning (governs this surface's changes) | core crate's semver *is* the contract version; four change classes; amend-in-place ends at first release; pairing = manifest pin + version-stamped contract suite + docs | | [019](decisions/019-mechanism-handle-surfaces.md) | Mechanism handles (amends 008 §1/§2/§8) | handle traits pinned (`Queue`/`StreamHandle`/`Outbox`/`Lock`/`JobHandle`); `worker_id` claimant identity; `Job`/`Schedule` struct shapes; core-owned `StopToken`; save-offset composition | | [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue options + bridges (amends 008 §1/§2, 009 §3) | `delay` wins over `run_at`; `expires` = relative seconds; scheduler stamps from derived queue defaults; serde_json byte-level payload encoding | | [021](decisions/021-tx-reads-and-value-shape-fixes.md) | Third review round (amends 008 §2/§8, 019 §1/§3, 009 §1) | tx-side read methods on `TxHandle` (read-your-own-writes); `Job.claimed_at` restored; `schedule()` queue argument validated; drop = rollback; receiver close/error arms pinned | | [023](decisions/023-fourth-review-round.md) | Fourth review round (amends 020 §4, 008 §5, 012 §4) | numeric-argument domains by kind (extents clamp empty, durations reject, boundaries total); `encode_payload` typed (`Codec`); plain-path SQLite open (URI flag dropped); watcher cadence 1 ms default + `SqliteOpts` knob | ## Open Questions Open questions are tracked in [open-questions.md](open-questions.md). Key questions affecting this document: - **OQ-10**: contract versioning discipline across engine crates — **resolved** ([ADR-017](decisions/017-contract-versioning.md)): the core crate's semver is the contract version; change classes pinned; the verification backlog below matures into the version-stamped contract suite (the pairing instrument). Resolved on this document's surface: **OQ-09** (scheduler collapse — [ADR-009](decisions/009-scheduler-collapse.md)), **OQ-05** (queue semantics depth — [ADR-010](decisions/010-queue-semantics-depth.md)), **OQ-13** (transactional outbox enqueue shape — [ADR-014](decisions/014-outbox-tx-enqueue.md)), **OQ-12** (streams depth — [ADR-015](decisions/015-streams-depth.md)), **OQ-08** (capability-surface shape — none, by default ever; [ADR-016](decisions/016-deployment-honesty.md)), and **OQ-10** (the versioning discipline this document's surface is governed by — [ADR-017](decisions/017-contract-versioning.md)), 2026-10-05/06. The second review round (2026-10-06) found no new open questions — its findings resolved directly as [ADR-019](decisions/019-mechanism-handle-surfaces.md) (handle surfaces, value shapes, stop token) and [ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md) (enqueue-option semantics, scheduler stamp source, payload encoding). The third review round (2026-10-07, pre-decomposition) likewise found no new open questions — its five findings resolved directly as [ADR-021](decisions/021-tx-reads-and-value-shape-fixes.md) (tx-side reads, `Job.claimed_at`, `schedule()` queue-validation, drop = rollback, receiver close/error arms).