Files
alkstore/tasks/pg-decide-stamp-arithmetic-side.md
T

7.1 KiB
Raw Blame History

id, name, status, depends_on, scope, risk, impact, level, tags
id name status depends_on scope risk impact level tags
pg-decide-stamp-arithmetic-side Decide which side guards pg's server-side stamp arithmetic (review 004 item 5, decision) completed
narrow low component implementation
review-004-fixes
postgres-engine
decision
contract

Description

The decision that must precede the BIGINT-overflow fix, per docs/reviews/004-pre-release-general-review.md §"Findings recorded, not fixed" item 5: pg's claim, heartbeat, retry-reschedule, and retention-sweep statements compute server-side sums over consumer-supplied i64 values (queue.rs:386/912/1000/714, lock.rs:91) — one row stamped with visibility_timeout_s = i64::MAX (or retention i64::MIN) makes every claim/heartbeat UPDATE fail with "bigint out of range", and the failure is sticky per row. The client-side resolution is saturated (resolution.rs); the row-stamp arithmetic is not.

Decide which side owns the guard:

  • (a) Clamp/validate at the enqueue seam — consumer i64s bounded at entry, matching resolution.rs's client-side saturation posture and the sibling engines' stamp arithmetic (sqlite/mem saturate per review 004 inline fix 6 — the engine-uniformity argument).
  • (b) LEAST/GREATEST-guard the server expressions — defense at the arithmetic site; no entry-point change, but the stored stamps are silently clamped server-side.

Contract-adjacent — this is why it's its own task. The choice changes which failures are reachable through the public surface, which touches ADR-023 §2's totality wording. Check the window state first: as of the decomposition (2026-10-11) no publish tag exists, so the class-1 window is presumed open — verify, and if open, land the decision with a dated ADR-023 §2-style annotation (the failure-set change is class-1-legal pre-publish, the review-004 inline fix 5 precedent). If the window has closed, raise with the user before pinning anything.

The decision must name the guard form precisely: the clamp domain, which sites it applies to (enqueue vs the stamp sites), and what the observable behavior of an extreme-stamped row becomes.

Acceptance Criteria

  • The decision of record: which side owns the guard, with the ADR-023 §2 totality argument and the sibling-engine uniformity check written down
  • The window state verified and recorded; a dated annotation landed if class-1, or user sign-off if post-publish
  • The guard form is precise (clamp domain, sites, the extreme-stamped row's observable semantics) — enough for pg-fix-bigint-stamp-overflow to implement without re-deciding
  • The decision recorded where the family keeps such records (ADR annotation / task Notes + contract text if the wording needs it)

References

  • docs/reviews/004-pre-release-general-review.md §"Findings recorded, not fixed" item 5 (the finding, the which-side question, the contract-adjacency)
  • docs/architecture/decisions/023-fourth-review-round.md §2 (the totality wording the choice touches)
  • alkstore-postgres/src/resolution.rs (the client-side saturation posture), queue.rs / lock.rs (the stamp sites)

Notes

The site set is six, not four — the lock arithmetic is two expressions, and the review's lock.rs:91 line ref was the try_lock insert only: lock.rs:92 (insert) and lock.rs:217 (renew) both sum $3::bigint + $4::bigint over the per-call ttl; and the retention sweep's subtraction is queue.rs:715 (the :714 ref was the IS NOT NULL line above it). The guard form must cover the renew arm too.

Per-call vs stamped arguments split the option-(a) story. Four of the six arguments are per-call (heartbeat(extend), the retry(err, Some(d)) override, try_lock/renew(ttl) ×2) — there is no enqueue to clamp at. The other two ride enqueue/stamped sources (visibility_timeout_s, dead_letter_retention_s — QueueOpts, stamped verbatim per the review-002 disposition, which recorded unvalidated stamping as the posture, not a defect). So option (a) as written decomposes into two different entry rules — one of which (validating QueueOpts) contradicts the recorded review-002 posture, and the other (validating per-call boundary args) contradicts ADR-023 §2's totality wording itself ("no clamping, no rejection" — heartbeat(i64::MAX) is a value, not a violation). Option (b) needs neither posture change: it realizes what the pinned totality already computes.

The pinned arithmetic's form, not its totality, is what was wrong. ADR-023 §2's 2026-10-11 annotation cites "saturating_add / plain SQL integer add" as the three engines' code of record and calls it total — correct for the non-positive values that annotation classified, but pg's plain bigint add was never total in the overflow domain (Postgres errors; SQLite's overflow→REAL approximates; Rust saturates). The decision therefore needs no contract-text change — the table pins the dispositions already; the annotation corrects the form on pg.

Summary

Decision of record: (b), the server expression owns the guard; clamp domain = the arithmetic result, saturated to the i64 domain; no input clamped, validated, or rejected at any seam. Guard form: every server-side stamp expression computes its sum in numeric and clamps before the bigint cast — LEAST(GREATEST(<sum>, -9223372036854775808), 9223372036854775807)::bigint — the SQL realization of the same saturating arithmetic the pinned code of record runs. Sites of record: pg queue.rs:386/912/1000/715 (claim, heartbeat, retry-reschedule, retention sweep) and lock.rs:92/217 (try_lock insert, renew); enqueue itself stays Rust-resolved (resolution.rs's saturating_add — never a SQL-side sum). Extreme-stamped-row observables (all three engines uniform): visibility_timeout_s = i64::MAX → claim deadline i64::MAX (effectively never lapsing); dead_letter_retention_s = i64::MIN → swept-before i64::MAX (sweeps everything, matching mem's saturating_sub); negative-extreme extend/delay → a past deadline voided by the uniform validity predicate on the next op; ttl = i64::MAX → i64::MAX expiry. "bigint out of range" leaves pg's reachable surface; the sticky-poison class dies; the duration row's opaque rejection stays ttl <= 0 only.

Recorded as the dated 2026-10-11 annotation in docs/architecture/decisions/023-fourth-review-round.md §2 (amending the boundary-row annotation's arithmetic parenthetical in place — ADR-017 §2 class 1). Window state verified open at decision time (2026-10-11): no git tags exist, no cargo publish has occurred, and ADR-017 §2's termination of record pins the class permanently at the first core-crate publish — so the annotation landed pre-publish, legal.

The guard form was validated against the live Postgres 16 harness server before pinning: clamping legs green (overflow both edges clamps; 1700000000 + i64::MIN rides exact, matching Rust saturating_add; now − i64::MIN clamps to i64::MAX), so pg-fix-bigint-stamp-overflow can implement the form verbatim without re-deciding. No code changed (a decision task); no crate touched, so no build/test gates re-run — the doc-only diff carries no rustdoc surface.