# 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. - *(Annotated 2026-10-07, third review round follow-through: §3a's "resolved over the queue handle's `QueueOpts`" reading applies only to the auto-commit `Queue::enqueue` — the one call shape with a queue handle in scope. Plain `enqueue_tx(queue_name, opts, payload)` is a no-handle-open shape too (a `TxHandle` never carries queue opts), so it stamps the engine's plain-queue defaults (300 s / 3 / 5 s / none) exactly as boundary fires do; the outbox's `outbox_enqueue_tx` keeps its 60 s/5/5 derived set ([ADR-014](014-outbox-tx-enqueue.md) §1). Without this note the SQLite and pg implementers could legitimately diverge on the tx path's stamp source.)* ### 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.