Files
alkstore/docs/architecture/decisions/019-mechanism-handle-surfaces.md
T

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 the TxHandle pattern."
  • ADR-009 §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 §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 §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 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) 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 §1) — the handle forms are the auto-commit convenience counterparts (ADR-007); 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 §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 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 §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), 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 Job type, two carriers: claims return Box<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 (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

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 StopToken is 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_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<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 cost TxHandle carries; 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 EventReceiver shape and rename table this ADR's handle shapes extend.
  • ADR-007 — the auto-commit-vs-_tx counterpart 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 JobHandle ops ride, the stamps Job'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/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 — the spec this ADR's §1–§5 pin into place.