Files
alkstore/docs/architecture/core-contract.md
T

13 KiB

status, last_updated
status last_updated
draft 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 — and pinned against it. The exact surface is pinned by ADR-008 (contract v1 partition, TxHandle representation, wake type, reserved strings, error taxonomy, config split); 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).
  • 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 rides OQ-09's collapse decision. (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 *_tx methods make side effects commit-atomic with a business write (ADR-007). Contract shape: the *_tx methods live on the TxHandle trait 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) -> event_id
    notify_tx(channel, payload)
    save_offset_tx(stream, consumer, offset)
    commit(self: Box<Self>) -> Result<()>   // or rollback
}

Mechanism handles come off the store (or, for transactional variants, off the handle):

store.notify(channel, payload)          handle.notify_tx(channel, payload)
store.stream(name) -> Stream            handle.publish_tx / save_offset_tx
store.queue(name, opts) -> Queue        handle.enqueue_tx
store.try_lock(name, owner, ttl) -> Option<Lock>
store.listen(channel) -> Box<dyn WakeReceiver>
store.outbox(name) -> Outbox

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). 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 §2's rationale applies identically).

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, 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 §5) — see also OQ-08.
  • listen(channel) -> Box<dyn WakeReceiver> — starts from "now"; delivers opaque Wake { channel } signals (ADR-006), never payloads or ids (ADR-008 §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)) 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).

  • 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 §8).
  • 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 §8).

queues

Durable at-least-once work (ADR-002). The semantics-depth design is queues.md's (OQ-05); the contract-level obligations here:

  • enqueue / enqueue_tx — commit-atomic; EnqueueOpts { delay, run_at, priority, max_attempts, expires } (the v1 skeleton, ADR-008 §1; deeper QueueOpts/retry surface rides OQ-05).
  • claim_one / claim_batch — exactly-once handout under concurrency (POC-pinned on both engines); cancel, get_job — job management and inspection.
  • Job handle: ack / retry / fail / heartbeat; visibility timeouts make at-least-once safe (work re-appears if a claimant dies).
  • sweep_expired — maintenance entry point (design in queues.md).

named locks

TTL-bounded coordination locks, transactional-friendly:

  • 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 honker's machinery — the SQLite-side pin is in the verification backlog below).
  • Guarantee row (ADR-008 §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 §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).

scheduler

Cron/@every enqueueing into named queues, leader-elected where the engine has peers (SQLite: single-host, no election needed beyond documented posture; Postgres: advisory-lock election). Whether this is a first-class mechanism or queues + schedule() is OQ-09.

Cross-cutting contracts

Delivery-guarantee table

The per-mechanism table in ADR-006 is the contract of record for notify/streams/queues; this spec inherits it and adds the consumer obligations:

  • wake idempotence (notify's at-least-once, coalescing behavior);
  • explicit offset saves (streams);
  • visibility-timeout budgeting (queues);
  • *_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 §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). 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.

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).

Naming / reserved namespace

Consumer-visible names (channels, streams, queues, locks) share engine-visible namespaces on Postgres (LISTEN channel names are server-global per database). Pinned by ADR-008 §4:

  • 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 *_tx counterparts — with the typed ReservedName error; empty names with InvalidName (ADR-008 §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: honker's _honker_* internal table family is storage- internal (not consumer namespace); Postgres: queue/stream tables are schema-scoped (layout rides OQ-05) — 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:

  • Lock TTL/expiry re-acquisition on SQLite — pinned on Postgres (pg POC's lock probe); on SQLite it rests on honker's machinery unverified.
  • 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) 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 full honker 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; honker's busy-path under lock contention is the thing to pin).

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

Open Questions

Open questions are tracked in open-questions.md. Key questions affecting this document:

  • OQ-09: scheduler as first-class mechanism vs queues + schedule — also owns the scheduler guarantee row (open)
  • OQ-10: contract versioning discipline across engine crates (open)
  • OQ-08: capability-surface shape (open)
  • OQ-05: queue semantics depth — the v1 skeleton's extension path (open)