The pre-release pass's last contract-text dispositions, each dated in-place citing the 2026-10-11 close-out pass (ADR-017 §2's terminal window): - Publish→wake ordering tolerance: documented tolerance (decided with the user) — core-contract.md streams paragraph + delivery-obligations bullet, ADR-015 §4 companion annotation; the wake = offset-order ADR amendment declined (commit-ordered pg visibility; hint-shaped wake posture). - ADR-023 §2: heartbeat(extend) and save_offset(offset) join the boundary/offset row (total — negatives well-defined), with the home rationale annotated; core-contract's domain paragraph mirrors; the backlog numeric-domain row registers the suite-duty follow-up. - ADR-021 §5: EventReceiver::try_recv's closed shape pinned (Err(Closed), mirroring ADR-008 §3); core-contract subscribe arms sentence extended. - get_job prose: core-contract queues section + queues.md's reads sentence now say the uniform id-global / get_job_tx-filtered split. - Mem-arithmetic observation: no-action disposition recorded in the task Notes (byte-level transplants; equivalence suite-pinned three-way). Suite stamps checked — no accumulation required (no cited § text changed meaning). Docs-only; gates green: build, workspace tests, clippy -D warnings, fmt, fuzz replay, rustdoc -D warnings, taskgraph validate (79 tasks).
19 KiB
ADR-023: Fourth review round — encode_payload failure arm, numeric-argument domains, SQLITE_OPEN_URI, watcher poll cadence
Status
Accepted (2026-10-08, Phase 1 — fourth review round, following the
2026-10-08 waves-1–2 general review
(docs/reviews/001-waves-1-2-general-review.md). Amends
ADR-020 §4,
ADR-008 §5, and
ADR-012 §4 in place — pre-implementation
for the contract pieces (ADR-017 class 1,
amend-in-place; no core release exists) and as authorized substrate
deltas for the fork pieces (ADR-012
§4's delta discipline, recorded in
PROVENANCE.md at adoption per
ADR-018).
Context
The general review of waves 1–2 (the first review round after implementation began) found four decisions needing a call before or at wave 3's engine wiring, none decidable by one-liner:
-
encode_payloadmaps serialization failure to emptyVec<u8>(N-2 in the review). The helper ispuband re-exported from the core crate's lib root — it is contract surface, not an internal helper — and the engines' enqueue/publish paths call it to produce the stored bytes (ADR-020 §4). In practiceserde_json::to_vecon a&Valuecannot fail (serde_json::Numbercannot hold NaN/∞, so everyValueserializes), so the arm is unreachable — but if it ever fired, it would silently store empty bytes, which decode toNull: silent data corruption. The current signature bakes that disposition in with no way for an engine call site to know. -
claim_batch(n <= 0)claims the entire queue or is a silent no-op (M-2 in the review). SQLite'sLIMITdialect is the substrate's contract:LIMIT -1= no limit,LIMIT 0= empty. The fork correctly passesnthrough (contract-blind), but the trait method's semantics are the crate family's to define, and as inherited they are a dialect artifact:n = -1hands out every claimable row in the queue to one worker. And the same hazard is a class, not a lone call site: the stream reads (read_since/read_from_consumer) passlimitinto aLIMITclause the same way —limit = -1reads the entire stream into aVec, the memory-shaped version of the same hole. Extending to the trait surface, every numeric scalar argument's out-of-domain behavior is currently decided by whichever SQL dialect the engine's substrate speaks:ttl <= 0ontry_lock/renewerrors substrate-side (an opaqueDatabase), extents clamp or explode by dialect, and the remaining numerics are well-defined by accident of their SQL shape. No domain rule exists; both engines must decide identically or engine-agnostic consumer code breaks. -
open_connpassesSQLITE_OPEN_URI(N-4 in the review) — inherited from upstream (honker uses it forfile:paths), which makes the path argument URI-interpretable: a?name=valuesuffix mutates connection semantics (locking modes, cache modes) without the constructor's knowledge. The watcher's own open path does not pass the flag — the inherited code is internally inconsistent, evidence nothing depends on it. The engine'sopenconstructor is wave-3 territory, so the posture must be decided now or wired silently. -
The watcher's default poll interval is 1 ms (N-6 in the review) — the lineage default, inherited verbatim (register D-20). At idle this is ~1000
PRAGMA data_versionreads/sec per instance. It measures as shipped: deployment.md's wake-latency numbers (p50 ~1.1–2.2 ms) were measured at this cadence. Deciding whether 1 ms is the shipping default or a dev default is a config posture call, not a defect.
A fifth shape-review consideration bounds the set: whether a new error variant is needed anywhere. The answer below is no (ADR-008 §5's act-differently rule holds throughout).
Decision
1. encode_payload becomes fallible: Result<Vec<u8>, Error::Codec>
The helper's signature changes to
pub fn encode_payload(value: &serde_json::Value) -> Result<Vec<u8>, Error::Codec>; serialization failure maps to
Error::Codec — the taxonomy's pinned variant for payload
serialization/deserialization failures
(ADR-008 §5) — no variant growth. The
unreachable arm stops encoding a silent-corruption disposition and
becomes a typed failure at the right seam; the engines' error plumbing
absorbs it (both engines' enqueue/publish paths already return
Result, so the signature change composes with existing wiring).
Rejected: infallible via debug_assert/unreachable — the assert
compiles out in release, leaving the same hole the old arm covered,
without a panic's loudness (panics are banned outside tests), so it
buys nothing over a typed error; leave infallible with the
empty-Vec arm — the silent-decode-to-Null disposition is the one
outcome that cannot be defended, and wave 3 would build on it
engines-side.
Versioning: core is pre-release — ADR-017 §2 class 1 (amend-in-place), the window this change rides. No post-release consumer exists to break; the contract suite pins the new shape (backlog row below).
The semantic content of §4 is unchanged: still serialize with serde_json and store exactly those bytes — the stored form, the cross-engine byte-equality pin, and the round-trip property all hold; only the failure disposition of the one helper is now typed.
2. Numeric-argument domain rule — by argument kind
The trait surface's numeric scalar arguments get a shared domain rule, classified by argument kind, not call site:
| Kind | Arguments | Out-of-domain (non-positive) rule |
|---|---|---|
| Extent ("at most this many") | claim_batch(n), read_since(limit), read_from_consumer(limit) |
n <= 0 → empty value result (claims nothing / reads nothing), not an error |
| Duration (seconds of lease/TTL semantics) | try_lock(ttl), renew(ttl) |
ttl <= 0 → Err, opaque (engine detail in the source chain, Database) — the substrate's existing guard (ttl_s <= 0), now pinned contract-side |
Boundary / offset (<= predicates, absolute or relative instants) |
trim_to(horizon), enqueue delay/run_at/expires; heartbeat(extend), save_offset(offset) (annotated 2026-10-11) |
total — any i64 is well-defined (negative horizon deletes nothing; negative delay → ready in the past → ready now; negative extension → the renewed deadline now + extend is in the past, voiding the claim through the validity predicate, never an error; negative offset → stored, reads resume forward); no clamping, no rejection |
The rule the table pins: extents clamp to empty (no-work is a value, the pinned claims vocabulary), durations error, boundaries and offsets are total. Each disposition answers to a decided principle:
- Extents. "Claim up to
nrows" withn = 0is vacuously zero rows — the contract text already promises the emptyVecis ordinary. Minting an error variant for a caller-configurable extent's degenerate value fails ADR-008 §5's act-differently test: empty is a result the caller already handles (the no-work arm), indistinguishable from an empty-but-honest poll. TheLIMIT -1-claims-everything artifact dies with the clamp, engine- side at the trait-impl boundary — no dialect may reinterpret an extent argument. The silent-stall risk of a misconfigurednis real but bounded:nis a per-call constant (typically a loop bound from config), the misconfiguration is a caller-code bug like any other, and an error variant would not surface it louder in the loop's first iteration than the stuck-empty behavior would. The no-work-is-a-value posture composes with the whole claims surface. - Durations. The opposite disposition, and correctly so: a
non-positive
ttlon an exclusion mechanism must not silently hand back a lock the caller believes it holds. The substrate's existing guard is the disposition, not a new one — pinned here so the pg engine (no inherited guard) implements the same rule, and so engine-agnostic code sees a uniform shape. NoInvalidArgumentvariant is minted: the caller's remedy is the same regardless — fix the constant — so per ADR-008 §5 the failure stays in the opaque fallback, detail in the source chain. - Boundaries/offsets. The remaining numerics are total by
accident of their SQL today; the table makes it rule, not accident
— an implementer reading the table has nothing to improvise.
(Annotation, 2026-10-11 — the pre-release class-1 close-out pass
(ADR-017 §2), disposing the wave-6
review gate's recorded under-classifications. Two arguments join
the boundary/offset row, and deliberately not the duration row:
heartbeat(extend)'s seconds-shaped argument is not lease/TTL —extendis the new full deadline in seconds from now, so a non-positive value is not a handed-back lease the caller believes it holds (the duration rule's rejection rationale never applies); it is a relative instant likedelay, and the three engines' uniform arithmetic (saturating_add/ plain SQL integer add — code of record, memqueue.rs, substratequeue_ops.rs, pgqueue.rs) is total over non-positive values: a negative extension shortens the deadline, a past one is voided by the uniform validity predicate on the next op, and the heartbeat itself reports the successful renewal (true) exactly when the row was processing and unexpired at call time. Andsave_offset's offset is literally an offset: a negative is stored as the checkpoint, reads resume forward past it, and saves at-or-below the stored checkpoint are the ADR-019 §6 monotone no-ops — rejections were never the behavior, so pinning the duration row's shape here would have contradicted all three engines.)
This is a core-contract amendment (doc text on claim_batch,
both read forms, try_lock/renew, and a shared domain-rule
paragraph), not an engine-layer choice: if the two engines resolved
extent clamping differently (one errors, one clamps), engine-agnostic
consumers could not compose. Domains are semantics, and semantics
live in the contract. The engine-layer validation point (trait-impl
entry, before substrate) is where the rule is enforced — the
review's correct instinct — but what is enforced is the contract's.
3. SQLITE_OPEN_URI dropped — the engine open path is a plain filesystem path
The substrate's open_conn drops the SQLITE_OPEN_URI flag; the
path argument to the engine's open(path, opts) is a plain
filesystem path (:memory: included, which works without URI mode —
verified; what is lost is only file: URI syntax: shared-cache
in-memory DBs and locking/cache query-parameter mutation of the
connection outside the constructor's knowledge).
Rationale:
- A constructor argument silently interpreted as a URI — where a
?nolock=1suffix mutates locking semantics — is an inherited dishonest surface of exactly the class this fork exists to shed (the URI parameter surface is SQLite's, notalkstore-sqlite's, and would ride consumer-influenced path strings into connection-semantics mutation the engine cannot review). - The engine's posture is file-backed single-host with plain paths; the flag has no consumer-inventory row naming URI features (the row-first gate: a shared-cache/in-memory-URI use case re-enters with a row; the flag is cheaply restored — engine-crate scope, non-contract).
- The internal inconsistency is the tell: the watcher's reconnect
opens never carried the flag, so URI and non-URI opens already
mix within the inherited machinery for any
file:-shaped path. Nothing inherits URI semantics coherently even today. - This is an owned fork delta (an
ours-lineage change to inherited code, not a cherry-pick); it is recorded as aport deltaregister entry at adoption per ADR-012 §4 and ADR-018 — the delta discipline's regular operation, no separate record surface. - The upstream-removal comparison: upstream (honker) uses the flag
for
file:-URI shared-cache memory databases in its embedding harness. If a consumer needs:memory:shared across connections, that is the same re-entry path (an inventory row; possibly a named engine-opts surface), not a silent flag.
Rejected: keep + document that path is URI-interpreted — honest, but it leaves the attack surface (consumer-influenced strings reaching connection-semantics mutation) and the mixed watcher/main flag posture in place for zero named need; the documented form is strictly weaker than removal while the surface has no users.
4. Watcher poll cadence — 1 ms stays the shipping default; the knob is carried onto SqliteOpts
The inherited default (1 ms, register D-20) stays: the 1 ms default
stands as the shipping default, and the engine open constructor
carries it onto SqliteOpts as poll_interval: Option<Duration>
(None = the 1 ms default), with deployment.md carrying the
documented facts (the idle data_version read rate) and the tuning
recipe (raise the interval for latency-tolerant deployments).
Rationale:
- The POC's published wake-latency numbers (p50 ~1.1–2.2 ms, deployment.md) were measured at 1 ms — the default is the advertised behavior. Changing it would silently un-advertise the measured facts.
- The knob already exists substrate-side (
WatcherConfig:: with_poll_interval, zero-interval rejected); carrying it ontoSqliteOptsis wiring, not machinery. - The cost (~1000 reads/sec idle) is documented, not hidden — this is the family's deployment-asserts-truth posture; idle CPU is a tuning concern for latency-tolerant deployments, and the engine opts surface is the pinned home for cadence (ADR-008 §6 — engine-configuration concerns live in the engine's option structs).
Rejected: raising the shipping default to ~50 ms — it would change the measured-and-advertised wake latency fivefold to save an idle cost the docs already surface, for a trade no consumer row names; hiding the cadence (no knob) — dishonest about the cost and removes the tuning lever the config surface exists for.
Consequences
Positive
- The silent-corruption arm (empty-Vec-on-failure) is gone before wave 3 consumes the helper; both engines now cannot silently store undecodable payloads.
- The
LIMIT-dialect artifact dies before wiring: no engine may reinterpret an extent argument, and both engines implement the same domain table — engine-agnostic code composes by construction. - The class is decided once, not per call site: future numeric arguments land in a row of the table, an implementer has nothing to improvise.
- The URI surface is gone before a constructor exists to pass consumer-influenced strings through it; the fork shed another inherited surface with a named rationale.
- The measured wake-latency facts stay advertised and true; the tuning lever is real and documented.
- No new error variants — ADR-008 §5's taxonomy holds throughout
(the domain rule's error case reuses the opaque fallback; the
encode case reuses
Codec).
Negative
encode_payload's call sites and tests gain?/.map_errat the seam — five call sites today, both engines' enqueue/publish paths wave 3 onward. The fallible shape is the honest cost of the typed seam.claim_batch/read_*'sn <= 0empty-value semantics are observable but oblique (an out-of-domain extent looks like an honest empty result); the contract suite pins both, so a review round sees the rule rather than rediscovering it.- The URI surface is not gone from SQLite itself — a consumer
could still pass a
file:string to the engine, whose behavior is now plain-path (SQLite treats it as a filename); the docs should note the shape so surprises route to the documented posture, not SQLite URI folklore. - Contract-suite surface grows: the new backlog rows below.
Verification backlog additions
- Extent-clamp semantics on both engines (§2) —
claim_batch(n = 0)claims nothing (emptyVec, no error);read_since/read_from_consumerwithlimit <= 0read nothing (emptyVec);claim_batch(n < 0)never claims more rows than a positive-n claim on the same queue state would (theLIMIT -1dialect artifact is dead on both engines). - Duration-refusal on both engines (§2) —
try_lock(name, owner, ttl <= 0)andrenew(ttl <= 0)are rejections (opaqueErrshape,Database) on both engines, with the substrate guard's message preserved in the source chain where the substrate carries it. encode_payloadtyped-failure round-trip (§1) — enqueue/publish store and decode the exact serialization of the inputValue; the helper's failure arm is typed (Codec), not silent.- Plain-path open on the SQLite engine (§3) — a path string
carrying a
?name=valuesuffix is treated as a filename (file created with that literal name) unless URI mode is explicitly enabled — pinning that no consumer path re-enables URI interpretation silently; the watcher's non-URI opens are unchanged shape. - Poll-cadence default and knob (§4) — the SQLite store's watcher
runs at 1 ms with
SqliteOptsdefault;poll_intervalset on the opts changes the observed cadence (sleep accounting in the watcher's config surface, not wall-clock timing in the suite — cadence measurements stay out of the contract suite's deterministic posture).
References
docs/reviews/001-waves-1-2-general-review.md— the review report that found all four (N-2, M-2, N-4, N-6; M-2's engine-layer resolution point recommended there is adopted where, while this ADR decides what, contract-side).- ADR-020 §4 — the payload-encoding bridge §1 completes (signature typed, semantics unchanged).
- ADR-008 — §5 (the taxonomy rule
every disposition answers to:
Codecreuse, opaque-fallback durations, no variant growth), §6 (the config split §4'sSqliteOptsknob rides). - ADR-012 §3/§4 — the fidelity
posture that keeps
open_conn's delta a register entry, and the delta discipline that authorizes it. - ADR-018 — the register the URI-flag delta is recorded in at adoption.
- ADR-017 §2 — the class-1 window (pre-release amend-in-place) the contract pieces ride.
- ADR-019 — the handle surfaces whose numeric arguments §2's table classifies.
- ADR-010 §1 — the claims semantics (no-work-is-a-value) that §2's extent rule composes with.