Follow-through on OQ-06/ADR-011: pin the fork's structural decisions (alkstore-substrate as a vendored path-dep crate, contract-blind API boundary with contract formulas computed engine-side and pinned equivalent by the contract suite, keep-the-kept-half API fidelity for cheap cherry-picks, the W-1/W-2/dead-man's-switch/W-4 port deltas decided per item, bootstrap re-keying off error-string matching, no rename migration, deliberate upstream tracking). Consistency sweep across the doc set for the fork: annotate ADR-003/ 005/009/010 and core-contract for superseded ownership facts, fix schedule-storage table naming (ADR-009 §5, queues.md), re-key ADR-010 §6's notifications hygiene to the at-attach cap the fork scope realizes, add OQ-11 (scaffold-time residue), and complete both ADR indexes. Independent review: 0 critical, warnings addressed.
366 lines
18 KiB
Markdown
366 lines
18 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 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<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
|
|
store.schedule(name, spec, queue, payload, opts) -> Result<Schedule>
|
|
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<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)). 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<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).
|
|
- **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 <n><unit>` 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. |