# ADR-008: Contract v1 surface pinning ## Status Accepted (2026-10-05, Phase 1 — OQ-04's resolution; binds [core-contract.md](../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](003-sqlite-driver.md), [ADR-004](004-postgres-driver.md)) and the load-bearing decisions made ([ADR-006](006-wake-and-delivery-contract.md) wake contract + guarantee table, [ADR-007](007-transactional-seam.md) 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](007-transactional-seam.md)). - 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](002-feature-scope.md)), 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](006-wake-and-delivery-contract.md)). *(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](../core-contract.md) streams. **Resolved 2026-10-05 by [ADR-015](015-streams-depth.md)** — 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` 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](014-outbox-tx-enqueue.md) — `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](007-transactional-seam.md)). *(Amended 2026-10-05 by [ADR-014](014-outbox-tx-enqueue.md): `outbox_enqueue_tx(outbox, opts, payload)` added to the `TxHandle` trait.)* - `subscribe(consumer) -> Box` — the durable stream subscription handle, delivering `StreamEvent`s 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](016-deployment-honesty.md): 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](../engine-postgres.md)'s LISTEN-driven claim), engine-internal. - **Rate limits, result storage** — cut-flag rows ([ADR-002](002-feature-scope.md)); re-enter only via a consumer-inventory row. - **Notification payloads on wakes** — wake is opaque ([ADR-006](006-wake-and-delivery-contract.md)); 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**: ```text 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) -> 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` — 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` (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](007-transactional-seam.md)) — 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: ```text listen(channel) -> Box trait WakeReceiver { recv() -> Option // None = source closed (watcher death) try_recv() -> Result> recv_timeout(d) -> Result> } struct Wake { channel: String } ``` - **`Wake` carries only the channel name** — the one piece of semantic content [ADR-006](006-wake-and-delivery-contract.md) 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](006-wake-and-delivery-contract.md)); 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](../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](006-wake-and-delivery-contract.md); 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](014-outbox-tx-enqueue.md))*. - **The one v1-reserved string:** `__alkstore_listener_reconnected__` — the Postgres forwarder's synthetic reconnect-wake channel ([ADR-004](004-postgres-driver.md)). 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](011-sqlite-substrate-fork.md) — 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](../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](015-streams-depth.md) §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 `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](../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](016-deployment-honesty.md): 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](006-wake-and-delivery-contract.md)'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`, explicit saves only | the explicit-offset obligation (core-contract.md streams; the auto-checkpoint ambiguity is not inherited, [ADR-006](006-wake-and-delivery-contract.md)) | | `StreamEvent.topic` (field) | `StreamEvent.stream` *(added 2026-10-05 by [ADR-015](015-streams-depth.md) §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](012-forked-substrate-design.md) §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](002-feature-scope.md) | 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` 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): ```text trait EventReceiver { recv() -> Option> // None = stream closed try_recv() -> Result> read_since(offset, limit) -> Result> 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](../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)` 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](010-queue-semantics-depth.md)/ [ADR-009](009-scheduler-collapse.md); OQ-08 resolved 2026-10-06 by [ADR-016](016-deployment-honesty.md) — 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](005-dependency-ownership.md) 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](../core-contract.md) — the spec this ADR's §1–§5 pin into place. - [ADR-002](002-feature-scope.md) (scope), [ADR-006](006-wake-and-delivery-contract.md) (wake contract, namespace existence, guarantee table), [ADR-007](007-transactional-seam.md) (tx seam mechanics). - OQ-09 (scheduler row transfer), OQ-08 (capability surface — resolved by [ADR-016](016-deployment-honesty.md), no runtime surface), OQ-10 (versioning discipline for future contract extensions).