--- status: draft last_updated: 2026-10-05 --- # 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); ADR-009/ADR-010 add the first *post-v1 contract extensions* (scheduler collapse surface, `QueueOpts` depth — versioning discipline for such extensions is OQ-10's); the *obligations* are this document. ## 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). - **Mechanism** — one of the surface's coordination families: notify, streams, queues (+outbox), locks, scheduler. Delivery guarantees are pinned per mechanism in [ADR-006](decisions/006-wake-and-delivery-contract.md)'s table (notify/streams/queues), extended with the locks row by [ADR-008](decisions/008-contract-v1-pinning.md), the scheduler row and collapse by [ADR-009](decisions/009-scheduler-collapse.md). *(Guiding principles are defined in `docs/research/phase-0.md` §Vision and cited by number throughout this directory.)* - **Wake** — the opaque "something changed, re-read" signal the wake contract delivers ([ADR-006](decisions/006-wake-and-delivery-contract.md)). The contract's central abstraction. - **TxHandle** — the caller-held transaction object whose `*_tx` methods make side effects commit-atomic with a business write ([ADR-007](decisions/007-transactional-seam.md)). Contract shape: the `*_tx` methods live on the `TxHandle` trait itself — engines implement it for their concrete handle, no downcast ([ADR-008](decisions/008-contract-v1-pinning.md) §2). ## The seam Per [ADR-008](decisions/008-contract-v1-pinning.md) §6, the trait is constructed by engine crates (durability knobs, pool sizing, watcher cadence are engine options — [deployment.md](deployment.md) carries the facts); the contract is the trait surface it returns: ```text Store::begin_tx() -> Box trait TxHandle { enqueue_tx(name, opts, payload) -> job_id publish_tx(stream, payload) -> event_id notify_tx(channel, payload) save_offset_tx(stream, consumer, offset) commit(self: Box) -> Result<()> // or rollback } ``` Mechanism handles come off the store (or, for transactional variants, off the handle): ```text store.notify(channel, payload) handle.notify_tx(channel, payload) store.stream(name) -> Stream handle.publish_tx / save_offset_tx store.queue(name, opts) -> Queue handle.enqueue_tx store.try_lock(name, owner, ttl) -> Option store.listen(channel) -> Box store.outbox(name) -> Outbox store.schedule(name, spec, queue, payload, opts) -> Result store.unschedule(name) -> bool store.run_schedules(stop) -> Result<()> ``` Payloads cross the trait as core value types (non-generic trait methods — object safety of the boxed handles, [ADR-008](decisions/008-contract-v1-pinning.md) §2); `payload_as` decodes on concrete returned values (`Job`, `StreamEvent`). The `with_tx` closure wrapper ships over the handle shape (the POC-verified composition direction, [ADR-007](decisions/007-transactional-seam.md)). Mechanism handles (`Stream`, `Queue`, `Outbox`, `Lock`) are core-owned trait objects like the tx handle — engine types never appear in consumer signatures; their trait methods pin at implementation, mirroring the `TxHandle` pattern ([ADR-008](decisions/008-contract-v1-pinning.md) §2's rationale applies identically). ## 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); 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) — see also OQ-08. - `listen(channel) -> Box` — starts from "now"; delivers opaque `Wake { channel }` signals ([ADR-006](decisions/006-wake-and-delivery-contract.md)), never payloads or ids ([ADR-008](decisions/008-contract-v1-pinning.md) §3). Wakes are at-least-once, possibly coalesced (SQLite) or per-notify (Postgres), possibly repeated after reconnect. **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). - `publish` / `publish_tx` / `publish_with_key` — append to the stream's durable log. - `read_since` / `read_from_consumer(offset)` — cursor-based reads; `get_offset(consumer)` — checkpoint inspection. - `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). - `subscribe(consumer) -> Box` — durable consumption: attach, read to current tail, resume after restart from the stored offset; explicit `save_offset` on the receiver (shape in [ADR-008](decisions/008-contract-v1-pinning.md) §8). ### 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 }`; queue-level `QueueOpts { visibility_timeout_s, max_attempts, backoff_base_s, dead_letter_retention_s }` ([ADR-010](decisions/010-queue-semantics-depth.md) §3/§4). - `claim_one` / `claim_batch` — exactly-once handout under concurrency (POC-pinned on both engines); claim ordering: priority DESC, then ready-time, then enqueue order (FIFO under equal priority). - Job handle: `ack / retry / fail / heartbeat`. `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. 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). `QueueOpts` stamp onto the job row at enqueue (§3a) — queues are names, not config owners. The remaining v1-skeleton ops (`ack_batch`, `cancel` — unconditional delete, not an interrupt) pin in [ADR-010](decisions/010-queue-semantics-depth.md) §1. - `sweep_expired(queue)` — 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); cadence recipe in [queues.md](queues.md) (no ambient sweeper). ### named locks TTL-bounded coordination locks, transactional-friendly: - `try_lock(name, owner, ttl)` — acquire or fail (`Option` — no-work is a value, not an error); `renew` and `release` on the lock handle. Release on explicit unlock or TTL expiry. Re-acquirable after expiry (POC-pinned on Postgres; on SQLite it rests on the forked substrate's lock machinery — the SQLite-side pin is in the verification backlog below). - **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)). ### 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)` runner (leader-elected where the engine has peers — the leadership lock is the reserved name `__alkstore_scheduler`). Schedules never fire without a runner (no ambient timers). v1 spec grammar: `@every ` only (`s|m|h|d`); cron strings are rejected (`InvalidSpec`) pending a consumer-inventory row naming wall-clock cron. 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 at-least-once, coalescing behavior); - explicit offset saves (streams); - 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, 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). Post-v1 additions: `InvalidSpec` (schedule spec grammar) and `LeadershipLost` (`run_schedules` return on leadership loss) — both from [ADR-009](decisions/009-scheduler-collapse.md) §6, the only taxonomy deltas so far. 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 in contract v1. Whether the `Store` exposes engine capabilities at all — and if so, which (payload limits, host semantics, wake-cadence knobs) — is OQ-08's decision ([deployment.md](deployment.md)). ### Naming / reserved namespace Consumer-visible names (channels, streams, queues, locks, and schedule names) share engine-visible namespaces on Postgres (LISTEN channel names are server-global per database). Pinned by [ADR-008](decisions/008-contract-v1-pinning.md) §4 (schedule names added 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). 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. ## 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 (post-v1 extension) | queues + `schedule()`/`run_schedules`, `@every`-only v1, boundary guarantee row | | [010](decisions/010-queue-semantics-depth.md) | Queue depth (post-v1 extension) | visibility/renewal, opts stamping, backoff curve, dead-letter, sweep, layout | | [011](decisions/011-sqlite-substrate-fork.md) | Substrate fork | SQLite substrate owned (`__alkstore_*` naming); queue ops re-derived on contract v1 | | [012](decisions/012-forked-substrate-design.md) | Fork design | contract-blind substrate boundary; engine-side formula arithmetic pinned equivalent by the contract suite | ## 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 ([open](open-questions.md)) - **OQ-08**: capability-surface shape ([open](open-questions.md)) Resolved on this document's surface: **OQ-09** (scheduler collapse — [ADR-009](decisions/009-scheduler-collapse.md)) and **OQ-05** (queue semantics depth — [ADR-010](decisions/010-queue-semantics-depth.md)), 2026-10-05.