23 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.)
(Annotation, 2026-10-11 — the pg stamp-arithmetic guard decision
(ADR-017 §2 class 1, the window
verified open — no tag exists, no cargo publish has occurred; the
review-004 item-5 pre-fix precedent). Review 004's post-round finding:
pg's server-side stamp arithmetic — the claim's
$2 + visibility_timeout_s (claim deadline), the heartbeat's
$3 + extend, the retry-reschedule's $2 + delay_s, the retention
sweep's $2 − dead_letter_retention_s, and the lock stamp/renew's
$3 + ttl (lock.rs:92/217) — overflow-failed on plain bigint
("bigint out of range") and the failure is sticky per row, one
extreme-stamped row poisoning every later claim/heartbeat/sweep that
sums against it. The side question is here because the boundary row's
totality lives on the argument values these sums consume, and the
two candidate guards differ in what they touch: an enqueue-seam
clamp/validate owns the guard at the entry of the consumer-supplied
i64s; a server-expression guard clamps the arithmetic result.
Decided: the server expression owns it — the clamp domain is the
sum's result, no input is clamped or validated at any seam. The
enqueue seam cannot own the guard: four of the six arguments are
per-call (heartbeat(extend), the retry(err, Some(d)) override,
try_lock/renew(ttl) ×2) and have no enqueue to clamp at; and
where an enqueue does exist, a result saturation is what the
pinned posture already computes, not a new input rule — enqueue's
own stamps resolve in Rust saturating_add (resolution.rs, both
engines), the boundary row pins argument totality ("any i64 is
well-defined… no clamping, no rejection" — a caller's
heartbeat(i64::MAX) or retry(err, Some(i64::MAX)) is a value,
not a violation), and QueueOpts stamps enter rows unvalidated
verbatim by the recorded review-002 disposition, so validating them
at entry would be a posture change, not a guard. The guard form,
precise: every server-side stamp expression saturates its result
to the i64 domain — LEAST(GREATEST(<sum computed in numeric>, -9223372036854775808), 9223372036854775807)::bigint, subtraction
sites analogously — verified green against a Postgres 16 harness: an
overflowing sum clamps at either edge, a representable result rides
untouched (1700000000 + i64::MIN stays exact, matching Rust's
saturating_add), now − i64::MIN clamps to i64::MAX (matching
mem's retention saturating_sub). The extreme-stamped row's
observable semantics, per site: a row stamped
visibility_timeout_s = i64::MAX claims to the i64::MAX deadline
(the claim never lapses through expiry; effectively-never, the same
shape as sqlite's REAL-approximation and mem's saturation); a row
stamped dead_letter_retention_s = i64::MIN sweeps every dead row
(sat(now − MIN) = i64::MAX); heartbeat(extend) extremes renew to
the saturated edge (a past saturated deadline voids through the
validity predicate on the next op, the annotation above's negative
semantics preserved); retry(err, Some(i64::MAX)) reschedules to
i64::MAX (ready ~never, the caller's value honored to the edge);
ttl = i64::MAX stamps an i64::MAX expiry. No contract text
changes: the table pins these dispositions already — what the
annotation completes is the arithmetic form, correcting the
"plain SQL integer add" parenthetical above: on pg the pinned
saturating arithmetic is realized as the server-side guard, not a
plain add, and the "bigint out of range" failure leaves the
reachable surface (no opaque-Err arm covers arithmetic overflow;
the duration row's rejection stays ttl <= 0 only). Sites of
record: pg queue.rs:386/912/1000/715, lock.rs:92/217; enqueue
itself stays Rust-resolved (no SQL-side sum at enqueue). The
implementation rides tasks/pg-fix-bigint-stamp-overflow.md —
cargo test -p alkstore-postgres live-server legs pin the
sticky-poison retirement.)
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.