16 KiB
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 §1/§2/ §8 in place, pre-implementation (ADR-017 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 (pre-review): "Mechanism
handles (
Stream,Queue,Outbox,Lock) are core-owned trait objects … their trait methods pin at implementation, mirroring theTxHandlepattern." - ADR-009 §1 deferred the
run_schedulesstop parameter's type the same way ("the exact type shapes at implementation, engine-crate docs"). JobandSchedule— both#[non_exhaustive]consumer-read types under ADR-017 §3 — had prose-only field lists, split across ADR-010 §1/§3a and ADR-009 §1.- The two
save_offsetforms (the pinnedsave_offset(stream, consumer, offset)store/tx form and ADR-008 §8's receiver-sidesave_offset(&mut self)) had no stated composition.
The second architecture review flagged that "pin at implementation" no longer holds: ADR-017 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 §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 §2 posture):
store.queue(name, opts) -> Box<dyn Queue>
store.stream(name) -> Box<dyn StreamHandle>
store.outbox(name) -> Box<dyn Outbox>
store.try_lock(name, owner, ttl) -> Option<Box<dyn Lock>>
(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.)
trait Queue {
name(&self) -> &str
enqueue(payload, EnqueueOpts) -> job_id
claim_one(worker_id) -> Option<Box<dyn JobHandle>>
claim_batch(worker_id, n) -> Vec<Box<dyn JobHandle>>
ack_batch(ids) -> count // non-claimed ids not counted
cancel(job_id) -> bool // unconditional delete
get_job(job_id) -> Option<Job> // 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<StreamEvent>
read_from_consumer(consumer, limit) -> Vec<StreamEvent>
save_offset(consumer, offset)
get_offset(consumer) -> i64 // absent consumer = 0
trim_to(horizon) -> count // deletes offset <= horizon
subscribe(consumer) -> Box<EventReceiver> // 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<Self>) -> 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)onStorewould take the name argument that the handle already carries; aQueuehandle withoutclaim_*would be a construction ceremony with no behavior. Honker'sJob-ops-on-claimed-work shape andLock::release(self)RAII shape are likewise inherited. get_job,save_offset,get_offset,read_since,read_from_consumerremain available from inside a transaction through theTxHandle's*_txmethods (get_job_tx,get_offset_tx,read_since_tx,read_from_consumer_tx— ADR-021 §1) — the handle forms are the auto-commit convenience counterparts (ADR-007); claim/maintenance ops are deliberately not tx-shaped (noclaim_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 §4 — nothing new; the handles carry validated names. - Trait method-set additions on these handle traits post-release
are ADR-017 class 2 — the same
lockstep cost as
TxHandleadditions; 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_oncetake aworker_id: &str— the claim's ownership token, stamping the row'sworker_idcolumn. It is a consumer-local identifier in the stream-consumer-name class (ADR-008 §4: no reserved- prefix rule, no shared namespace): callers self-supply (a host id, a task label); the contract requires only non-emptiness (InvalidNameotherwise — 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), 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 §3
(field additions ride class 2):
struct Job {
id: i64,
queue: String,
state: JobState, // enum: Pending | Processing
// | Dead
payload: Vec<u8>,
priority: i64,
run_at: i64, // the claim-order key
attempts: i64, // every claim counts
max_attempts: i64, // the row's stamp
worker_id: Option<String>, // claimant, None pre-claim
claimed_at: Option<i64>, // unix seconds at the most
// recent claim; None pre-claim
// (added by ADR-021 §2)
claim_expires_at: Option<i64>, // deadline, None pre-claim
created_at: i64, // unix seconds
expires_at: Option<i64>, // 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<i64>, // the stamps with the row)
last_error: Option<String>, // dead-only (None on live)
died_at: Option<i64>, // dead-only
}
- Single
Jobtype, two carriers: claims returnBox<dyn JobHandle>— ops + row:
trait JobHandle {
job(&self) -> &Job // the row, as a value
ack(self: Box<Self>) -> bool
retry(self: Box<Self>, err: Option<String>, delay: Option<i64>)
-> bool // None delay = queue curve
fail(self: Box<Self>, err: Option<String>) -> 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<Self>, …) 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<Job> (pure data). The same
uniform validity predicate (ADR-010
§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<Self> 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<String>: 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 §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 §2: claimed_at
was missing from the original pinning —
ADR-010 §1's get_job list carried
it and the fork schema adds the column — restored above.)
Schedule(theschedule()read-back value):Schedule { name: String, spec: String, queue: String, opts: ScheduleOpts }.
4. run_schedules(stop) — the stop token is a core-crate type
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 — a pinned method's
signature), and a core-owned
StopTokenis exactly the config-split posture (ADR-008 §6) in miniature — a contract-owned type whose await mechanics are engine-internal (tokio watch per engine; not contract). - Semantics (ADR-009 §1, unchanged):
cancel()on any clone flips every clone;run_schedulesends its sleep early and returnsOk(())— the clean-stop return; leadership loss still returnsErr(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<dyn JobHandle>, returning the boxed future of
Result<()> (object safety: no generic methods on the boxed
handle — the ADR-008 §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 §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 §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 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 costTxHandlecarries; measured negligible against commit costs (ADR-008 §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 §1/§2/§4/§5/§8 — the
partition this completes, the boxed-handle/object-safety posture,
the validation rules, the act-differently rule, the
EventReceivershape and rename table this ADR's handle shapes extend. - ADR-007 — the auto-commit-vs-
_txcounterpart structure the handle forms slot into. - ADR-009 §1 — the
run_schedules(stop)method whose stop type §4 pins; the return semantics §4 restates unchanged. - ADR-010 §1/§2/§3a — the job
lifecycle, the uniform validity predicate §3's
JobHandleops ride, the stampsJob's fields carry. - ADR-017 §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/honkerpackages/honker-rs/src/lib.rs@ f4e53c6) — the placement record:Queue/Outbox/Stream/Lockmethods,JobRow/Jobsplit,SaveOffsetupsert,Lock::release(self), claimworker_id. - core-contract.md — the spec this ADR's §1–§5 pin into place.