ADR-019/020: second review round — handle surfaces, enqueue semantics, payload bridge + mechanical fixes

ADR-019: mechanism-handle traits pinned (Queue/StreamHandle/Outbox/
Lock/JobHandle), Job/Schedule struct shapes, worker_id claimant
identity, core StopToken — closes the 'pin at implementation' residue
before ADR-017's amend-in-place window terminates at first release.

ADR-020: delay-wins-over-run_at precedence (honker parity), relative
expires, scheduler-fired stamps from derived queue defaults, serde_json
byte-level payload encoding.

Mechanical: overview/engine-postgres ADR tables completed through 020,
core-contract Errors section re-framed to class-1, ADR-014 sketch
annotated with publish_with_key_tx, ADR-015 status dated, reserved-
namespace kinds list normalized to six kinds, redrive gate wording
aligned, broken link fixed, StopToken deferral superseded in ADR-009.
This commit is contained in:
glm-5.3-flash committed 2026-10-06 13:06:23 +00:00
1 parent c49befd195
commit 8323a7853e
13 files changed
+661 -54

No files matched your search

@@ -0,0 +1,309 @@
# 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_*` remain available
from *inside* a transaction through the `TxHandle`'s `*_tx` methods
— the handle forms are the auto-commit convenience counterparts
([ADR-007](007-transactional-seam.md)).
- 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
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>)
// None delay = queue curve
fail(self: Box<Self>, err: Option<String>) -> bool
heartbeat(self, extend: i64) -> bool // absolute reset
}
```
— 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).
`err: Option<String>`: `None` = the engine's
documented-default string (taxonomy-consistent: these strings are
row content, `get_job` data — not matched error variants).
- `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.