diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 963d7bc..e1eea5f 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-10-06 (ADR-018; OQ-11 resolved — question set closed) +last_updated: 2026-10-06 (ADR-019/020 — second review round resolved) --- # alkstore — Architecture @@ -53,6 +53,8 @@ pending architecture review and OQ resolution. | [016](decisions/016-deployment-honesty.md) | Deployment honesty — no runtime capability surface; compile-time engine identity + documented matrix | Accepted | | [017](decisions/017-contract-versioning.md) | Contract versioning — core crate's semver is the contract version; change classes, pairing carriers, lockstep duties | Accepted | | [018](decisions/018-provenance-register-and-cherry-picks.md) | Substrate provenance register and cherry-pick procedure — `PROVENANCE.md` in-tree, per-delta category-tagged entries, five-step adoption discipline | Accepted | +| [019](decisions/019-mechanism-handle-surfaces.md) | Mechanism-handle surfaces — handle traits (`Queue`/`StreamHandle`/`Outbox`/`Lock`/`JobHandle`), `Job`/`Schedule` shapes, `worker_id` identity, core `StopToken` | Accepted | +| [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue-option semantics — delay/run_at precedence, relative `expires`, scheduler stamp source, serde_json payload encoding | Accepted | ## Open Questions @@ -81,8 +83,10 @@ depth — [ADR-015](decisions/015-streams-depth.md)), ~~OQ-10~~ (fork follow-through — provenance register + cherry-pick procedure, [ADR-018](decisions/018-provenance-register-and-cherry-picks.md)). -No deferred OQs. With the question set closed, Phase 1 moves to -architecture review closure and the implementation-phase gates. +No deferred OQs. With the question set closed (and the 2026-10-06 +second review round resolving its findings directly as ADR-019/ADR-020 +rather than as new OQs), Phase 1 moves to architecture review closure +and the implementation-phase gates. ## Document Lifecycle diff --git a/docs/architecture/core-contract.md b/docs/architecture/core-contract.md index 3846a89..175093f 100644 --- a/docs/architecture/core-contract.md +++ b/docs/architecture/core-contract.md @@ -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` 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 store.listen(channel) -> Box store.schedule(name, spec, queue, payload, opts) -> Result 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` 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` + 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 ` 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). diff --git a/docs/architecture/decisions/009-scheduler-collapse.md b/docs/architecture/decisions/009-scheduler-collapse.md index 9153628..82ea529 100644 --- a/docs/architecture/decisions/009-scheduler-collapse.md +++ b/docs/architecture/decisions/009-scheduler-collapse.md @@ -81,9 +81,11 @@ store.run_schedules(stop) -> Result<()> // runs until `stop` leadership ends the runner — the consumer's recipe is to respawn it (documented); the lock loss always precedes any stolen fire, so respawn never double-fires a boundary. -- **Stop and return semantics**: `stop` is a cancellation token - (a cloneable handle whose flip ends the sleep early — the exact - type shapes at implementation, engine-crate docs). A leadership +- **Stop and return semantics**: `stop` is a cancellation token — a + core-crate cloneable `StopToken` (pinned by + [ADR-019](019-mechanism-handle-surfaces.md) §4, 2026-10-06; the + original "exact type shapes at implementation" deferral is + superseded). A leadership loss returns `Err(LeadershipLost)` — a distinct, matchable outcome from the clean `Ok(())` a stop produces; the respawn recipe matches on it. (One taxonomy note: `LeadershipLost` is a *return-shape* diff --git a/docs/architecture/decisions/014-outbox-tx-enqueue.md b/docs/architecture/decisions/014-outbox-tx-enqueue.md index 565b243..e196055 100644 --- a/docs/architecture/decisions/014-outbox-tx-enqueue.md +++ b/docs/architecture/decisions/014-outbox-tx-enqueue.md @@ -54,6 +54,9 @@ One method, added to the `TxHandle` trait: trait TxHandle { enqueue_tx(queue, opts, payload) -> job_id publish_tx(stream, payload) -> offset + publish_with_key_tx(stream, key, payload) -> offset // ADR-015, + // same day — not in this + // ADR's original sketch notify_tx(channel, payload) save_offset_tx(stream, consumer, offset) outbox_enqueue_tx(outbox, opts, payload) -> job_id // the addition diff --git a/docs/architecture/decisions/015-streams-depth.md b/docs/architecture/decisions/015-streams-depth.md index ed3b3f0..45d6f75 100644 --- a/docs/architecture/decisions/015-streams-depth.md +++ b/docs/architecture/decisions/015-streams-depth.md @@ -2,7 +2,8 @@ ## Status -Accepted +Accepted (2026-10-05, Phase 1 — +[OQ-12](../open-questions.md)'s resolution) ## Context diff --git a/docs/architecture/decisions/019-mechanism-handle-surfaces.md b/docs/architecture/decisions/019-mechanism-handle-surfaces.md new file mode 100644 index 0000000..95c91f8 --- /dev/null +++ b/docs/architecture/decisions/019-mechanism-handle-surfaces.md @@ -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 +store.stream(name) -> Box +store.outbox(name) -> Box +store.try_lock(name, owner, ttl) -> Option> +``` + +*(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> + claim_batch(worker_id, n) -> Vec> + ack_batch(ids) -> count // non-claimed ids not counted + cancel(job_id) -> bool // unconditional delete + get_job(job_id) -> Option // 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 + read_from_consumer(consumer, limit) -> Vec + save_offset(consumer, offset) + get_offset(consumer) -> i64 // absent consumer = 0 + trim_to(horizon) -> count // deletes offset <= horizon + subscribe(consumer) -> Box // 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) -> 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, + priority: i64, + run_at: i64, // the claim-order key + attempts: i64, // every claim counts + max_attempts: i64, // the row's stamp + worker_id: Option, // claimant, None pre-claim + claim_expires_at: Option, // deadline, None pre-claim + created_at: i64, // unix seconds + expires_at: Option, // 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, // the stamps with the row) + last_error: Option, // dead-only (None on live) + died_at: Option, // dead-only +} +``` + +- Single `Job` type, two carriers: **claims return + `Box`** — ops + row: + +```text +trait JobHandle { + job(&self) -> &Job // the row, as a value + ack(self: Box) -> bool + retry(self: Box, err: Option, delay: Option) + // None delay = queue curve + fail(self: Box, err: Option) -> bool + heartbeat(self, extend: i64) -> bool // absolute reset +} +``` + + — and **`get_job` returns `Option`** (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` mirroring `TxHandle`'s + commit shape (consumed handle, one-shot discipline). + `err: Option`: `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`, 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. \ No newline at end of file diff --git a/docs/architecture/decisions/020-enqueue-opt-semantics-and-bridges.md b/docs/architecture/decisions/020-enqueue-opt-semantics-and-bridges.md new file mode 100644 index 0000000..e1abc65 --- /dev/null +++ b/docs/architecture/decisions/020-enqueue-opt-semantics-and-bridges.md @@ -0,0 +1,176 @@ +# ADR-020: Enqueue option semantics — delay/run_at, expires, scheduler stamp source, payload encoding + +## Status + +Accepted (2026-10-06, Phase 1 — second architecture review round, +pre-decomposition; pre-implementation depth pinning per +[ADR-017](017-contract-versioning.md) class 1, same frame as +[ADR-014](014-outbox-tx-enqueue.md)/[ADR-015](015-streams-depth.md)) + +## Context + +The second architecture review flagged four semantic holes where an +implementer would otherwise improvise observable behavior: + +1. **`delay` vs `run_at` precedence** — [ADR-008](008-contract-v1-pinning.md) + §1 carries both fields in `EnqueueOpts` with only "`run_at` is the + absolute-time counterpart of `delay`" for guidance. Claim ordering + (`priority DESC`, ready-time `run_at` ASC — ADR-010 §1) silently + presupposes an answer: what does an enqueue with both set mean? +2. **`expires` polarity** — the v1 field set has one expiry field; + relative lifetime or absolute deadline? The `expires_at` column and + the sweep predicate depend on the answer. +3. **Scheduler-fired jobs' stamp source** — [ADR-009](009-scheduler-collapse.md) + §3 stamps boundary-fire enqueues "per [ADR-010](010-queue-semantics-depth.md) + §3a", but §3a resolves stamps "over the queue handle's `QueueOpts`" — + and the scheduler's tick enqueues internally, with no queue handle + open. The outbox closed this gap explicitly (derived `QueueOpts` + defaults, ADR-010 §3); the scheduler never did. Two engines could + pick different defaults before the contract suite catches it. +4. **The payload encoding bridge** — [ADR-008](008-contract-v1-pinning.md) + §2 pins payloads crossing the trait as `serde_json::Value`; + [ADR-015](015-streams-depth.md) §3 pins `StreamEvent.payload: + Vec` ("core value bytes"); `payload_as` (decode side, + error `Codec`) is pinned. The *encode* side — what relationship the + stored bytes bear to the `Value`, without which the round trip and + the cross-engine byte-equality rows are undefined — is stated + nowhere. + +All four are decidable now from decided material + the starting +artifact's source; none is a new design space. + +## Decision + +### 1. `delay` wins over `run_at`; both resolve to the row's ready time + +Pinned exactly as honker's enqueue already implements it +(`honker-core/src/honker_ops.rs` `enqueue`: "delay set → +`unixepoch() + delay` (wins over run_at)"): + +- `EnqueueOpts { delay, run_at }` resolution precedence: + **`delay` set → ready time = now + delay** (wins); + **else `run_at` set → ready time = run_at literally** (absolute + unix seconds); **else → now** (claimable immediately). +- Both fields stay (honker parity, ADR-008 §1's "carried whole"); the + resolution rule is the contract, and the row stores the resolved + ready time — `get_job` shows the resolved `run_at`, never the + enqueuer's raw fields. +- No validation error for both-set: precedence is defined, not + rejected (an error would be a third behavior for + [ADR-008](008-contract-v1-pinning.md) §5's rule to price, where + honker's defined precedence already exists and suffices). +- `now` is the engine's single second-precision clock (the queues + pin); the same instant is used for the delay resolution and the row + write (one enqueue, one clock read). + +### 2. `expires` is relative seconds from enqueue; `None` = never + +Pinned as honker implements it (`expires_s: Option` → row +`expires_at = unixepoch() + s`): + +- `EnqueueOpts.expires: Option` is a **relative TTL in + seconds from the enqueue instant**; the row's `expires_at` is + resolved at enqueue and stored absolutely. `None` = no expiry. +- This makes the stamping story uniform — every enqueue-time field + resolves against the enqueue instant (delay → ready time; expires → + expiry), and `get_job` shows the resolved absolute `expires_at`. +- The consumer who wants absolute-form expiry computes + `run_at`-style arithmetic on its side (`expires = Some(t - now)`); + no second field is added (the v1 field set stays closed — an + `expires_at` absolute twin would be an opts addition, class 3 + priced, with no row naming it and the arithmetic available). + +### 3. Scheduler-fired jobs' stamps: the target queue's derived defaults, engine-resolved + +The boundary-fire enqueue resolves stamps from **the named queue's +derived QueueOpts defaults** — the engine's built-in defaults (honker +parities: 300 s / 3 / 5 s / none), not a handle's opts and not +`ScheduleOpts`: + +- The tick enqueues internally; there is no queue handle in scope by + design (queues are names, not registered objects — + [ADR-009](009-scheduler-collapse.md) §5). The stamp source is + therefore the **engine's queue-defaults resolution** — exactly the + "no handle was opened" shape the outbox already resolved (derived + defaults, [ADR-014](014-outbox-tx-enqueue.md) §1; the same posture, + the plain-queue default set instead of the outbox's 60 s/5/5 s + set). +- `ScheduleOpts { priority, max_attempts, expires }` ride *as + themselves* — the two EnqueueOpts-minus-delay/run-at fields + ([ADR-009](009-scheduler-collapse.md) §3) apply over the defaults + (`max_attempts` overrides the default 3; `expires` resolves per §2); + the other three stamps (visibility/backoff/retention) take the + engine defaults. One resolution, engine-side, same on both engines + by the contract suite's equivalence pin. +- **`ScheduleOpts` field growth post-release is class 3** (opts + struct, [ADR-017](017-contract-versioning.md) §3's exemption) — + stated so the pricing is visible now. + +### 4. The payload encoding: serde_json serialization of the trait `Value`, byte-exactly + +- The trait's `payload` parameter is `serde_json::Value` + ([ADR-008](008-contract-v1-pinning.md) §2); the engine serializes it + with serde_json and stores **exactly those bytes** — the stored + bytes of a job row or stream event row are, contract-pinned, the + serde_json serialization of the `Value` the publish/enqueue carried. +- `Job`/`StreamEvent`'s `payload: Vec` fields and `payload_as` + decode those bytes; the error is `Codec` (pinned). Nothing re-encodes + on the way out. +- This makes the **cross-engine byte-equality rows well-defined**: a + `publish` with `Value` V on engine A and engine B stores byte-identical + rows (same serde_json canonical output for the same `Value`), which + is what the stream-equivalence backlog row's byte comparison means. +- Not pinned to a serde_json *feature flavor* (arbitrary-precision + big ints, map key order etc.) beyond serde_json's default shipping + behavior — the suite pins round-trip (publish → read → `payload_as` + → `Value` equality), not storage-format trivia; engines must use + serde_json (the family-standard JSON codec both POCs used), and a + codec change would itself be a contract change under + [ADR-017](017-contract-versioning.md) class 3 (it changes what + `payload_as` can decode). + +## Consequences + +**Positive** + +- The last four improvisation surfaces close with zero new machinery: + two are honker's existing behavior promoted to contract text, one + reuses the outbox's already-decided derived-defaults posture, one + makes the existing round-trip assumptions explicit. +- `get_job`'s resolved-fields honesty (§1/§2) makes job behavior + inspectable without ambiguity about which form was enqueued. +- The equivalence suite's byte-equality rows get a stated meaning. + +**Negative** + +- `delay`-wins-over-`run_at` is honker's precedence, carried as-is: + a consumer supplying both gets the delay form's meaning — stated + contract text, but a surprise if unread. +- Relative-only `expires` puts the absolute arithmetic on the + consumer needing it — acceptable at v1 field-set closure (no row + names the second form). +- serde_json's shipping behavior (map order, number handling) is + implicitly in the stored row — pinned to default serde_json, testable + by round-trip, and only visible via `payload_as` anyway. + +## References + +- [ADR-008](008-contract-v1-pinning.md) §1 (field sets), §2 (the + `Value` payload posture §4 encodes), §5 (§1's no-new-variant + reasoning). +- [ADR-009](009-scheduler-collapse.md) §3/§5 — the fire-enqueue shape + §3 stamps; the names-not-objects posture that makes §3's source the + engine defaults. +- [ADR-010](010-queue-semantics-depth.md) §1 (claim ordering's + `run_at` key), §3a (stamping — §3 completes its source rule), §8. +- [ADR-014](014-outbox-tx-enqueue.md) §1 — the derived-defaults + posture §3 reuses. +- [ADR-015](015-streams-depth.md) §3 — the `Vec` payload field §4 + bridges. +- [ADR-017](017-contract-versioning.md) §2 class 1/§2 class 3/§3 — + the amendment frame; the opts-field pricing §3 states. +- honker-core `enqueue` (`/workspace/honker/honker-core/src/honker_ops.rs` + @ f4e53c6, precedence/expiration doc comment + implementation) — the + §1/§2 behavior's source of record. +- [core-contract.md](../core-contract.md) — the spec this ADR's + §1–§4 pin into place. \ No newline at end of file diff --git a/docs/architecture/deployment.md b/docs/architecture/deployment.md index cf2f4a7..ec3d8e9 100644 --- a/docs/architecture/deployment.md +++ b/docs/architecture/deployment.md @@ -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) --- # Deployment diff --git a/docs/architecture/engine-postgres.md b/docs/architecture/engine-postgres.md index 0709b3e..bb9c92d 100644 --- a/docs/architecture/engine-postgres.md +++ b/docs/architecture/engine-postgres.md @@ -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) --- # Postgres engine @@ -112,6 +112,9 @@ the record in [ADR-003](decisions/003-sqlite-driver.md). | [014](decisions/014-outbox-tx-enqueue.md) | Outbox tx enqueue | `outbox_enqueue_tx` on `TxHandle`; derivation engine-side inside the caller's tx | | [015](decisions/015-streams-depth.md) | Streams depth | nullable key column (carried metadata); bigserial offsets, global-FIFO reads; keyed tx publish; `trim_to` as a pool-connection delete | | [016](decisions/016-deployment-honesty.md) | Deployment honesty | no runtime capability surface — this engine's multi-host posture is stated by its crate identity and docs; `PayloadTooLarge` occurrence pinned contract-suite (this engine produces it, client-side pre-round-trip) | +| [017](decisions/017-contract-versioning.md) | Contract versioning | engine pins core `alkstore = "1.y"` in its manifest; changes touching contract semantics classified under its four-class taxonomy | +| [019](decisions/019-mechanism-handle-surfaces.md) | Handle surfaces | boxed handle traits (`Queue`/`StreamHandle`/`Outbox`/`Lock`/`JobHandle`) implemented over the pooled/claimed rows; `worker_id` stamps the claimant column | +| [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue options | delay-over-`run_at` resolution in the engine's enqueue SQL; scheduler fires stamp from derived defaults; serde_json byte encoding (the equivalence suite's byte rows) | ## Open Questions @@ -137,6 +140,8 @@ Resolved: **OQ-09** (scheduler collapse — [ADR-009](decisions/009-scheduler-collapse.md)), **OQ-05** (queue semantics depth — [ADR-010](decisions/010-queue-semantics-depth.md)), **OQ-12** (streams depth — [ADR-015](decisions/015-streams-depth.md)), -and **OQ-08** (capability surface — none; -[ADR-016](decisions/016-deployment-honesty.md)), +**OQ-08** (capability surface — none; +[ADR-016](decisions/016-deployment-honesty.md)), and **OQ-10** +(contract versioning — +[ADR-017](decisions/017-contract-versioning.md)), 2026-10-05/06. \ No newline at end of file diff --git a/docs/architecture/engine-sqlite.md b/docs/architecture/engine-sqlite.md index ef5c3ed..9cb1971 100644 --- a/docs/architecture/engine-sqlite.md +++ b/docs/architecture/engine-sqlite.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-10-06 (OQ-11 resolved — ADR-018) +last_updated: 2026-10-06 (ADR-019/020 — handle surfaces, enqueue semantics) --- # SQLite engine @@ -133,6 +133,8 @@ family is `__alkstore_*` (ADR-010 §8's naming authorization). | [016](decisions/016-deployment-honesty.md) | Deployment honesty | no runtime capability surface; single-host posture stated by crate identity + docs; this engine never produces `PayloadTooLarge` (pinned in the contract suite) | | [017](decisions/017-contract-versioning.md) | Contract versioning | engine pins core `alkstore = "1.y"` in its manifest; substrate cherry-picks are class-4 non-events; adoption duties per its §5 | | [018](decisions/018-provenance-register-and-cherry-picks.md) | Substrate provenance | `src/substrate/PROVENANCE.md` (identity, delta register — cherry-picks as tagged entries); cherry-picks recorded at adoption, non-versioning | +| [019](decisions/019-mechanism-handle-surfaces.md) | Handle surfaces | boxed handle traits (`Queue`/`StreamHandle`/`Outbox`/`Lock`/`JobHandle`); `worker_id` stamps the row's claimant column; `StopToken` core-owned | +| [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue options | delay-over-`run_at` in the substrate's re-derived enqueue; scheduler fires stamp from derived defaults; serde_json byte encoding | ## Open Questions diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index 1eb1926..5ec3c04 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -42,6 +42,18 @@ entries; five-step cherry-pick procedure recorded at adoption). **The Phase 1 question set is closed** — no open OQs remain; Phase 1 moves to architecture review and the implementation-phase gates. +**Second review round (2026-10-06, pre-decomposition):** the review +found no *new* open questions — every finding was decidable from +decided material, so it resolved directly as +[ADR-019](decisions/019-mechanism-handle-surfaces.md) (handle-trait +surfaces, `Job`/`Schedule` shapes, claimant identity, `StopToken` — +closing the "pin at implementation" residue before ADR-017's class-1 +window ends at first release) and +[ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md) +(`delay`/`run_at` precedence, relative `expires`, scheduler-fired +jobs' stamp source, the serde_json payload encoding bridge), plus +mechanical fixes (ADR index staleness in overview/engine-postgres, +stale wording, framing drift). Resolved questions stay listed with their resolution; they are not deleted. @@ -137,7 +149,7 @@ narrowed to the pinning work its own record already scoped.)* surface; the contract is the trait it returns. (7) Locks guarantee row added to ADR-006's table (TTL-bounded mutual exclusion, silent expiry); the scheduler row is explicitly transferred to OQ-09's - resolution. A verification backlog (core-contract.md §Verification + resolution. A verification backlog (core-contract.md §Verification backlog) tracks the one-engine-pinned properties. - **Cross-references**: OQ-01, OQ-10 (resolved — [ADR-017](decisions/017-contract-versioning.md), the discipline diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index a4d5acc..bb36c0e 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -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) --- # alkstore — Overview @@ -80,6 +80,10 @@ Per [ADR-002](decisions/002-feature-scope.md): | [014](decisions/014-outbox-tx-enqueue.md) | Transactional outbox enqueue (`outbox_enqueue_tx` on `TxHandle`) | Accepted | | [015](decisions/015-streams-depth.md) | Streams depth (carried-metadata keys, global-FIFO ordering, `StreamEvent`, `trim_to`) | Accepted | | [016](decisions/016-deployment-honesty.md) | Deployment honesty (no runtime capability surface; compile-time identity + matrix) | Accepted | +| [017](decisions/017-contract-versioning.md) | Contract versioning (core crate's semver is the contract version; change classes, pairing carriers, lockstep duties) | Accepted | +| [018](decisions/018-provenance-register-and-cherry-picks.md) | Substrate provenance register and cherry-pick procedure (`PROVENANCE.md` in-tree, per-delta entries) | Accepted | +| [019](decisions/019-mechanism-handle-surfaces.md) | Mechanism-handle surfaces (handle traits, `Job`/`Schedule` shapes, `worker_id`, `StopToken`) | Accepted | +| [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue-option semantics (delay/run_at, expires, scheduler stamp source, payload encoding) | Accepted | ## Non-goals diff --git a/docs/architecture/queues.md b/docs/architecture/queues.md index 6d8b338..2a30f11 100644 --- a/docs/architecture/queues.md +++ b/docs/architecture/queues.md @@ -1,6 +1,6 @@ --- status: draft -last_updated: 2026-10-05 +last_updated: 2026-10-06 (ADR-019/020 — handle surfaces, enqueue semantics) --- # Queues, scheduler, outbox — semantics depth @@ -39,7 +39,11 @@ made under; the ADRs carry the WHY. - **Job options** (the contract v1 skeleton, [ADR-008](decisions/008-contract-v1-pinning.md) §1, from the honker-rs surface): `delay, priority, max_attempts, expires, run_at` - at enqueue; `ack / retry / fail / heartbeat` on the job handle. + at enqueue (`delay`-wins-over-`run_at` resolution and + relative-`expires` pinned by + [ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md) §1/§2); + `ack / retry / fail / heartbeat` on the boxed `JobHandle` a claim + returns ([ADR-019](decisions/019-mechanism-handle-surfaces.md)). - **Cut-flag context**: result storage is [ADR-002](decisions/002-feature-scope.md)'s cut-flag row; [ADR-010](decisions/010-queue-semantics-depth.md) §7 reconsidered it @@ -139,7 +143,8 @@ made under; the ADRs carry the WHY. opt) makes `sweep_expired` enforce a per-queue dead TTL. Honker's no-retention posture kept as default; the mechanism is ours. - **No redrive API in v1**: the recipe is `get_job` + fresh `enqueue`. - No consumer names an API shape; re-entry needs an OQ. + No consumer names an API shape; re-entry gates on a + consumer-inventory row first (the ADR-002 row-first discipline). ## Sweep / maintenance (ADR-010 §5–§6) @@ -187,6 +192,8 @@ Collapsed into queues: no `Scheduler` handle, no schedule objects. (the no-ambient-timers posture). The runner: leadership lock → {renew (on loss, return before ticking — `Err(LeadershipLost)`, distinct from clean `Ok(())` stop) → fire due boundaries → sleep}. + The `stop` parameter is a core-crate cloneable `StopToken` + ([ADR-019](decisions/019-mechanism-handle-surfaces.md) §4). Leadership lock: reserved name `__alkstore_scheduler` (engine-derived name under the reserved prefix, [ADR-008](decisions/008-contract-v1-pinning.md) §4). @@ -201,7 +208,12 @@ Collapsed into queues: no `Scheduler` handle, no schedule objects. - Each boundary fire enqueues ordinary work: `payload` into the named queue with `ScheduleOpts { priority, max_attempts, expires }` — stamped onto the enqueued job per - [ADR-010](decisions/010-queue-semantics-depth.md) §3a; the queue + [ADR-010](decisions/010-queue-semantics-depth.md) §3a; the three + `ScheduleOpts` fields apply over the target queue's **derived engine + defaults** for the stamps the fire doesn't carry (no queue handle is + open — the same derived-defaults posture as the outbox's backing + queue, [ADR-020](decisions/020-enqueue-opt-semantics-and-bridges.md) + §3); the queue mechanism's guarantees apply, no separate delivery machinery. - Schedule storage is engine-internal (SQLite: the forked substrate's `__alkstore_scheduler_tasks` table; Postgres: the engine-owned @@ -270,6 +282,8 @@ Collapsed into queues: no `Scheduler` handle, no schedule objects. | [011](decisions/011-sqlite-substrate-fork.md) | Substrate fork | SQLite-side queue ops re-derived in owned code; `__alkstore_*` naming | | [012](decisions/012-forked-substrate-design.md) | Fork design | contract-blind substrate boundary; fidelity posture; port deltas | | [014](decisions/014-outbox-tx-enqueue.md) | Outbox tx enqueue | `outbox_enqueue_tx` on `TxHandle`; derived backing queue reached only through the outbox surface | +| [019](decisions/019-mechanism-handle-surfaces.md) | Handle surfaces | queue ops on the `Queue` handle, `JobHandle` from claims, `worker_id` identity, `Job`/`Schedule` shapes, core `StopToken` | +| [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue options | delay-over-run_at; relative `expires`; scheduler fires stamp from derived queue defaults | ## Open Questions