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

347 lines
16 KiB
Markdown

# 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<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.)*
```text
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](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<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:
```text
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](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<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](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<dyn JobHandle>`, 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.