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

294 lines
13 KiB
Markdown

---
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 exact 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); 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
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](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) -> 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):
```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<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](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)).
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<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 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<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).
### queues
Durable at-least-once work
([ADR-002](decisions/002-feature-scope.md)). The semantics-depth design
is [queues.md](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](decisions/008-contract-v1-pinning.md) §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](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](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
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](decisions/006-wake-and-delivery-contract.md) 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](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). 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](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](decisions/008-contract-v1-pinning.md) §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](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: 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](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 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](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 |
## Open Questions
Open questions are tracked in
[open-questions.md](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](open-questions.md))
- **OQ-10**: contract versioning discipline across engine crates
([open](open-questions.md))
- **OQ-08**: capability-surface shape ([open](open-questions.md))
- **OQ-05**: queue semantics depth — the v1 skeleton's extension path
([open](open-questions.md))