- sqlite scheduler tick: a failed COMMIT (or ROLLBACK) left the connection released with a stuck-open transaction, wedging the writer slot forever — the one commit site missing the drop-+-reopen-replenish arm; tick now takes the conn by value with a poisoned disposition (drop + replenish), the commit-fault seam reaches the tick, and failed_tick_commit_replenishes_the_writer_slot pins the arm - pg save_offset_tx stored GREATEST($3,0) where the wave-8 gate's negative-seed fix (raw seed, ADR-023 §2 2026-10-11 annotation) covered only the auto-commit form; mem's merged in-tx read clamped a staged negative seed against a phantom zero; both fixed and the tx-form totality pinned by a new stamped suite leg (in_tx_reads_see_own_writes) green on all three columns vs the live harness server - pg: a failed forwarder register leaked the channel refcount forever (no receiver to drop it) — listen/spawn_bridge now unregister on the register error, mirroring the subscribe-failure arm; register doc corrected to put the failure disposition at the call site - pg forwarder: nine poisoned-mutex expect() panics converted to the mem crate's into_inner recovery idiom (no-expect-outside-tests rule) - core encode_payload: a deeply-nested Value aborted the process in serde_json's unguarded serializer recursion and the fuzz pin blessed storing bytes no decoder could read back; encode now rejects nesting past 127 containers (the empirically-verified decode limit) with typed Codec — decode-aligned boundary pins added to the fuzz invariant and value_type_tests, applied pre-publish within the open class-1 window - scheduler sleep deadlines: Instant + Duration overflow caps (MAX_SLEEP_S, tick-and-resleep) on all three engines for far-future boundaries; sqlite tick claim stamp now saturating_add - docs read true again: Error::Closed scope (stream try_recv arm), validation whitespace wording, sqlite lib.rs phantom durability knobs, pg lock.rs honest autocommit wording, mem tx/stream merge + wake- granularity wording; wakes.rs stale allow(dead_code) removed - tests: value_type_tests exercised its own map_err instead of the function under test (fixed); unwrap_or_default reference encodes panic with cause - docs/reviews/004-pre-release-general-review.md records nine fixed + sixteen parked findings (sqlite seam panic-safety armor, watcher post-death-subscribe hang + initial-stat self-kill, pg forwarder TOCTOU/hang/diagnosability, pg BIGINT server-side stamp overflow, mem growth/outbox notes, pg identifier-truncation doc note, smaller) and two false positives caught on verification; implementation.md review-rounds entry + core-contract backlog rows updated - gates: build, workspace tests (sqlite 194, core 27, suite legs new), full live pg column 127+25+9, clippy -D warnings, fmt, fuzz replay 6/6, wasm32 check, doc gate -D warnings — all green
889 lines
51 KiB
Markdown
889 lines
51 KiB
Markdown
---
|
|
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<dyn TxHandle + Send>
|
|
|
|
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<Job> // ADR-021
|
|
get_offset_tx(stream, consumer) -> i64 // ADR-021
|
|
read_since_tx(stream, offset, limit) -> Vec<StreamEvent> // ADR-021
|
|
read_from_consumer_tx(stream, consumer, limit) -> Vec<StreamEvent> // ADR-021
|
|
outbox_enqueue_tx(outbox, opts, payload) -> job_id
|
|
commit(self: Box<Self>) -> 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<T>` 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<Lock>
|
|
store.listen(channel) -> Box<dyn WakeReceiver>
|
|
store.schedule(name, spec, queue, payload, opts) -> Result<Schedule>
|
|
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<T>`
|
|
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<Result<()>>` — `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<dyn WakeReceiver>` — 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<String>`-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<String>, payload:
|
|
Vec<u8>, 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<T>` 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<dyn EventReceiver>` — 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<T>`; `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<i64>` (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<i64>` 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<i64>`, `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<Self>`
|
|
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<Lock>` —
|
|
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 <n><unit>` 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; the 2026-10-11 general review added the tx-seam form of
|
|
the `save_offset` totality — a staged negative seed reads raw
|
|
in-tx and stores raw at commit, pinned in
|
|
`in_tx_reads_see_own_writes`, again uniformly green on all three
|
|
engines).
|
|
- **`encode_payload` typed failure**
|
|
([ADR-023](decisions/023-fourth-review-round.md) §1) — the helper
|
|
returns `Result<Vec<u8>, 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.
|
|
The failure set grew once (the 2026-10-11 general review, applied
|
|
pre-publish): a `Value` nested
|
|
deeper than the encode-side depth limit (127 containers — a
|
|
128th nested open fails to decode, empirically aligned with
|
|
serde_json's recursion limit) is a typed `Codec` rejection, not a
|
|
serializable-but-undecodable store and not a serializer stack
|
|
overflow; the fuzz corpus-replay invariant pins the boundary
|
|
(`fuzz/shared/src/payload_codec.rs`).
|
|
- **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).
|