Skip to content

Bound the spending history by count, so a moved clock cannot erase it - #1067

Merged
kwsantiago merged 2 commits into
mainfrom
the-spending-history-survives-a-clock-that-moved
Oct 18, 2026
Merged

kwsantiago merged 2 commits into
mainfrom
the-spending-history-survives-a-clock-that-moved

Conversation

@kwsantiago

@kwsantiago kwsantiago commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

The spending history that backs daily_limit and weekly_limit was pruned against the device wall clock, and the prune ran while reading the record. A clock moved forward past the weekly window matched every entry, so loading the counters discarded all of them, and the next spend persisted the pruned history. Correcting the clock did not bring it back: both totals read zero from then on, and the limits they back counted nothing.

Physical access to an unlocked device and the date setting is enough. No root, no prompt, and nothing in the record afterwards shows what was lost.

The prune was also unnecessary. daily_total and weekly_total each select their own window when they are asked, so the limits were never relying on the stored history having been trimmed. Pruning by age only bounded the size of the record, and the count cap already does that: the history is now bounded by MAX_TRACKED_TRANSACTIONS alone, which is a number the clock cannot move. This is the same principle load_descriptors already states in the same file, that a read path must not prune, because pruning is a write that makes an absence permanent.

What that costs, stated plainly: the worst-case record size is unchanged, because the count cap was already the ceiling, but the typical steady state grows. Before, the record shrank to the last seven days on every load; now a device that has ever recorded a large number of spends keeps them, up to roughly 800 KB of JSON at the cap, re-encrypted and rewritten on each spend. Compacting the at-rest encoding, which stores each timestamp as an RFC3339 string with nanoseconds, would roughly halve it and is tracked separately rather than folded in here, since changing the stored format needs a migration path for records already written.

Since the cap is now the only bound, a record arriving above it would be loaded whole and would not come back under the cap for thousands of spends, because record evicts one entry per call. load_velocity therefore refuses such a record instead of trimming it: trimming would put a destructive write back on the read path, which is the shape this change exists to remove, and refusing leaves the counters unknown, which already denies every rule that depends on them. The record is also bounded by length before it is parsed, as the policy record is.

Not closed by this change: while the clock is wrong, the totals themselves still select the wrong window, so a device whose date is pushed forward reports zero for a window in which spends did happen and the limits do not bind during that period. Separating that out needs a monotonic reference rather than the wall clock, which is tracked separately. The difference this makes is between a bypass that lasts as long as the clock is wrong and one that is permanent and silent.

Tests cover that reading the record preserves what it holds whatever the clock says about the age of the entries, that recording a spend afterwards cannot launder the loss into storage, and that the totals select their own window. The forward clock cannot be produced by moving the real clock under test, so the entries carry the age a shifted clock would make them appear to have; reverting the fix drops exactly those entries and the test fails.

Summary by CodeRabbit

  • Bug Fixes
    • Velocity history is now retained beyond seven days, while daily and weekly totals continue to include only transactions within their respective time windows.
    • History is capped at 10,000 transactions. Oversized or invalid saved records are rejected instead of being loaded, preventing them from affecting velocity-based spending decisions.

@coderabbitai

coderabbitai Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 0a007d76-0c4c-4e3d-ab52-511c96d28538
📥 Commits

Reviewing files that changed from the base of the PR and between fa8109b and 4f5119b.

📒 Files selected for processing: 3
  • keep-mobile/src/lib.rs
  • keep-mobile/src/persistence.rs
  • keep-mobile/src/velocity.rs

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

Walkthrough

Velocity tracking now retains history based on transaction count rather than age. Stored velocity records are checked against byte and transaction limits when loaded. Tests cover time-based totals, retained history, and policy evaluation with an over-capacity record.

Changes

Velocity history and persistence

Layer / File(s) Summary
Bound velocity history by count
keep-mobile/src/velocity.rs
VelocityTracker retains old transactions and evicts entries based on the 10,000-transaction cap. Daily and weekly totals continue to use their time cutoffs. Tests cover retained history across reloads and writes, and totals for entries of different ages.
Validate stored velocity records
keep-mobile/src/persistence.rs, keep-mobile/src/lib.rs
load_velocity rejects records above the byte ceiling or transaction cap. A policy test verifies that evaluating a spend with an over-capacity record returns Deny.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Suggested reviewers: wksantiago

Merge Risk: ⚪ Minimal · up to 4f511

The retention and stored-record checks appear consistent with the intended behavior. No issue requiring resolution before merge was identified.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly summarizes the main change: spending history is bounded by transaction count instead of being erased when the device clock moves forward.
  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit checks the ledger’s bounds,
And keeps old tracks where time still counts.
The daily sum looks close at hand,
The weekly sum spans wider land.
Safe records hop back to their stand.

Comment @coderabbitai help to get the list of available commands.

@kwsantiago
kwsantiago merged commit 7d94e9d into main Oct 18, 2026
13 checks passed
@kwsantiago
kwsantiago deleted the the-spending-history-survives-a-clock-that-moved branch October 18, 2026 02:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant