17 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 |
total — any i64 is well-defined (negative horizon deletes nothing; negative delay → ready in the past → ready now); 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.
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.