Files
alkstore/docs/architecture/core-contract.md
T
glm-5.3-flash 2949612e2c ADR-012: forked-substrate design — contract-blind boundary, fidelity posture, port deltas
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.
2026-10-05 05:00:55 +00:00

18 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 base surface is pinned by ADR-008 (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): 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 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 *_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
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 §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). Depth pinned by ADR-010; 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 §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 §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 §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 §1.
  • sweep_expired(queue) — moves every past-expiry row (any state) to dead + enforces dead-letter retention — the no-stranded-rows property (ADR-010 §5); cadence recipe in 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 §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

Collapsed into queues per ADR-009: 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 §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 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 §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 §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 §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 §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).

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 §4 (schedule names added 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 *_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: 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_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) 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 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.

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 (post-v1 extension) queues + schedule()/run_schedules, @every-only v1, boundary guarantee row
010 Queue depth (post-v1 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

Open Questions

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

  • OQ-10: contract versioning discipline across engine crates (open)
  • OQ-08: capability-surface shape (open)

Resolved on this document's surface: OQ-09 (scheduler collapse — ADR-009) and OQ-05 (queue semantics depth — ADR-010), 2026-10-05.