# ADR-019: Mechanism-handle surfaces — handle traits, value shapes, claimant identity, stop token ## Status Accepted (2026-10-06, Phase 1 — second architecture review round, pre-decomposition; amends [ADR-008](008-contract-v1-pinning.md) §1/§2/ §8 in place, pre-implementation ([ADR-017](017-contract-versioning.md) class 1 — no release exists; the amend-in-place frame is still open)) ## Context Contract v1 pinned the `TxHandle`, `WakeReceiver`, and `EventReceiver` trait shapes exactly, but the mechanism handles were left deliberately open: - [core-contract.md](../core-contract.md) (pre-review): "Mechanism handles (`Stream`, `Queue`, `Outbox`, `Lock`) are core-owned trait objects … their trait methods pin at implementation, mirroring the `TxHandle` pattern." - [ADR-009](009-scheduler-collapse.md) §1 deferred the `run_schedules` stop parameter's type the same way ("the exact type shapes at implementation, engine-crate docs"). - `Job` and `Schedule` — both `#[non_exhaustive]` consumer-read types under [ADR-017](017-contract-versioning.md) §3 — had prose-only field lists, split across ADR-010 §1/§3a and ADR-009 §1. - The two `save_offset` forms (the pinned `save_offset(stream, consumer, offset)` store/tx form and [ADR-008](008-contract-v1-pinning.md) §8's receiver-side `save_offset(&mut self)`) had no stated composition. The second architecture review flagged that "pin at implementation" no longer holds: [ADR-017](017-contract-versioning.md) makes trait methods the lockstep-priced additive class and their shapes permanent contract surface, and that ADR's class-1 window (amend-in-place) **terminates at the core crate's first release** — and these shapes ship in that first release. So the pinning must happen in an ADR now, or the implementation agent would be authoring versioned contract surface with no decision record behind it. The decisions below are derived, not invented: honker-rs (`/workspace/honker` `packages/honker-rs/src/lib.rs` @ f4e53c6) is the starting artifact every placement so far has mirrored, and its handle placement is the inherited shape — `[ADR-002](002-feature-scope.md)`'s "fidelity where kept" posture applied to the *contract* side the way [ADR-012](012-forked-substrate-design.md) §3 applied it to the substrate's. ## Decision ### 1. Handle placement mirrors honker-rs — mechanism ops live on the mechanism handles Naming, construction, and placement (the return types are boxed trait objects — core-owned traits, engine types never in consumer signatures, the [ADR-008](008-contract-v1-pinning.md) §2 posture): ```text store.queue(name, opts) -> Box store.stream(name) -> Box store.outbox(name) -> Box store.try_lock(name, owner, ttl) -> Option> ``` *(Trait-name note: the stream handle's trait is `StreamHandle`, not `Stream` — `StreamEvent` already occupies the type-name's vocabulary and a bare `Stream` trait beside `EventReceiver` invites confusion with tokio's `Stream`. The honker-rs struct name maps to `StreamHandle`; everything else keeps honker's names.)* ```text trait Queue { name(&self) -> &str enqueue(payload, EnqueueOpts) -> job_id claim_one(worker_id) -> Option> claim_batch(worker_id, n) -> Vec> ack_batch(ids) -> count // non-claimed ids not counted cancel(job_id) -> bool // unconditional delete get_job(job_id) -> Option // sees dead rows; a value, // no ops — see §2 sweep_expired() -> count // this queue's rows } trait StreamHandle { name(&self) -> &str publish(payload) -> offset publish_with_key(key, payload) -> offset read_since(offset, limit) -> Vec read_from_consumer(consumer, limit) -> Vec save_offset(consumer, offset) get_offset(consumer) -> i64 // absent consumer = 0 trim_to(horizon) -> count // deletes offset <= horizon subscribe(consumer) -> Box // shape: ADR-008 §8 } trait Outbox { name(&self) -> &str enqueue(payload, EnqueueOpts) -> job_id // into the derived // backing queue run_once(worker_id, delivery) -> bool // see §4 } trait Lock { name(&self) -> &str renew(ttl) -> bool // new full TTL window from // now; false = lost it release(self: Box) -> bool // consuming; true if held } ``` - **Why mirror honker's placement**: it is the starting artifact's proven shape; every prior decision (claim ordering, QueueOpts stamping, the v1 skeleton's method *names*) already assumed queue-scoped claim/maintenance ops — `sweep_expired(queue)` on `Store` would take the name argument that the handle already carries; a `Queue` handle without `claim_*` would be a construction ceremony with no behavior. Honker's `Job`-ops-on-claimed-work shape and `Lock::release(self)` RAII shape are likewise inherited. - `get_job`, `save_offset`, `get_offset`, `read_since`, `read_from_consumer` remain available from *inside* a transaction through the `TxHandle`'s `*_tx` methods (`get_job_tx`, `get_offset_tx`, `read_since_tx`, `read_from_consumer_tx` — [ADR-021](021-tx-reads-and-value-shape-fixes.md) §1) — the handle forms are the auto-commit convenience counterparts ([ADR-007](007-transactional-seam.md)); claim/maintenance ops are deliberately not tx-shaped (no `claim_tx` — a claim's visibility deadline must not be tied to a business transaction's lifetime). - The **reserved-prefix / name validation rules apply to the constructors** (`queue`/`stream`/`outbox`/`try_lock`) exactly as pinned by [ADR-008](008-contract-v1-pinning.md) §4 — nothing new; the handles carry validated names. - Trait *method-set* additions on these handle traits post-release are [ADR-017](017-contract-versioning.md) class 2 — the same lockstep cost as `TxHandle` additions; stated here so the cost is priced before any consumer depends on it. ### 2. Claimant identity — `worker_id`, caller-supplied and consumer-local - `claim_one`/`claim_batch`/`run_once` take a **`worker_id: &str`** — the claim's ownership token, stamping the row's `worker_id` column. It is a **consumer-local identifier in the stream-consumer-name class** ([ADR-008](008-contract-v1-pinning.md) §4: no reserved- prefix rule, no shared namespace): callers self-supply (a host id, a task label); the contract requires only non-emptiness (`InvalidName` otherwise — the v1 skeleton's only name-kind rule addition, same variant, no new taxonomy). - Cross-process semantics are the queue mechanism's, not the identifier's: claim exclusivity comes from the claim statement's atomicity ([ADR-010](010-queue-semantics-depth.md)), not from id uniqueness; two workers self-supplying the same string is their confusion to avoid (ops tooling reads it, nothing enforces it). ### 3. Value shapes — `Job` and `Schedule` pinned as structs Both `#[non_exhaustive]` per [ADR-017](017-contract-versioning.md) §3 (field additions ride class 2): ```text struct Job { id: i64, queue: String, state: JobState, // enum: Pending | Processing // | Dead payload: Vec, priority: i64, run_at: i64, // the claim-order key attempts: i64, // every claim counts max_attempts: i64, // the row's stamp worker_id: Option, // claimant, None pre-claim claimed_at: Option, // unix seconds at the most // recent claim; None pre-claim // (added by ADR-021 §2) claim_expires_at: Option, // deadline, None pre-claim created_at: i64, // unix seconds expires_at: Option, // job-level expiry, None = // never visibility_timeout_s: i64, // the §3a stamps, dead rows backoff_base_s: i64, // included (get_job returns dead_letter_retention_s: Option, // the stamps with the row) last_error: Option, // dead-only (None on live) died_at: Option, // dead-only } ``` - Single `Job` type, two carriers: **claims return `Box`** — ops + row: ```text trait JobHandle { job(&self) -> &Job // the row, as a value ack(self: Box) -> bool retry(self: Box, err: Option, delay: Option) -> bool // None delay = queue curve fail(self: Box, err: Option) -> bool heartbeat(self, extend: i64) -> bool // absolute reset } ``` *(Annotated 2026-10-07, third review round follow-through: the original sketch elided `retry`'s return type — pinned `-> bool`, the same validity predicate as its siblings. A refused retry — deadline lapsed, row reclaimed/cancelled/acked elsewhere — leaves the handle's op **not applied**: the handle was consumed by `retry(self: Box, …)` regardless (caller-side), but the row keeps its pre-call state (still `processing`, or gone if acked/cancelled) with its original stamps; no dead-letter, no schedule-side delay is recorded. `false` = the caller's window closed, at-least-once redelivery governs.)* — and **`get_job` returns `Option`** (pure data). The *same uniform validity predicate* ([ADR-010](010-queue-semantics-depth.md) §2 — `processing` + unexpired caller deadline) governs a `JobHandle`'s ops; a get_job row yields no handle, so no ghost ops exist on it. Ops take `self: Box` mirroring `TxHandle`'s commit shape (consumed handle, one-shot discipline); `heartbeat` is the deliberate counter-case — `self, extend: i64`, repeatable, because renewal is inherently repeated within one claim (the same one-shot/repeatable split the receiver shapes carry; do not "fix" the asymmetry at implementation). `err: Option`: `None` = the engine's documented-default string (taxonomy-consistent: these strings are row content, `get_job` data — not matched error variants). *(Annotated 2026-10-07, third review round follow-through: the default string is pinned — **`fail(None)` records `"failed"`**; `retry(None, …)` that exhausts the budget records `"max attempts exceeded"`, per [ADR-010](010-queue-semantics-depth.md) §4's path-based rule — exhaustion is engine-observed state, so the path's pinned string wins over the caller-None case; a successful `retry(None, …)` records nothing (the row returns to pending, `last_error` exists only on dead rows).)* *(Struct corrected 2026-10-07 by [ADR-021](021-tx-reads-and-value-shape-fixes.md) §2: `claimed_at` was missing from the original pinning — [ADR-010](010-queue-semantics-depth.md) §1's `get_job` list carried it and the fork schema adds the column — restored above.)* - `Schedule` (the `schedule()` read-back value): `Schedule { name: String, spec: String, queue: String, opts: ScheduleOpts }`. ### 4. `run_schedules(stop)` — the stop token is a core-crate type ```text store.run_schedules(stop: StopToken) -> Result<()> #[derive(Clone)] struct StopToken impl StopToken { fn cancel(&self) fn is_cancelled(&self) -> bool } ``` - **Pinned in core, not deferred**: the parameter type is contract surface ([ADR-017](017-contract-versioning.md) — a pinned method's signature), and a core-owned `StopToken` is exactly the config-split posture ([ADR-008](008-contract-v1-pinning.md) §6) in miniature — a contract-owned type whose *await mechanics* are engine-internal (tokio watch per engine; not contract). - Semantics ([ADR-009](009-scheduler-collapse.md) §1, unchanged): `cancel()` on any clone flips every clone; `run_schedules` ends its sleep early and returns `Ok(())` — the clean-stop return; leadership loss still returns `Err(LeadershipLost)` regardless of the token. ### 5. `run_once`'s delivery callable `Outbox::run_once(worker_id, delivery) -> bool` (true = claimed and processed) — the delivery argument is the consumer-supplied async callable taking `Box`, returning the boxed future of `Result<()>` (object safety: no generic methods on the boxed handle — the [ADR-008](008-contract-v1-pinning.md) §2 boxed-future posture applied to the one remaining surface it applies to). Outcome posture is pinned as before: `Ok` ⇒ ack, `Err(e)` ⇒ `retry(err, None)` (the queue curve). The exact callable *encoding* (`&mut dyn FnMut…`-form or a named one-method trait object) is Rust mechanics with one workable answer under object safety — the semantics above are the contract; the encoding follows §2's pinned pattern at implementation. ### 6. The two `save_offset` forms compose by monotonicity One op, two conveniences: `StreamHandle::save_offset(consumer, offset)` / `save_offset_tx` write the checkpoint; the receiver's `save_offset(&mut self)` ([ADR-008](008-contract-v1-pinning.md) §8) writes **the receiver's last-yielded event's offset through the same op**. Saves are monotone (a save whose offset is below the stored checkpoint is a silent no-op — inherited: honker's `ON CONFLICT … WHERE excluded.offset > existing` upsert), so the forms interleave freely — they cannot regress or fight one another. No-offset-regression is the pinned discipline: replay is a *read* concern (`read_since(offset)` works from any offset; saved checkpoints do not gate reads). Returns `Result<()>` — refusal (regression) is a no-op, not caller-actionable (the act-differently rule, [ADR-008](008-contract-v1-pinning.md) §5). ## Consequences **Positive** - Every handle a consumer constructs is now fully specified surface: decomposition off the current text can write the contract-crate task without inventing contract shapes. - The last "pin at implementation" deferral in the contract surface is closed, before the [ADR-017](017-contract-versioning.md) class-1 window that makes such pinning possible ends. - Placement honesty: the contract mirrors the starting artifact that every prior queue/lock/stream decision assumed, so no re-interpretation debt accrues at implementation. **Negative** - Four more boxed-handle traits (plus `JobHandle`) for engines to implement — the same per-trait cost `TxHandle` carries; measured negligible against commit costs ([ADR-008](008-contract-v1-pinning.md) §2), but real surface. - Trait-method additions on five traits now ride ADR-017 class 2 lockstep duties (engine adoption releases are mandatory on trait additions). Honest pricing, stated before consumers exist. - `Job`'s field list is pinned wide (all stamps, both namespaces of timestamps) — growth from here is class-2-minor via `#[non_exhaustive]`, but the initial text is larger than a minimal read type would be. ## References - [ADR-008](008-contract-v1-pinning.md) §1/§2/§4/§5/§8 — the partition this completes, the boxed-handle/object-safety posture, the validation rules, the act-differently rule, the `EventReceiver` shape and rename table this ADR's handle shapes extend. - [ADR-007](007-transactional-seam.md) — the auto-commit-vs-`_tx` counterpart structure the handle forms slot into. - [ADR-009](009-scheduler-collapse.md) §1 — the `run_schedules(stop)` method whose stop type §4 pins; the return semantics §4 restates unchanged. - [ADR-010](010-queue-semantics-depth.md) §1/§2/§3a — the job lifecycle, the uniform validity predicate §3's `JobHandle` ops ride, the stamps `Job`'s fields carry. - [ADR-017](017-contract-versioning.md) §2 class 1/§3 — the amend-in-place window this ADR uses (and which its urgency derives from), and the `#[non_exhaustive]` classifications §3's structs satisfy. - honker-rs surface (`/workspace/honker` `packages/honker-rs/src/lib.rs` @ f4e53c6) — the placement record: `Queue`/`Outbox`/`Stream`/`Lock` methods, `JobRow`/`Job` split, `SaveOffset` upsert, `Lock::release(self)`, claim `worker_id`. - [core-contract.md](../core-contract.md) — the spec this ADR's §1–§5 pin into place.