fix(wallet): discipline reservation liveness against a monotonic clock - #539
Merged
Conversation
Reservation deadlines (#502/#525/#528) are anchored entirely on wall-clock readings. #528 closes the case where the clock is already wrong at the moment a reservation is FIRST written. It does not close the general form (#532): a wall clock stepped FORWARD while a reservation is already live, mid-hold -- an NTP step, a VM pause/resume, an operator setting the clock -- produces no self-contradiction for #528's check to catch, yet the very next prune reads the jump as elapsed time and can retire a bundle's hold while it is still genuinely in flight, with no bound on how far forward the step goes (the #348/#497 double-spend direction). Add ClockGovernor: it disciplines every reservation-lifecycle "now" reading so it cannot advance, between two observations, faster than a monotonic clock says real time has actually elapsed. A forward wall-clock jump is absorbed rather than trusted and the disciplined clock simply runs behind until real time catches up, at which point it resumes tracking the wall clock with no special unfreeze step. A backward step is passed straight through unclamped, since it can only lengthen a hold, never shorten one -- the safe direction #502/#528 already accept elsewhere. The governor lives for the process's lifetime and is not persisted: a restart re-seeds it from the wall clock at that moment, so a clock already wrong at boot remains #528's write-time contradiction check's problem, not this one's. Closes #532 Co-Authored-By: Claude <noreply@anthropic.com>
Root workspace version, per the main lane -- the minor field's version scheme is being fixed separately under #521/#522; this is the interim number to carry PR #539 (dig-node#532) through the version-increment gate. Cargo.lock refreshed in the same commit (cargo update -w --offline) so dig-node-service's locked entry matches -- every CI job runs --locked, and a manifest-only bump here fails Clippy/Test+coverage/all three package builds together on a change that cannot otherwise break a build. Co-Authored-By: Claude <noreply@anthropic.com>
…onotonic # Conflicts: # Cargo.lock # Cargo.toml
…onotonic # Conflicts: # Cargo.lock # Cargo.toml # SPEC.md
Closed
3 tasks
…onotonic # Conflicts: # Cargo.lock # Cargo.toml
…onotonic # Conflicts: # Cargo.lock # Cargo.toml
MichaelTaylor3d
added a commit
that referenced
this pull request
Sep 3, 2026
Merged origin/main forward (7042f89, #535) rather than rebasing -- the queue across the five open PRs sequences this one last (#542 0.254.42, #539 0.254.43, #543 0.254.44, #536 0.254.50, this PR 0.254.51), deliberately: #542 touches ensure-version-increment.yml, which this PR's follow-up note also targets, so landing last means the final shape is on main rather than guessed at. Re-read the version from Cargo.toml on disk (not the commit log) after the merge, per the standing caution that a rebase can silently drop a bump commit as "already upstream" -- this was a merge, and the version file itself confirms 0.254.51 post-bump. Co-Authored-By: Claude <noreply@anthropic.com>
Per the main lane: main advanced to exactly 0.254.41 after #535's rebase, tying this branch's version. 0.254.43 clears main and every sibling PR in the version-bump queue (#542=0.254.42, #543=0.254.44, #536=0.254.50, #544=0.254.51). Cargo.lock re-synced with `git checkout origin/main -- Cargo.lock` followed by `cargo update -w --offline` (never hand-editing lock conflict markers), confirmed clean with `--dry-run` -> `Locking 0 packages`. Co-Authored-By: Claude <noreply@anthropic.com>
…onotonic # Conflicts: # Cargo.lock # Cargo.toml
….254.61) # Conflicts: # Cargo.lock # Cargo.toml
MichaelTaylor3d
marked this pull request as ready for review
September 3, 2026 20:29
MichaelTaylor3d
added a commit
that referenced
this pull request
Sep 3, 2026
…543) * fix(wallet): discipline reservation liveness against a monotonic clock Reservation deadlines (#502/#525/#528) are anchored entirely on wall-clock readings. #528 closes the case where the clock is already wrong at the moment a reservation is FIRST written. It does not close the general form (#532): a wall clock stepped FORWARD while a reservation is already live, mid-hold -- an NTP step, a VM pause/resume, an operator setting the clock -- produces no self-contradiction for #528's check to catch, yet the very next prune reads the jump as elapsed time and can retire a bundle's hold while it is still genuinely in flight, with no bound on how far forward the step goes (the #348/#497 double-spend direction). Add ClockGovernor: it disciplines every reservation-lifecycle "now" reading so it cannot advance, between two observations, faster than a monotonic clock says real time has actually elapsed. A forward wall-clock jump is absorbed rather than trusted and the disciplined clock simply runs behind until real time catches up, at which point it resumes tracking the wall clock with no special unfreeze step. A backward step is passed straight through unclamped, since it can only lengthen a hold, never shorten one -- the safe direction #502/#528 already accept elsewhere. The governor lives for the process's lifetime and is not persisted: a restart re-seeds it from the wall clock at that moment, so a clock already wrong at boot remains #528's write-time contradiction check's problem, not this one's. Closes #532 Co-Authored-By: Claude <noreply@anthropic.com> * chore(release): bump workspace version to 0.254.20 Root workspace version, per the main lane -- the minor field's version scheme is being fixed separately under #521/#522; this is the interim number to carry PR #539 (dig-node#532) through the version-increment gate. Cargo.lock refreshed in the same commit (cargo update -w --offline) so dig-node-service's locked entry matches -- every CI job runs --locked, and a manifest-only bump here fails Clippy/Test+coverage/all three package builds together on a change that cannot otherwise break a build. Co-Authored-By: Claude <noreply@anthropic.com> * fix(wallet): route wallet_reset_coin_db's now through ClockGovernor wallet_reset_coin_db read its now_ms from a fresh, undisciplined SystemTime::now() rather than WalletBackend::reservation_now_ms(), so a wall-clock jump mid-hold (an NTP correction, a VM pause/resume) could make its in-flight-spend check see a still-live reservation as already expired and let the reset proceed -- the #348/#497 double-spend direction, no attacker required. reservation_now_ms() is now pub so the control plane (a different crate) can route through it, sharing the same ClockGovernor clamp state every other reservation call site (reserve_coins, prune_reservations) already uses. Swept every reservation-touching path in dig-wallet and dig-node-service for a direct SystemTime::now() read; this was the only production one. Closes #541 Co-Authored-By: Claude <noreply@anthropic.com> * chore(release): bump dig-node 0.254.42 / dig-wallet 0.49.0 dig-wallet: minor -- reservation_now_ms is now a public API surface (dig-node-service routes through it, dig-node#541). dig-node: patch -- behaviour fix, no breaking change. Co-Authored-By: Claude <noreply@anthropic.com> * chore(release): re-bump to 0.254.44 -- coordinator-assigned to avoid collision with #542 #542 keeps 0.254.42 (urgent required-CI-gate PR, merges first); 0.254.43 is reserved for #539, which this branch sits on top of. Version assignment across concurrent PRs is the coordinator's per CLAUDE.md section 1.4. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
MichaelTaylor3d
added a commit
that referenced
this pull request
Sep 3, 2026
…bump (#544) * fix(release): guard the MSI cross-release ordering hazard at a MAJOR bump scripts/package-version.sh's MINOR-overflow carry (#537, closing #521/#522) folds an overflowing MINOR into the idle MSI major field and is monotonic for the whole MAJOR==0 lifetime, but it cannot see a cross-release hazard: a carried release like 0.511.0 (MSI 1.255.0) can compare HIGHER under msiexec's numeric ordering than a later, perfectly legal 1.0.0 (MSI 1.0.0). An in-version guard can't catch this -- 1.0.0 alone is not illegal, only a specific predecessor makes it a downgrade. Add an optional second argument, PREV_VERSION (the previous stable release's bare X.Y.Z). When supplied, the script folds it with the same rule and refuses to emit a version whose MSI tuple compares LOWER than PREV_VERSION's -- turning the "pick a high-enough MAJOR-bump number" decision into a machine-checked one instead of an unchecked human call. Omitting it (every existing package.yml callsite) is byte-for-byte unchanged; ensure-version-increment.yml already checks out both the PR head and main, so a follow-up can wire main's version in at zero extra cost (sequenced separately against #542, which is adding a step to that same job). Measured (dig-node#540): MINOR has never exceeded 255 in any released dig-node version, so nothing shipped is affected today -- this closes the hazard before it can ever be reached rather than reacting once it is. Also corrects "dig_ecosystem#521/#522" references in this script/test/SPEC to "#521/#522" -- those are dig-node's own issues, not the unrelated dig_ecosystem tickets of the same numbers. Revert-proof: stripping the guard reproduces the exact hazard as an accepted exit 0 (0.511.0 -> 1.0.0), and reinstating it passes the full suite (58 ok / 0 fail). Closes #540 Co-Authored-By: Claude <noreply@anthropic.com> * chore(release): re-bump to 0.254.51 -- main took 0.254.41 (#535) Merged origin/main forward (7042f89, #535) rather than rebasing -- the queue across the five open PRs sequences this one last (#542 0.254.42, #539 0.254.43, #543 0.254.44, #536 0.254.50, this PR 0.254.51), deliberately: #542 touches ensure-version-increment.yml, which this PR's follow-up note also targets, so landing last means the final shape is on main rather than guessed at. Re-read the version from Cargo.toml on disk (not the commit log) after the merge, per the standing caution that a rebase can silently drop a bump commit as "already upstream" -- this was a merge, and the version file itself confirms 0.254.51 post-bump. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
Reservation liveness (
RESERVATION_TTL_MS/MAX_RESERVATION_HOLD_MS, #502/#525/#528) is judgedentirely on raw wall-clock readings. #528 (open, not yet merged) closes the case where the clock is
already wrong at the moment a reservation is FIRST written -- a self-contradiction between a row's
own two columns.
This closes the general form (#532): a wall clock that steps FORWARD while a reservation is
already live, mid-hold -- an NTP step correction, a VM pause/resume, an operator setting the
clock -- produces no such contradiction (the anchor is unchanged and the jumped reading looks like
an ordinary "now"). The very next liveness check reads the jump as elapsed time and can retire a
bundle's hold while it is still genuinely in flight, with no bound at all on how far forward
the step goes -- the #348/#497 double-spend direction, and strictly worse than #528's bounded
110-minute residual for a bad reading at write time.
ClockGovernor(new,crates/dig-wallet/src/sage/custody.rs) disciplines everyreservation-lifecycle "now" reading so it cannot advance, between two observations, faster than a
monotonic clock says real time has actually elapsed.
WalletBackendnow reads reservation"now" through
self.reservation_now_ms()(a thin wrapper sharing oneArc<Mutex<ClockGovernor>>across every
Clone, same pattern ascustodied_public_keys) instead of callingsuper::custody::now_ms()directly, at all 6 production call sites in the reservation lifecycle(
reservations_held,reserve_coins,reserve_pushed_bundle,get_pending_transactions,spendable_coins, the CAT-send path).The shape, and why
jump is absorbed rather than trusted -- the disciplined clock simply runs behind the wall clock
and simply keeps pace with REAL elapsed time, until real time genuinely catches up, at which
point it resumes tracking the wall clock exactly as before (no special unfreeze step; see
the_clamp_releases_itself_once_real_time_catches_up). A backward wall-clock step is passedstraight through UNCLAMPED -- it can only lengthen a hold, never shorten one, which is the safe
direction A repushed bundle re-arms its reservation from now, so a retrying caller can hold coins indefinitely #502/fix(wallet): bound a reservation deadline by the observing clock, not only by the first push's #528 already accept elsewhere. A governor that also clamped backward steps would
be deciding a hold expired sooner than either clock claims -- exactly the failure this exists to
close.
from the wall clock read at that moment (
WalletBackend::new), so a clock that is already wrongAT BOOT is unguarded by this governor -- that is
now_mswritten directly into a freshreservation, and remains fix(wallet): bound a reservation deadline by the observing clock, not only by the first push's #528's write-time contradiction check's problem to catch, not this
one's. This governor's job starts the instant after boot: a clock that reads fine at startup and
jumps forward LATER, mid process lifetime, mid hold.
disciplined value instead of a raw wall-clock value at the same 6 call sites, so write-time and
read-time observations stay on ONE continuous timeline (the same philosophy
custody::now_ms'sown doc comment already states for why there is one clock implementation, not two). This PR is
independent of fix(wallet): bound a reservation deadline by the observing clock, not only by the first push's #528 and does not depend on it merging first or in any order.
Instant-anchored deadline outright.Instantdoes not survive a process restart, and reservations ARE persisted across restarts, so a naive
swap trades a clock-jump bug for a restart bug. The hybrid here keeps wall-clock persistence
(unchanged DB schema, unchanged fix(wallet): bound a reservation deadline by the observing clock, not only by the first push's #528 behaviour) and adds monotonic discipline only for the
in-process, mid-lifetime observation gap that fix(wallet): bound a reservation deadline by the observing clock, not only by the first push's #528 cannot see.
Tests
crates/dig-wallet/src/sage/custody.rs,mod tests(new), 5 tests, all passing:a_wall_clock_matching_real_time_passes_through_unclamped-- no clamp in the ordinary case.a_forward_jump_mid_hold_is_clamped_to_real_elapsed_time-- a 1-hour wall-clock jump with only100ms real elapsed is clamped to 100ms.
a_backward_step_passes_through_unclamped-- confirms the deliberate asymmetry.the_clamp_releases_itself_once_real_time_catches_up-- proves this is not a freeze: trackingresumes automatically once real elapsed time reaches the jumped value.
a_forward_clock_jump_mid_hold_no_longer_releases_a_live_reservation_early-- the moneyproperty, built from the REAL
WalletDb::reserve_spend/prune_reservationsAPI (no hand-placedrow, same shape fix(wallet): bound a reservation deadline by the observing clock, not only by the first push's #528's own regression tests use): a bundle pushed at t=0, then a raw wall-clock
jump that would read as past the reservation's deadline, does NOT release the coin when pruned
through the governed clock; it releases only once real elapsed time actually reaches the TTL.
Every pure-decision test uses
Instant::now() + Durationto construct two readings a knowndistance apart -- exact and deterministic, no sleeping, no fake-clock trait needed.
Regression check -- every existing reservation-lifecycle test still passes after routing through
the governor (
cargo test -p dig-wallet --lib reserv, run against the builtWalletBackendproduction paths, not just the DB layer):
cargo test -p dig-wallet --lib sage::custody::tests:cargo clippy -p dig-wallet --lib -- -D warnings: clean, exit 0.cargo check -p dig-wallet --lib: clean, exit 0.rustfmt --check --edition 2021on both touched files: clean, exit 0 (touched files only, notcargo fmt --all).Versioning -- NEEDS THE MAIN LANE
crates/dig-wallet/Cargo.tomlis bumped0.48.0 -> 0.48.1(patch: internal bug fix, no APIchange) and
Cargo.lockis refreshed to match.The root
[workspace.package].version(the releaseddig-nodebinary version, currently0.254.0) is intentionally left UNCHANGED. Per the dispatching brief: the minor field is at itsceiling (
package-version.shcapsminor <= 255) and another lane is fixing the version schemeunder #521/#522 right now. Requesting the correct next root version rather than inventing one --
the
Check version incrementCI job will fail on this PR until that number is supplied andapplied.
Status
DRAFT. Do not merge, do not undraft -- handing back to the main lane per the dispatch brief.
Closes #532