23 KiB
ADR-008: Contract v1 surface pinning
Status
Accepted (2026-10-05, Phase 1 — OQ-04's resolution; binds core-contract.md; engine specs map it)
Context
OQ-04 (promoted from OQ-ST-04) scoped the contract work: with both
engines POC-verified (ADR-003,
ADR-004) and the load-bearing decisions made
(ADR-006 wake contract + guarantee
table, ADR-007 caller-held tx seam), what
remained was pinning the exact trait surface against the honker-rs
starting artifact (/workspace/honker
packages/honker-rs/src/lib.rs v0.5.0, the §Interface finding in
phase-0.md). The unpinned items:
- Which surface parts are contract v1 vs engine-extension.
TxHandlerepresentation — the POC sketch useddyn+ per-engineas_any_mutdowncast (two recorded frictions, ADR-007).- The reserved namespace's exact strings (POC used
__listener_reconnected__ad hoc). - The error taxonomy (e.g. is
PayloadTooLargeuniversal when SQLite has no 8000-byte limit?). - What
listen()returns and where the honker-rs surface gets renamed. - The
Store::openconfig split (contract vs engine-crate option). - Delivery-guarantee rows for locks and scheduler (ADR-006's table covered notify/streams/queues only).
- A verification backlog for properties POC-pinned on one engine only.
All of this is paper over a complete evidence base: the honker-rs surface listing, both POC findings (including the trait sketches and their recorded frictions), the consumer inventory rows (ADR-002), and the guiding principles (phase-0.md — engine-agnostic consumer API, no engine-type branching).
Decision
1. Contract v1 partition
Contract v1 (the core crate's trait surface, both engines must implement identically):
- notify / listen —
notify(channel, payload);listen(channel). - streams —
publish,publish_with_key,read_since,read_from_consumer,save_offset,get_offset,subscribe(durable log, explicit offsets — the auto-checkpoint ambiguity is not inherited, per ADR-006). (Depth annotation 2026-10-05: the key's semantics, theStreamEventshape, the ordering guarantee row, and log retention are OQ-12's — the method names are pinned here, their depth was left un-pinned, mirrored in core-contract.md streams. Resolved 2026-10-05 by ADR-015 — key = carried metadata, global-FIFO ordering row, event shape pinned,trim_toadded;publish_with_key_txalso joins the tx seam (§2 there); the depth was completed in place, pre-implementation.) - queues v1 skeleton —
enqueue,claim_one,claim_batch,ack_batch,cancel,get_job,sweep_expired; job handleack / retry / fail / heartbeat;EnqueueOpts { delay, run_at, priority, max_attempts, expires }— the honker-rs field set, carried whole (the POC sketch's shape;run_atis the absolute-time counterpart ofdelay, not dropped). - locks —
try_lock(name, owner, ttl) -> Option<Lock>withrenewandrelease. - outbox helper —
outbox(name)withenqueue+run_oncedelivery worker. (Depth annotation 2026-10-05: the transactional enqueue shape was OQ-13's — this §1/§2 method list had no outbox enqueue method and the derived backing queue's reserved prefix made plainenqueue_txillegal; resolved by ADR-014 —outbox_enqueue_txjoins theTxHandletrait.run_onceworker semantics are pinned in core-contract.md's outbox section.) - the tx seam —
begin_tx/ commit / rollback and the*_txmethods (ADR-007). (Amended 2026-10-05 by ADR-014:outbox_enqueue_tx(outbox, opts, payload)added to theTxHandletrait.) subscribe(consumer) -> Box<dyn EventReceiver>— the durable stream subscription handle, deliveringStreamEvents with an explicitsave_offseton the receiver (no auto-checkpoint; §8 defines the shape).
Not in v1 — deliberately owned elsewhere:
- Queue semantics depth (retry/backoff curve, dead-letter
move-vs-flag, visibility-renewal mechanics, sweep cadence,
QueueOptsfields beyond the v1 skeleton) — OQ-05's design surface. v1 pins the skeleton above; depth additions extend the contract later. - Scheduler surface shape (first-class mechanism vs queues +
schedule()) — OQ-09 decides; the scheduler surface (if any) is not part of contract v1. - Capability flags — OQ-08 decides whether
Storeexposes them at all; v1 has no capability surface. claim_waker— dropped from the contract surface; wake-driven claim is the engine's consumption posture (engine-postgres.md's LISTEN-driven claim), engine-internal.- Rate limits, result storage — cut-flag rows (ADR-002); re-enter only via a consumer-inventory row.
- Notification payloads on wakes — wake is opaque
(ADR-006); the payload carrying
honker's
Notificationtype happens to provide is not surfaced in the contract (see §3).
2. TxHandle representation: the *_tx methods live on the handle trait
The downcast friction dissolves by moving the ops, not by choosing a
handle encoding. The POC sketches put enqueue_tx etc. on the
engine/store trait taking &mut dyn TxHandle, forcing each impl to
downcast the handle to its concrete type. The contract instead defines
a TxHandle trait carrying the *_tx methods directly:
trait TxHandle {
enqueue_tx(name, opts, payload) -> job_id
publish_tx(stream, payload) -> offset
publish_with_key_tx(stream, key, payload) -> offset // ADR-015
notify_tx(channel, payload)
save_offset_tx(stream, consumer, offset)
outbox_enqueue_tx(outbox, opts, payload) -> job_id // ADR-014
commit(self: Box<Self>) -> Result<()> // or rollback
}
(Sketches elide Result<> wrappers on *_tx returns for brevity —
every fallible op returns the taxonomy of §5; notify_tx can fail
with PayloadTooLarge/ReservedName exactly like its auto-commit
counterpart.)
Store::begin_tx() -> Box<dyn TxHandle + Send>— callers hold the boxed handle across await points.- Each engine implements
TxHandlefor its own concrete handle — no downcast, no enum, no generic parameters. The friction the POCs recorded disappears because there is no engine-store dispatch of a handle argument; the handle is the object. - Object safety constraints that follow: no generic methods on
TxHandle, payload serialization is value-typed in core (serde_json::Value— the type both POCs used; not generic overT: Serialize);commit/rollbacktakeself: Box<Self>(dispatchable on a boxed trait object). - Why not the enum (one variant per engine): the enum's payload types would make core depend on engine types — inverting ADR-001's dependency direction (engines depend on core, never the reverse) — or make the variants engine-opaque, which re-introduces downcast one step further away. A new engine (a stated ADR-001 goal) must not be a core-crate enum edit.
- The generic-vs-dyn alternative buys static dispatch for a hot-path cost the POCs measured as negligible against the seam itself (SQLite hop 0.35 ms p50, pg 2.4 ms p50 — both dominated by commit costs); it is the recorded optimization if ever needed (same posture as ADR-003's dedicated-bridge optimization).
- Async dispatch over a boxed handle uses the desugared boxed-future form (the family-standard async trait posture); its per-op cost is bounded by the same measurements.
- Thread-affinity note retained verbatim: SQLite tx ops serialize through the writer slot (ADR-007) — a documented engine note, not a contract item.
3. Wake type and the listen() return
listen(channel) returns a wake receiver, not honker's
subscription:
listen(channel) -> Box<dyn WakeReceiver>
trait WakeReceiver {
recv() -> Option<Wake> // None = source closed (watcher death)
try_recv() -> Result<Option<Wake>>
recv_timeout(d) -> Result<Option<Wake>>
}
struct Wake { channel: String }
Wakecarries only the channel name — the one piece of semantic content ADR-006 allows. Honker'sNotification { id, channel, payload }is not surfaced: the pg engine would have to discard payloads to honor the opaque contract, and honoring the contract uniformly is what makes consumer code engine-agnostic. Consumers needing content use streams (the guarantee split, ADR-006). The SQLite engine's payload transport is a non-contract implementation detail.- Receiver close is the failure surface: SQLite watcher death closes
the receiver (
recv() -> None, ADR-006); the Postgres reconnect-wake arrives asWake { channel: Reserved::LISTENER_RECONNECTED }on every subscriber's receiver (a broadcast-to-all-subscribers fanout — engine behavior recorded in engine-postgres.md). WakeReceivermethods have no wake-count or id: wakes are hints, their multiplicity is not part of the contract (coalescing on SQLite, per-notify on Postgres — ADR-006). Close semantics across the receiver's forms:recv() -> Nonemeans closed (source death);try_recv/recv_timeoutreturnErr(Closed)when the source is closed, versusOk(None)when merely no wake is pending right now — closed and idle are distinguishable states.
4. Reserved namespace — exact strings
- Reserved prefix:
__alkstore_(leading double underscore). The namespace exists per ADR-006; this pins its string. Engine-independent — applies to channel, stream, queue, lock, and outbox names on every engine (outbox names added to the kinds list by ADR-014). - The one v1-reserved string:
__alkstore_listener_reconnected__— the Postgres forwarder's synthetic reconnect-wake channel (ADR-004). The POC's ad-hoc__listener_reconnected__string is renamed into the namespace at implementation; nothing (consumer or contract text) depended on the ad-hoc string. - Consumer names with the reserved prefix are rejected at the entry
points — the name-bearing surface methods (
notify,listen,stream,queue,outbox,try_lock) and their*_txcounterparts (notify_txat minimum; any*_txop taking a name) — with the typedReservedNameerror (taxonomy, §5). Engine-side validation obligation: reject before any engine round trip, on both the auto-commit and tx paths (validation must not be bypassable by the commit-atomic path). Stream-consumer names (save_offset(_tx)consumer arguments,subscribe(consumer)) are not a reserved- namespace kind — they are consumer-local identifiers, not shared engine namespaces; no prefix rule applies (an engine may still quote them). - Per-engine internal names:
- SQLite: honker's machinery owns two categories of internal names,
and the contract treats them differently. Its
_honker_*table family (_honker_dead,_honker_locks, …) is storage-internal — not part of any consumer namespace; its fate resolved with the quality read (OQ-06, ADR-011 — the fork re-owns the names as__alkstore_*). Its consumer-namespace derived names are a real collision surface: honker-rs materializes an outbox's backing queue as_outbox:{name}inside the queue-name namespace — a consumer callingqueue("_outbox:foo")directly could collide withoutbox("foo"). The contract's rule: engine-derived names in consumer namespaces carry the reserved prefix — the SQLite engine's outbox backing queue is__alkstore_outbox:{name}, derived from the consumer's outbox name, and every entry point rejects the reserved prefix for directly-supplied names (so a consumer can never create or collide with it). Honker's_outbox:scheme is not inherited verbatim. - Postgres: queue/stream tables are schema-scoped to avoid colliding with consumer tables (the co-tenancy precedent; layout decision rides OQ-05's namespace bullet, queues.md). The reserved channel namespace is this section's; the outbox backing queue obeys the same reserved-prefix rule as SQLite's.
- SQLite: honker's machinery owns two categories of internal names,
and the contract treats them differently. Its
- Channel-name charset: the contract requires non-empty names and the
reserved-prefix rejection; identifier quoting for Postgres LISTEN /
pg_notifyis the engine's obligation (POC #2 owned the quoting pitfall — test-pinned).
5. Error taxonomy
One top-level Error in the core crate, thiserror-typed (family
standard). Pinning rule: an error gets its own matchable variant
only when the caller can act differently on it — engine-specific
conditions that a caller cannot act on stay inside the opaque
fallback with their detail in the source chain. This is the line that
keeps the taxonomy small and keeps it from inheriting honker's
per-binding sprawl.
v1 variants (guaranteed-matchable on every engine):
PayloadTooLarge { limit }— universal variant, produced by the Postgres engine's client-side check (8000 bytes, POC #2 verified-before-round-trip) and by contract never on SQLite (no limit — the documented engine asymmetry). The variant lives in the shared taxonomy so the limit asymmetry is visible to engine-agnostic code — a caller can match it without knowing which engine is behind the store, and the error itself documents the boundary. The large-payload alternative (a table row with the id in the notification — the outbox shape) is the documented workaround.ReservedName { name }— rejected reserved-prefix names (§4).InvalidName { name }— empty names and other entry-point validation failures (§4).Closed— an operation against a closed receiver/source (also thetry_recv/recv_timeoutform of the wake close in §3).Codec— payload serialization/deserialization failures (payload_ason stream events and job payloads; wakes carry no payload per §3). (Annotated 2026-10-05: a present key onpublish_with_key(_tx)being non-empty is validated asInvalidName(ADR-015 §2 — the same entry-point-validation variant, not a new one).)Database— everything else: driver/connection/SQL errors, opaque to the contract, engine detail preserved via the error source chain. No engine-specific variants are minted for its contents.
Explicitly not taxonomy items (callers act via value results, not
errors): queue claim returning none (no work); job-handle ops
(ack/retry/fail/heartbeat) returning "did the op land" booleans
(false = the job was no longer claimable/owned — expired, acked
elsewhere, cancelled); try_lock returning Option<Lock>; lock
release's boolean.
6. Store::open config shape — engine options, not contract surface
- The core crate defines the
Storetrait (the §1 surface plusbegin_tx). Constructors and their option structs live in the engine crates (alkstore_sqlite::open(path, SqliteOpts)/alkstore_postgres::open(url, PgOpts)): durability knobs, pool sizing, pragmas, watcher cadence, listener posture are engine-configuration concerns — deployment.md carries the knob facts, engine-crate docs carry the options. - What is contract: the trait the constructor returns, and the
constructor's own obligation — a
Storehanded out by an engine crate honors the full v1 surface. - No capability surface in v1 (OQ-08 owns whether one ever exists).
- Consumer code stays engine-agnostic by depending on core and being constructed by exactly one engine crate (ADR-001's single-driver binaries) — the engine choice is a dependency-graph fact, not a runtime branch.
7. Delivery-guarantee rows — locks pinned, scheduler owned by OQ-09
ADR-006's table is extended by this decision:
| Mechanism | Durability | Replay | Atomicity | Guarantee |
|---|---|---|---|---|
| locks | row-backed (machinery-defined) | n/a | acquire/release atomic | mutual exclusion bounded by TTL + renewal discipline: a lock excludes other holders while held (explicit release or within TTL); after TTL expiry exclusion lapses silently — no revocation event, exclusivity resumes with re-acquisition; a holder renewing inside its TTL keeps exclusion |
- The consumer obligation this row carries: long held-lock work must
renewwithin TTL; expiry is not an error but a loss of exclusivity (the honest TTL semantics the alkblobs fleet-sweeper ADR already treats as ground). - The scheduler guarantee row is not pinned here — it is defined by OQ-09's collapse decision (under collapse, the scheduler inherits the queues row; the scheduler-tick/leader-election guarantees become part of OQ-09's and OQ-05's resolution text). This is an explicit transfer to OQ-09's scope, not an open residue of this ADR.
8. Renames and grouping deltas from the honker-rs surface
Pinned deltas (the starting artifact's names, where the contract differs):
| honker-rs | contract v1 | Reason |
|---|---|---|
Subscription (listen) |
WakeReceiver / Wake |
wake is opaque — no payload, no id; name must not suggest subscription semantics (no replay, ADR-006) |
Notification { id, channel, payload } |
Wake { channel } |
§3; payload is non-contract |
Lock::heartbeat(ttl) |
Lock::renew(ttl) |
renew describes the TTL discipline the guarantee row pins; heartbeat is the queue-side renewal term |
Queue::claim_waker() |
(not surfaced) | engine-internal consumption wake (§1) |
StreamSubscription (save_every, auto-save on drop) |
subscribe(consumer) -> Box<dyn EventReceiver>, explicit saves only |
the explicit-offset obligation (core-contract.md streams; the auto-checkpoint ambiguity is not inherited, ADR-006) |
StreamEvent.topic (field) |
StreamEvent.stream (added 2026-10-05 by ADR-015 §3) |
one term for the mechanism everywhere — the contract names it streams (the §4 kinds list); the substrate's column stays topic per the ADR-012 §3 fidelity posture |
update_events() |
(not surfaced) | superseded by the wake subscription — the pinned WakeReceiver is the reactive-event surface; honker's separate raw update-event stream has no contract role |
prune_notifications / prune_notifications_keep_latest |
(not surfaced) | notifications-table maintenance tooling on the SQLite engine; disposition rides OQ-05's sweep/maintenance design (consumer-visible only if that design surfaces it) |
Scheduler surface |
(out of v1, OQ-09 decides) | §1 |
try_rate_limit / save_result / get_result / sweep_results |
(cut-flag rows) | ADR-002 |
Grouping stays honker-shaped otherwise: mechanism handles off the
store (stream(name), queue(name, opts), outbox(name)), the tx
handle off begin_tx, payloads serialized through core value types
(non-generic trait methods, §2), payload_as<T> as the decode
convenience on concrete returned values (Job, StreamEvent) — the
deserialization error is Codec, not a per-type variant.
The stream subscription handle's shape (defined here, since §1 pins the return type):
trait EventReceiver {
recv() -> Option<Result<StreamEvent>> // None = stream closed
try_recv() -> Result<Option<StreamEvent>>
read_since(offset, limit) -> Result<Vec<StreamEvent>>
save_offset(&mut self) -> Result<()> // explicit only — the
// offset advances per read;
// the save is the consumer's
// checkpoint call
offset() -> i64 // the receiver's current offset
}
No save_every, no save-on-drop — checkpointing is the consumer's
explicit call (the §1 obligation). The consumer's name is fixed at
subscribe(consumer); it is a consumer-local identifier (no reserved
prefix, §4).
Consequences
Positive
- The contract v1 is a complete, implementable surface: every open shape question from the OQ-04 list is pinned; no placeholder semantics remain in core-contract.md.
- The tx-seam downcast friction is dissolved, not tolerated — the trait shape makes each engine's handle self-contained, and a future engine is purely additive (implement the traits; no core edits).
- The taxonomy rule (variants only where callers act) is a durable principle for later surface additions, keeping queue depth (OQ-05) from accreting error variants.
- The reserved namespace is exact (
__alkstore_), testable, and engine-independent.
Negative
- Dyn dispatch on
begin_tx/mechanism returns means boxed handles and boxed futures per op — bounded by POC measurement as negligible against commit costs, but real; monomorphized fast paths are the recorded optimization if profiles ever demand them. - Wake payloads are dropped from what the SQLite engine could carry through — engine-side, honker's transport capability is unused by the contract surface (the cost of an honest uniform contract).
commit(self: Box<Self>)shape means a consumed handle can't be reused after commit (callers re-begin_tx) — matches the caller-owned lifetime the POCs verified, but is stricter than honker's re-usable&Transactionstyle.- OQ-05/OQ-09/OQ-08 remain open: this ADR deliberately does not pin queue depth, the scheduler shape, or capability flags; the v1 additions to those surfaces will be later contract extensions.
References
- OQ-04 (
docs/architecture/open-questions.md) — this ADR's resolution. - The honker-rs surface (
/workspace/honkerpackages/honker-rs/src/lib.rsv0.5.0) — the starting artifact;/workspace/honkeris a reference checkout (ADR-005 posture). docs/research/poc-sqlite-posture-findings.md§Probe 1 (trait friction), §"What feeds where";docs/research/poc-pg-posture-findings.md§Sub-module T (tx-seam shapes), §Sub-module W (wake/reconnect), §Payload boundary (PayloadTooLarge).- core-contract.md — the spec this ADR's §1–§5 pin into place.
- ADR-002 (scope), ADR-006 (wake contract, namespace existence, guarantee table), ADR-007 (tx seam mechanics).
- OQ-09 (scheduler row transfer), OQ-08 (capability surface), OQ-10 (versioning discipline for future contract extensions).