Files
alkstore/docs/architecture/decisions/008-contract-v1-pinning.md
T

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.
  • TxHandle representation — the POC sketch used dyn + per-engine as_any_mut downcast (two recorded frictions, ADR-007).
  • The reserved namespace's exact strings (POC used __listener_reconnected__ ad hoc).
  • The error taxonomy (e.g. is PayloadTooLarge universal when SQLite has no 8000-byte limit?).
  • What listen() returns and where the honker-rs surface gets renamed.
  • The Store::open config 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, the StreamEvent shape, 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_to added; publish_with_key_tx also 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 handle ack / retry / fail / heartbeat; EnqueueOpts { delay, run_at, priority, max_attempts, expires } — the honker-rs field set, carried whole (the POC sketch's shape; run_at is the absolute-time counterpart of delay, not dropped).
  • locks — try_lock(name, owner, ttl) -> Option<Lock> with renew and release.
  • outbox helper — outbox(name) with enqueue + run_once delivery 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 plain enqueue_tx illegal; resolved by ADR-014 — outbox_enqueue_tx joins the TxHandle trait. run_once worker semantics are pinned in core-contract.md's outbox section.)
  • the tx seam — begin_tx / commit / rollback and the *_tx methods (ADR-007). (Amended 2026-10-05 by ADR-014: outbox_enqueue_tx(outbox, opts, payload) added to the TxHandle trait.)
  • subscribe(consumer) -> Box<dyn EventReceiver> — the durable stream subscription handle, delivering StreamEvents with an explicit save_offset on 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, QueueOpts fields 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 Store exposes them at all; v1 has no capability surface. (Resolved 2026-10-06 by ADR-016: no capability surface — by default ever; the boundary is compile-time engine identity + deployment.md's matrix.)
  • 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 Notification type 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 TxHandle for 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 over T: Serialize); commit/rollback take self: 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 }
  • Wake carries only the channel name — the one piece of semantic content ADR-006 allows. Honker's Notification { 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 as Wake { channel: Reserved::LISTENER_RECONNECTED } on every subscriber's receiver (a broadcast-to-all-subscribers fanout — engine behavior recorded in engine-postgres.md).
  • WakeReceiver methods 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() -> None means closed (source death); try_recv/recv_timeout return Err(Closed) when the source is closed, versus Ok(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 *_tx counterparts (notify_tx at minimum; any *_tx op taking a name) — with the typed ReservedName error (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 calling queue("_outbox:foo") directly could collide with outbox("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.
  • Channel-name charset: the contract requires non-empty names and the reserved-prefix rejection; identifier quoting for Postgres LISTEN / pg_notify is 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 the try_recv / recv_timeout form of the wake close in §3).
  • Codec — payload serialization/deserialization failures (payload_as on stream events and job payloads; wakes carry no payload per §3). (Annotated 2026-10-05: a present key on publish_with_key(_tx) being non-empty is validated as InvalidName (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 Store trait (the §1 surface plus begin_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 Store handed out by an engine crate honors the full v1 surface.
  • No capability surface in v1 (OQ-08 owns whether one ever exists). (Resolved 2026-10-06 by ADR-016: by default never — the engine choice stays a dependency-graph fact; PayloadTooLarge is the one runtime carriage of an engine asymmetry.)
  • 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 renew within 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 &Transaction style.
  • OQ-05/OQ-09/OQ-08 remained open here: this ADR deliberately did not pin queue depth, the scheduler shape, or capability flags; the v1 additions to those surfaces were later contract decisions. (OQ-05/OQ-09 resolved by ADR-010/ ADR-009; OQ-08 resolved 2026-10-06 by ADR-016 — none, by default ever.)

References

  • OQ-04 (docs/architecture/open-questions.md) — this ADR's resolution.
  • The honker-rs surface (/workspace/honker packages/honker-rs/src/lib.rs v0.5.0) — the starting artifact; /workspace/honker is 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 — resolved by ADR-016, no runtime surface), OQ-10 (versioning discipline for future contract extensions).