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

+111 -36
View File
@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-10-06
last_updated: 2026-10-06 (ADR-019/020 — handle surfaces, enqueue semantics, payload bridge)
---
# Core contract
@@ -87,13 +87,17 @@ present key must be non-empty → `InvalidName`) and is
point ([ADR-015](decisions/015-streams-depth.md) §2).
Mechanism handles come off the store (or, for transactional variants,
off the handle):
off the handle). **Payload encoding** ([ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md)
§4): the trait crosses `serde_json::Value`; engines serialize it with
serde_json and store exactly those bytes — `payload_as<T>` decodes
them, `Codec` on failure; round-trip (publish → read → decode →
`Value` equality) is what the contract suite pins, on both engines.
```text
store.notify(channel, payload) handle.notify_tx(channel, payload)
store.stream(name) -> Stream handle.publish_tx / publish_with_key_tx
store.stream(name) -> StreamHandle handle.publish_tx / publish_with_key_tx
handle.save_offset_tx
(the Stream handle also carries
(the StreamHandle also carries
trim_to — stream-side, outside the
tx seam)
store.queue(name, opts) -> Queue handle.enqueue_tx
@@ -102,20 +106,34 @@ store.try_lock(name, owner, ttl) -> Option<Lock>
store.listen(channel) -> Box<dyn WakeReceiver>
store.schedule(name, spec, queue, payload, opts) -> Result<Schedule>
store.unschedule(name) -> bool
store.run_schedules(stop) -> Result<()>
store.run_schedules(stop: StopToken) -> Result<()>
```
The mechanism handles' full trait surfaces (`Queue { enqueue,
claim_one, claim_batch, ack_batch, cancel, get_job, sweep_expired }`,
`StreamHandle { publish, publish_with_key, read_since,
read_from_consumer, save_offset, get_offset, trim_to, subscribe }`,
`Outbox { enqueue, run_once }`, `Lock { renew, release }`, plus the
boxed `JobHandle` a claim returns) are pinned by
[ADR-019](decisions/019-mechanism-handle-surfaces.md); the
`StopToken` parameter is a core-crate type (same ADR, §4).
Payloads cross the trait as core value types (non-generic trait
methods — object safety of the boxed handles,
[ADR-008](decisions/008-contract-v1-pinning.md) §2); `payload_as<T>`
decodes on concrete returned values (`Job`, `StreamEvent`). The
`with_tx` closure wrapper ships over the handle shape (the POC-verified
composition direction, [ADR-007](decisions/007-transactional-seam.md)).
Mechanism handles (`Stream`, `Queue`, `Outbox`, `Lock`) are core-owned
trait objects like the tx handle — engine types never appear in
consumer signatures; their trait methods pin at implementation,
mirroring the `TxHandle` pattern ([ADR-008](decisions/008-contract-v1-pinning.md)
§2's rationale applies identically).
Mechanism handles (`Queue`, `StreamHandle`, `Outbox`, `Lock`) are
core-owned trait objects like the tx handle — engine types never appear
in consumer signatures. Their trait surfaces are pinned by
[ADR-019](decisions/019-mechanism-handle-surfaces.md) (placement
mirroring the honker-rs starting artifact: claim/maintenance ops on the
`Queue` handle, publish/read/trim on the `StreamHandle`, the claim ops
on the boxed `JobHandle`, `run_once` on the `Outbox`); trait-method
additions on these traits post-release ride
[ADR-017](decisions/017-contract-versioning.md) class 2 like any
other trait surface.
## Mechanism contracts
@@ -200,11 +218,18 @@ business-tx shape). Depth pinned by
cadence — honker's per-binding ambiguity is not inherited). The
subscription handle exposes no `save_every` / auto-save-on-drop
([ADR-008](decisions/008-contract-v1-pinning.md) §8). Saves are
monotone; a saved offset below the trim horizon
monotone — a save below the stored checkpoint is a silent no-op —
so the direct form and the receiver's no-arg form
(`EventReceiver::save_offset(&mut self)`, which saves the
last-yielded event's offset through the same op,
[ADR-019](decisions/019-mechanism-handle-surfaces.md) §6)
interleave freely and cannot regress one another. A saved offset
below the trim horizon
([ADR-015](decisions/015-streams-depth.md) §5) stays a valid
position marker.
- `trim_to(horizon)` — delete events with `offset <= horizon`
([ADR-015](decisions/015-streams-depth.md) §5). The stream-side
- `trim_to(horizon) -> count` — delete events with `offset <= horizon`
([ADR-015](decisions/015-streams-depth.md) §5; the return is the
deleted-row count, [ADR-019](decisions/019-mechanism-handle-surfaces.md)). The stream-side
bounded-growth op, consumer-invoked, no engine-default
retention and no ambient sweeper (the ADR-010 §6 posture; the
replay-forever default is the mechanism's purpose — growth is
@@ -232,14 +257,30 @@ Durable at-least-once work
obligations:
- `enqueue` / `enqueue_tx` — commit-atomic; `EnqueueOpts { delay,
run_at, priority, max_attempts, expires }`; queue-level
run_at, priority, max_attempts, expires }`; **option semantics
([ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md))**:
ready time resolves `delay` → `now + delay` (wins) over `run_at` →
literal absolute over neither → now; `expires` is relative seconds
from enqueue resolved to an absolute row `expires_at` (`None` =
never); the resolved values — not the raw fields — are what
`get_job` shows. Queue-level
`QueueOpts { visibility_timeout_s, max_attempts, backoff_base_s,
dead_letter_retention_s }` ([ADR-010](decisions/010-queue-semantics-depth.md)
§3/§4).
§3/§3a) stamp onto each job row at enqueue, resolved over the
handle's opts (auto-commit/tx paths) or the engine's derived
defaults (outbox backing queues, scheduler boundary fires — the
no-handle-open shapes, ADR-014/ADR-020 §3).
- `claim_one` / `claim_batch` — exactly-once handout under concurrency
(POC-pinned on both engines); claim ordering: priority DESC, then
ready-time, then enqueue order (FIFO under equal priority).
- Job handle: `ack / retry / fail / heartbeat`. `ack` deletes the row;
(POC-pinned on both engines); take a caller-supplied `worker_id`
(claimant identity — consumer-local, no reserved rule, non-empty;
[ADR-019](decisions/019-mechanism-handle-surfaces.md) §2); claim
ordering: priority DESC, then
ready-time, then enqueue order (FIFO under equal priority). Claims
return boxed `JobHandle`s — ops + row value (same ADR, §3).
- Job handle: `ack / retry / fail / heartbeat` on the boxed
`JobHandle` a claim returns ([ADR-019](decisions/019-mechanism-handle-surfaces.md)
§3 — `job(&self) -> &Job` beside the ops; one-shot `self: Box<Self>`
ops like the tx commit). `ack` deletes the row;
`heartbeat(extend)` is *renewal* — an absolute reset of the claim
deadline (extend is the new full deadline from now, **not additive**
to elapsed time); late heartbeat refused; **a reclaim consumes an
@@ -296,8 +337,11 @@ A helper over queues, not a separate mechanism: enqueue inside the
business transaction + the delivery/consumption worker entry points.
Same evidence base and guarantee as queues
([ADR-002](decisions/002-feature-scope.md)). Worker semantics
(honker's `run_once` shape, inherited as the pinned posture):
`run_once(worker_id, delivery)` is a *pull* op — claim one job, run
(honker's `run_once` shape, inherited as the pinned posture; the
delivery callable's shape pinned by
[ADR-019](decisions/019-mechanism-handle-surfaces.md) §5):
`run_once(worker_id, delivery) -> bool` is a *pull* op — claim one
job, run
the delivery closure, `ack` on `Ok`, `retry(err, None)` (the queue's
curve) on `Err`; the consumer calls it in its own loop. **No
heartbeat inside delivery** (honker parity, inherited deliberately):
@@ -312,8 +356,9 @@ payload)` on the `TxHandle` trait
name (validated like `store.outbox(name)` — empty → `InvalidName`,
reserved-prefixed → `ReservedName`), derives the backing queue name
engine-side, and lands the job row in the caller's transaction with
`EnqueueOpts` stamped per [ADR-010](decisions/010-queue-semantics-
depth.md) §3a over the backing queue's derived `QueueOpts`. Commit
`EnqueueOpts` stamped per
[ADR-010](decisions/010-queue-semantics-depth.md) §3a over the backing
queue's derived `QueueOpts`. Commit
makes the job visible to `run_once` exactly when the business write
commits; rollback drops both (the no-ghosts property,
[ADR-007](decisions/007-transactional-seam.md)). The derived backing
@@ -326,15 +371,21 @@ itself the guarantee that the derivation cannot be collided with.
Collapsed into queues per [ADR-009](decisions/009-scheduler-collapse.md):
`schedule(name, spec, queue, payload, opts)` (upsert by name),
`unschedule(name)`, and the opt-in `run_schedules(stop)` runner
(leader-elected where the engine has peers — the leadership lock is
the reserved name `__alkstore_scheduler`). Schedules never fire
`unschedule(name)`, and the opt-in `run_schedules(stop: StopToken)`
runner (`StopToken` is a core-crate cloneable cancel handle —
[ADR-019](decisions/019-mechanism-handle-surfaces.md) §4; the
leader-elected leadership lock is the reserved name
`__alkstore_scheduler`). Schedules never fire
without a runner (no ambient timers). v1 spec grammar:
`@every <n><unit>` only (`s|m|h|d`); cron strings are rejected
(`InvalidSpec`) pending a consumer-inventory row naming wall-clock
cron. Each boundary fire enqueues ordinary work stamped with
`ScheduleOpts { priority, max_attempts, expires }`
([ADR-009](decisions/009-scheduler-collapse.md) §3). Boundary
([ADR-009](decisions/009-scheduler-collapse.md) §3) — resolved over
the target queue's *derived engine defaults* (visibility/backoff/
retention; no handle is open), per
[ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md) §3.
Boundary
guarantee: [ADR-009](decisions/009-scheduler-collapse.md) §4
(at-least-once per elapsed boundary, bounded catch-up with
skip-forward past the cap).
@@ -372,11 +423,12 @@ inherits them and adds the consumer obligations:
`Error`, with v1 variants `PayloadTooLarge` (universal; produced
pg-side, contract-wide matchable), `ReservedName`, `InvalidName`,
`Closed`, `Codec`, and the opaque `Database` fallback (engine detail
preserved via the source chain). Post-v1 additions:
`InvalidSpec` (schedule spec grammar) and `LeadershipLost`
(`run_schedules` return on leadership loss) — both from
[ADR-009](decisions/009-scheduler-collapse.md) §6, the only
taxonomy deltas so far. Pinning rule: a variant exists only when
preserved via the source chain). `InvalidSpec` (schedule spec grammar)
and `LeadershipLost` (`run_schedules` return on leadership loss) —
both from [ADR-009](decisions/009-scheduler-collapse.md) §6 — are
part of contract v1's initial text (pre-release additions per
[ADR-017](decisions/017-contract-versioning.md) class 1; the
"post-v1 additions" framing an earlier draft used is retired). Pinning rule: a variant exists only when
callers can act differently on it. Queue claim "no work" and job/lock
boolean results are values, not errors; dead-letter is observable
state (`get_job`), not an error.
@@ -396,11 +448,13 @@ a consumer-inventory row naming a runtime-adapt need.
### Naming / reserved namespace
Consumer-visible names (channels, streams, queues, locks, and
schedule names) share engine-visible namespaces on Postgres (LISTEN
channel names are server-global per database). Pinned by
[ADR-008](decisions/008-contract-v1-pinning.md) §4 (schedule names
added by [ADR-009](decisions/009-scheduler-collapse.md) §1):
Consumer-visible names (channels, streams, queues, outboxes, locks,
and schedule names) share engine-visible namespaces on Postgres (LISTEN
channel names are server-global per database). The canonical kinds
list is exactly these six (notify/stream/queue/outbox/lock/schedule;
pinned by [ADR-008](decisions/008-contract-v1-pinning.md) §4, outbox
added by [ADR-014](decisions/014-outbox-tx-enqueue.md), schedule names
by [ADR-009](decisions/009-scheduler-collapse.md) §1):
- Reserved prefix **`__alkstore_`**, engine-independent, applies
across all name kinds; the one v1-reserved string is
@@ -503,6 +557,19 @@ before the engine specs are called `stable`:
first remaining row, saved offsets below the horizon stay valid,
trim wakes nothing, and a concurrent subscriber never loses its
place.
- **Save-offset monotone composition** ([ADR-019](decisions/019-mechanism-handle-surfaces.md)
§6) — direct-form and receiver-form saves interleave without
regression on both engines; a lower-offset save is a silent no-op;
receiver saves land the last-yielded event's offset exactly.
- **Payload encode/decode round-trip on both engines**
([ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md) §4) —
same `Value` published/enqueued on both engines stores byte-identical
rows and decodes back to `Value` equality (`Codec` only on
non-serializable target types).
- **Enqueue-opts resolution equivalence** ([ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md)
§1/§2/§3) — delay-over-run_at precedence, relative-expires
resolution, and the scheduler-fired jobs' derived-default stamps
(`get_job`-visible via the row) are identical on both engines.
## Design Decisions
@@ -521,6 +588,8 @@ before the engine specs are called `stable`:
| [015](decisions/015-streams-depth.md) | Streams depth (amends 006/008) | key = carried metadata, global-FIFO ordering row, `StreamEvent` shape, `publish_with_key_tx`, `trim_to` |
| [016](decisions/016-deployment-honesty.md) | Deployment honesty (decides 008's parked question) | no runtime capability surface — compile-time engine identity + documented matrix; `PayloadTooLarge` occurrence asymmetry pinned |
| [017](decisions/017-contract-versioning.md) | Contract versioning (governs this surface's changes) | core crate's semver *is* the contract version; four change classes; amend-in-place ends at first release; pairing = manifest pin + version-stamped contract suite + docs |
| [019](decisions/019-mechanism-handle-surfaces.md) | Mechanism handles (amends 008 §1/§2/§8) | handle traits pinned (`Queue`/`StreamHandle`/`Outbox`/`Lock`/`JobHandle`); `worker_id` claimant identity; `Job`/`Schedule` struct shapes; core-owned `StopToken`; save-offset composition |
| [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue options + bridges (amends 008 §1/§2, 009 §3) | `delay` wins over `run_at`; `expires` = relative seconds; scheduler stamps from derived queue defaults; serde_json byte-level payload encoding |
## Open Questions
@@ -544,3 +613,9 @@ depth — [ADR-015](decisions/015-streams-depth.md)), **OQ-08**
[ADR-016](decisions/016-deployment-honesty.md)), and **OQ-10**
(the versioning discipline this document's surface is governed by —
[ADR-017](decisions/017-contract-versioning.md)), 2026-10-05/06.
The second review round (2026-10-06) found no new open questions —
its findings resolved directly as
[ADR-019](decisions/019-mechanism-handle-surfaces.md) (handle
surfaces, value shapes, stop token) and
[ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md)
(enqueue-option semantics, scheduler stamp source, payload encoding).