51 KiB
status, last_updated
| status | last_updated |
|---|---|
| stable | 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
— and pinned against it. The base surface is pinned by
ADR-008 (contract v1 partition,
TxHandle representation, wake type, reserved strings, error
taxonomy, config split — amended in place, pre-implementation, by
ADR-014: outbox_enqueue_tx
joins the TxHandle trait, and by
ADR-015: 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's, resolved: the
core crate's semver is the contract version);
ADR-016 decides the parked
capability-surface question: none, by default ever — engine
differences surface at compile time (engine-crate identity) and in
the deployment matrix, 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): 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 §4; the engine choice remains a dependency-graph fact made before anyStoreexists.) - Mechanism — one of the surface's coordination families: notify,
streams, queues (+outbox), locks, scheduler. Delivery guarantees are
pinned per mechanism in ADR-006's
table (notify/streams/queues), extended with the locks row by
ADR-008, the scheduler row
and collapse by ADR-009.
(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). The contract's central abstraction.
- TxHandle — the caller-held transaction object whose
*_txmethods make side effects commit-atomic with a business write (ADR-007). Contract shape: the*_txmethods live on theTxHandletrait itself — engines implement it for their concrete handle, no downcast (ADR-008 §2).
The seam
Per ADR-008 §6, the trait is constructed by engine crates (durability knobs, pool sizing, watcher cadence are engine options — deployment.md carries the facts); the contract is the trait surface it returns:
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 §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 §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).
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 §2).
Mechanism handles come off the store (or, for transactional variants,
off the handle). Payload encoding (ADR-020
§4, signature typed by ADR-023
§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 §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 SQLiteLIMIT -1dialect 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, opaqueDatabase, 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), enqueuedelay/run_at/expires,heartbeat(extend),save_offset(offset)) — total: anyi64is well-defined (a negative horizon deletes nothing; a negative delay resolves to a ready time in the past, i.e. ready now; a negativeheartbeat(extend)— the renewed claim deadline isnow + 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 §2 by the close-out pass; before that date the table covered only the first three arguments).
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; 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 §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;
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 (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 class 2 like any
other trait surface.
Mechanism contracts
notify / listen
Fire-and-forget signals, commit-atomic when sent in a transaction (ADR-007). 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, typedPayloadTooLargebefore the round trip, verified by POC #2; the measured quantity is the serde_json serialization of the payloadValue— 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 §5) — with no capability surface (ADR-016), 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 opaqueWake { channel }signals (ADR-006), never payloads or ids (ADR-008 §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)) 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). 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.
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 §1): the key is carried metadata — stored on the event row, round-tripped on every read,Option<String>-shaped onStreamEvent— 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.StreamEventshape (ADR-015 §3):{ offset: i64, stream: String, key: Option<String>, payload: Vec<u8>, created_at: i64 }—streamrenames honker'stopic(one term everywhere, the ADR-008 §4 kinds list);offsetis engine-assigned, monotone per stream, immutable, never renumbered (gaps after a trim are legal);created_atis unix-seconds-at- publish, informational, not an ordering field (offset is the only one);payload_as<T>decodes (the error isCodec).- Ordering guarantee row (ADR-015
§4, extending ADR-006
§2's streams row):
read_since/subscribeyieldoffset 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 §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_sincealways sees it, and a freshsubscribe(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 directread_sincesweep 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 nosave_every/ auto-save-on-drop (ADR-008 §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 §6) interleave freely and cannot regress one another. Both save forms'Errarm carriesDatabaseonly (ADR-021 §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 §5) stays a valid position marker.trim_to(horizon) -> count— delete events withoffset <= horizon(exact-boundary; the horizon is a boundary argument, total per ADR-023 §2 — a negative horizon deletes nothing, idempotently) (ADR-015 §5; the return is the deleted-row count, ADR-019). 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 thedata_versionwatcher 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; explicitsave_offseton the receiver (shape in ADR-008 §8). Error and close arms pinned (ADR-021 §5):recv()'sErrarm carriesDatabaseonly (payloads cross raw;Codeclives atpayload_as<T>;Closedis theNonearm); the poll form's arms pin identically —try_recv():Ok(None)= idle (no event pending right now),Err(Closed)= the receiver closed,Databaseotherwise (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 freshsubscribe(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); 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). Depth pinned by ADR-010; 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,expiresareOption<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),priorityis a plaini64with default 0 (the claim-order key; the row schema'sDEFAULT 0), andmax_attemptsisOption<i64>withNone= 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-levelQueueOpts { visibility_timeout_s, max_attempts, backoff_base_s, dead_letter_retention_s }(ADR-010 §3/§3a): plaini64fields (dead_letter_retention_s: Option<i64>,None= forever) with aDefaultproviding the pinned defaults 300/3/5/none (honker parities except retention, which is ours);ScheduleOpts { priority, max_attempts, expires }types identical to theirEnqueueOptstwins. Option semantics (ADR-020): ready time resolvesdelay→now + delay(wins) overrun_at→ literal absolute over neither → now;expiresis relative seconds from enqueue resolved to an absolute rowexpires_at(None= never); the resolved values — not the raw fields — are whatget_jobshows. TheQueueOptsstamps land on each job row at enqueue (§3a), resolved per ADR-020 §3 by call shape: the auto-commitQueue::enqueue(the one handle-carrying shape) resolves over that handle's opts;enqueue_tx(aTxHandlecarries 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-suppliedworker_id(claimant identity — consumer-local, no reserved rule, non-empty; ADR-019 §2); claim ordering: priority DESC, then ready-time, then enqueue order (FIFO under equal priority). Claims return boxedJobHandles — ops + row value (same ADR, §3).claim_batch(n)'snis an extent (ADR-023 §2):n <= 0claims nothing (the emptyVec, a value — never an error, never the whole queue).- Job handle:
ack / retry / fail / heartbeaton the boxedJobHandlea claim returns (ADR-019 §3 —job(&self) -> &Jobbeside the ops; one-shotself: Box<Self>ops like the tx commit). The one-shot/repeatable split is deliberate:ack/retry/failconsume the handle (each is a terminal transition of the claim);heartbeat(self, extend)takes&selfand is repeatable — renewal is inherently repeated within one claim; do not "fix" this asymmetry at implementation (ADR-019 §3).ackdeletes 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 §3),retry(err, Some(d))overrides;fail= immediate dead-letter. Handle-op validity (uniform predicate): an op succeeds only while the row isprocessingand 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 §2). Dead letters: move-to-dead storage,get_jobsees dead rows (withlast_error/died_at), retention viadead_letter_retention_s(default forever), no redrive API (ADR-010 §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-sideget_job_tx(queue, job_id)takes the queue name and is queue-filtered (ADR-019 §1, ADR-021 §1).QueueOptsstamp 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 §1. sweep_expired()— moves every past-expiry row (any state) to dead + enforces dead-letter retention — the no-stranded-rows property (ADR-010 §5); the queue-scoped handle form (ADR-019 §1 — the name rides the handle); cadence recipe in 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);renewandreleaseon 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 <= 0is a rejection (duration kind, ADR-023 §2) — opaqueErr, never a silent immediately-expired lease.- Guarantee row (ADR-008
§7): mutual exclusion bounded by TTL + renewal — after TTL expiry
exclusion lapses silently (no revocation event); holders must
renewwithin TTL; expiry is a loss of exclusivity, not an error. - Lock names are shared-namespace (reserved-prefix rules below, ADR-008 §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). Worker semantics
(honker's run_once shape, inherited as the pinned posture; the
delivery callable's shape pinned by
ADR-019 §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): 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 §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). 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:
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 §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 §1) and, since
ADR-021 §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). 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 §3) — resolved over
the target queue's derived engine defaults (visibility/backoff/
retention; no handle is open), per
ADR-020 §3.
Boundary
guarantee: ADR-009 §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 is the contract of record for notify/streams/queues; the locks row is ADR-008 §7's and the scheduler row is ADR-009 §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 §2);
- running
run_schedulesfor 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 §6); *_txoperations 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 §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 §6 — are
part of contract v1's initial text (pre-release additions per
ADR-017 class 1; the
"post-v1 additions" framing an earlier draft used is retired).
LeadershipLost is the loss arm only — the lease expired and was
re-acquired elsewhere; a failed renewal is not a loss and never
returns LeadershipLost: a closed store (respawning a leader
against a dead store is misdirected — the engine-wide closed-store
posture) and storage failure both surface the opaque Database
fallback with the engine detail preserved via the source chain
(annotated 2026-10-11 — review 004 item 9; ADR-009 §1's dated
classification note). 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 (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's single-driver binaries) and
in 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 §4, outbox added by ADR-014, schedule names by ADR-009 §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). - Reserved-prefix names are rejected at every entry point — the
name-bearing methods and their
*_txcounterparts — with the typedReservedNameerror; empty names withInvalidName(ADR-008 §4 — enforced as non-empty-after-whitespace-exclusion per that ADR's wave-1 review annotation: whitespace-only names areInvalidNameon 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, designed per ADR-012). Postgres: engine tables are schema-scoped — one engine-owned schema (ADR-010 §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_renewverified 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
WakeReceivershapes — POCs verified wake delivery/coalescing through their own probe types; the pinnedWake { channel }/ recv forms (§3 of ADR-008) need the contract suite's own property tests on both engines. save_offset_txexactly-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_txin the contract suite.- Concurrent
try_lockloser/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 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 §3) and the opts-stamping resolution (§3a) are computed engine-side per ADR-012 §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 §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_batchapplies the predicate per id (lapsed or non-claimed ids silently not counted);retry's refusal isfalsewith 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_txcommit-atomicity on both engines (ADR-014) — rollback drops the backing-queue job row together with the business write (no ghost job); commit makes it claimable byrun_once; reserved/empty outbox names rejected identically on the tx path (ReservedName/InvalidName); stamped opts visible viaget_jobequal for both engines.- Cross-engine stream equivalence (ADR-015
§3/§4) — publish sequence → identical (per-stream) offset
sequence → identical
read_sinceoutput 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;keyround-trips exactly (NonevsSome),stream/created_atfields equal. publish_with_key_txcommit-atomicity on both engines (ADR-015 §2) — rollback drops the keyed event row with the business write (no ghost event); commit makes it visible toread_since/subscribe; empty-Some-keyInvalidNameon both engines' tx paths.PayloadTooLargeoccurrence asymmetry (ADR-016 §5) — with no capability surface,PayloadTooLargeis the one runtime carriage of an engine asymmetry, so its matchability pins in the suite: an oversizednotify/notify_txpayload 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_tosemantics on both engines (ADR-015 §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 §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 §4) —
same
Valuepublished/enqueued on both engines stores byte-identical rows and decodes back toValueequality (Codeconly on non-serializable target types). - Enqueue-opts resolution equivalence (ADR-020
§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; plainenqueue_txstamps 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 §1) —
reads issued on the tx handle see the transaction's own writes
(
get_job_txa row the same tx enqueued;read_since_txan 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 §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 §3) — reserved-prefix and empty queue arguments rejected at registration withReservedName/InvalidName; stored schedules fire only into non-reserved queue names.- Receiver error/close arms on both engines
(ADR-021 §5) —
recv()'sErrcarriesDatabaseonly;save_offset'sErrcarriesDatabaseonly; regression saves are silent no-ops; SQLite's receiver closes on watcher death (terminalNone); 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 §2) — extents
clamp to empty (
claim_batch(n <= 0)claims nothing; stream reads withlimit <= 0read nothing, never the whole queue/stream), so the SQLiteLIMIT -1dialect artifact is dead on both engines; durations reject (try_lock/renewwithttl <= 0are opaqueErrs on both engines); boundaries are total (a negativetrim_tohorizon deletes nothing, idempotently; the same totality over non-positive values coversheartbeat(extend)'s relative-deadline argument andsave_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 injob_handle_validity_predicateandreceiver_close_and_save_arms, uniformly green on all three engines; the 2026-10-11 general review added the tx-seam form of thesave_offsettotality — a staged negative seed reads raw in-tx and stores raw at commit, pinned inin_tx_reads_see_own_writes, again uniformly green on all three engines). encode_payloadtyped failure (ADR-023 §1) — the helper returnsResult<Vec<u8>, Error::Codec>; enqueue/publish store and decode the exact serialization of the inputValue(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): aValuenested 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 typedCodecrejection, 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
§3) — the SQLite engine's
openpath argument is a plain filesystem path: a string carrying a?name=valuesuffix is treated as a filename, no URI interpretation, no connection- semantics mutation outside the constructor's opts. - Watcher cadence default + knob (ADR-023
§4) — the SQLite store's watcher runs at the inherited 1 ms
default;
SqliteOpts::poll_intervalchanges 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 | Crate split | core + per-engine crates; single-driver binaries |
| 002 | Feature scope | inventory-confirmed features only; cut-flags explicit |
| 006 | Wake contract | opaque wake + re-read; notify-vs-streams guarantee split |
| 007 | Tx seam | caller-held handle, *_tx methods, native commit-atomicity |
| 008 | Contract v1 pinning | surface partition, TxHandle trait shape, Wake type, reserved strings, error taxonomy, config split, locks guarantee row |
| 009 | Scheduler collapse (pre-release extension) | queues + schedule()/run_schedules, @every-only v1, boundary guarantee row |
| 010 | Queue depth (pre-release extension) | visibility/renewal, opts stamping, backoff curve, dead-letter, sweep, layout |
| 011 | Substrate fork | SQLite substrate owned (__alkstore_* naming); queue ops re-derived on contract v1 |
| 012 | Fork design | contract-blind substrate boundary; engine-side formula arithmetic pinned equivalent by the contract suite |
| 014 | Outbox tx enqueue (amends 008) | outbox_enqueue_tx on TxHandle; outbox-name validation; derived backing queue reached only through the outbox surface |
| 015 | Streams depth (amends 006/008) | key = carried metadata, global-FIFO ordering row, StreamEvent shape, publish_with_key_tx, trim_to |
| 016 | Deployment honesty (decides 008's parked question) | no runtime capability surface — compile-time engine identity + documented matrix; PayloadTooLarge occurrence asymmetry pinned |
| 017 | 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 | 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 | 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 | 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 | 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. Key questions affecting this document:
- OQ-10: contract versioning discipline across engine crates — resolved (ADR-017): 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), OQ-05 (queue
semantics depth — ADR-010),
OQ-13 (transactional outbox enqueue shape —
ADR-014), OQ-12 (streams
depth — ADR-015), OQ-08
(capability-surface shape — none, by default ever;
ADR-016), and OQ-10
(the versioning discipline this document's surface is governed by —
ADR-017), 2026-10-05/06.
The second review round (2026-10-06) found no new open questions —
its findings resolved directly as
ADR-019 (handle
surfaces, value shapes, stop token) and
ADR-020
(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 (tx-side
reads, Job.claimed_at, schedule() queue-validation, drop =
rollback, receiver close/error arms).