What every binary that can spend has ever spent, on disk, so that no amount of restarting, crashing or running two of them at once can lose track of the total.
Why a file and not a counter in each process. jevsnes's native
and jevprobe, jevhooks, lmjtfy's eval and anything else that builds a
[crate::Jev] are separate processes with no shared memory. A total kept
in one process's memory only sees that process's own spend; two of them
running at once could each think the account had spent nothing so far.
The file is what makes "lifetime" mean the account, not the process.
Why flock and not a database. One file, appended to, read
occasionally, by at most a handful of processes that mostly are not
actually running at the same moment - a lock file is the whole mechanism a
database would give here, without a dependency that cannot compile for
nothing (this crate is already the host-only one that opens sockets and
files at all; see its README). flock(2)'s lock is released
when the file descriptor closes, including when the process is killed, so
a crash mid-write cannot leave the ledger wedged - which is the property
that matters more than losing one torn write to a crash nobody could have
flushed cleanly anyway.
The guards, and which of them a human ever has to touch. Every request
is admitted by [Ledger::admit], which reads the file, decides, and - in
the same flock - appends a [Hold] for the request it admitted, so two
processes admitting at the same moment are serialised on the lock and the
second one sees the first one's question. Nothing about the guards lives
in any process's memory; the file IS the bucket.
- Questions a minute: a throttle, never a pause. The bucket holds
max_questions_per_minutetokens; a question spends one and it comes back exactly a minute later, which is the sliding window read straight off the ledger's recent lines. An empty bucket refuses with [Refusal::Throttled] and how long until the next token returns; the caller keeps playing without asking (the bot takes its own pick), and the bucket refills as the window slides. There is no file to delete, because there is no state but the ledger: on 2026-09-21 the old breaker'spaused.jsonwas "cleared" by an agent deleting it - a guard being routed around, which this design makes pointless rather than forbidden. - Dollars an hour: a hard stop that reopens by itself. While the last
hour's spend plus this request's worst case is over
max_dollars_per_hour, nothing goes out, from any process. It reopens once enough of that spend is more than an hour old - never by a human. This is a RATE guard, not a budget: it bounds how fast money can go out, not how much, ever - see below for why there is no lifetime cap here. - There is no lifetime cap. Removed 2026-09-22, the user's own ruling
("not this random $4 number") on an earlier version of this
file that refused once
lifetime_usdcrossed a self-imposedJEV_LIFETIME_CAP_USD: a made-up ceiling on the account's own money is not this project's number to invent. [Status::line] shows what has been spent, plainly, with no denominator. - Out of credit (the vendor said so) is the one on-disk flag,
out-of-credit.json, because it is a fact about the account that no amount of waiting changes - the only hard stop on total spend now; [Ledger::credit_restored] clears it once the account has been topped up.
Holds. A hold counts as a question at the moment it was admitted and
as its worst-case cost until the request's real cost is appended against
it ([Ledger::settle]) or it is released unanswered ([Ledger::release]).
A process killed mid-request leaves its hold unsettled, which keeps
counting its worst case - a few hundredths of a cent - forever: the
conservative direction.
Why the running total is read from the file every time, not cached. Two files that must agree - a fast running total and the log it summarises
- are a state that can drift, and a cache that has drifted toward "we have spent less than we have" is exactly the failure this exists to prevent. Summing a file of a few thousand lines costs low single-digit milliseconds, which is nothing next to the hundred-plus milliseconds the network call itself takes.
81use serde::{Deserialize, Serialize};
Seconds in the windows the guards watch.
What crashed-before-the-ledger-existed spend is booked as, absent a
vendor usage endpoint to read the true figure from (digest §7b/§10: no
such endpoint exists, re-confirmed 2026-09-22 by probing api.typesafe.ai
directly and reading docs.typesafe.ai's own index - see
../README.md's "The lifetime ledger" section).
Corrected 2026-09-22, from the user's own TypeSafe dashboard reading
(last 7 days, $0.1876 total spend - the account's whole history, since it
is only a couple of days old): that figure minus this ledger's own real,
measured spend at the same moment ($0.0359, from its own recorded token
counts) leaves $0.1517 of real spend that predates the ledger - not the
$0.25 guessed here on 2026-09-20. The guess was about 65% too high. This
is the same move as correcting Failure::OutOfCredit's classification
when a real example turns up (../CLAUDE.md): a documented guess, fixed
once better data exists, in the same commit as the measurement. It is
still a guess, not a vendor-confirmed figure - the dashboard itself is
marked "*Estimated" - which is why the lifetime total stays flagged
"estimated" in [Status::line] rather than being trusted exactly.
Now, as seconds since the epoch. A plain f64 rather than a formatted
timestamp: nothing here does timezone arithmetic, and no time/chrono
crate is in this project's dependency graph to spend on formatting one.
Everything below takes now as an argument instead of calling this, so a
test can hand it any clock it likes.
One request's real cost, as the ledger keeps it. Serialize/Deserialize
are the wire format: one JSON object per line.
Seconds since the epoch.
123 pub time: f64,
Which binary spent this - native, zbanks, jevprobe, ... - so a
ledger shared by all of them still says who.
True for the one opening line seeded when the ledger is created, and for nothing else - every other line is the API's own reported usage.
The [Hold] this settles. Lines written before holds existed
(2026-09-21) have none, and each of them is a question of its own.
A question admitted and not yet answered, written in the same lock as the check that admitted it and before the request goes out.
Unique across processes: pid, clock, and a per-process counter.
153 pub hold_id: String,
The most this request could cost; counted as spent until settled.
A line of the file. A hold is told apart by its hold_id, which no
[Entry] has; every line written before holds existed is an Entry.
The two RATE limits a request is admitted against. There is no lifetime
cap here - removed 2026-09-22, the user's own ruling ("not this
random $4 number"): a self-imposed ceiling on an account's own money is a
number this project has no standing to invent. The only hard stop on
total spend is the vendor's own out-of-credit answer (Refusal::OutOfCredit),
because that is a FACT about the account, not a guess.
The question bucket's size; each token returns a minute after it is spent.
177 pub max_questions_per_minute: u32,
182impl Guards {
JEV_MAX_QUESTIONS_PER_MINUTE (30) and JEV_MAX_DOLLARS_PER_HOUR
($0.25).
What the ledger says about the recent past, read in one pass: everything a guard decides from.
When each question of the last minute was admitted, oldest first.
203 pub minute: Vec<f64>,
Every amount booked in the last hour and when, oldest first.
205 pub hour: Vec<(f64, f64)>,
Holds not yet settled or released: requests in flight, or ones whose process died mid-request.
A guard that clears itself: in retry_in seconds the same request
would be admitted, if nothing else is asked meanwhile. The caller goes
on without an answer; nobody has to do anything.
217 Throttled { why: String, retry_in: f64 },
The vendor said the account has no credit. Never clears by waiting -
the only hard stop on total spend (there is no self-imposed lifetime
cap; see Guards's own doc comment).
221 OutOfCredit(String),
The ledger could not be read or written, so nothing is admitted: a guard that cannot see the spend fails closed.
227impl std::fmt::Display for Refusal { 228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 229 match self { 230 Self::Throttled { why, retry_in } => write!(f, "{why}; clears in {retry_in:.0}s"), 231 Self::OutOfCredit(why) => f.write_str(why), 232 Self::Ledger(why) => write!(f, "the spend ledger: {why}"), 233 } 234 } 235} 236 237impl Window {
Read the lines, as of now. Pure: the tests hand it any clock.
239 fn of(lines: Vec<Line>, now: f64) -> Self { 240 let mut holds: HashMap<String, Hold> = HashMap::new(); 241 let mut spends = Vec::new(); 242 for line in lines { 243 match line { 244 Line::Hold(hold) => { 245 holds.insert(hold.hold_id.clone(), hold); 246 } 247 Line::Spend(entry) => spends.push(entry), 248 } 249 } 250 let mut window = Self::default(); 251 let mut questions = Vec::new(); 252 let mut booked = Vec::new(); 253 for entry in spends { 254 window.lifetime_usd += entry.cost_usd; 255 if entry.estimated { 256 // Booked spend from before the ledger existed, not activity: 257 // the moment it is written must not look like a burst. 258 continue; 259 } 260 match entry.hold.as_ref().and_then(|id| holds.remove(id)) { 261 // The question was asked when it was admitted. 262 Some(hold) => questions.push(hold.time), 263 // A line from before holds, or one whose hold was a torn write. 264 None => questions.push(entry.time), 265 } 266 booked.push((entry.time, entry.cost_usd)); 267 } 268 for hold in holds.into_values() { 269 window.unsettled += 1; 270 window.lifetime_usd += hold.held_usd; 271 questions.push(hold.time); 272 booked.push((hold.time, hold.held_usd)); 273 } 274 window.minute = questions.into_iter().filter(|t| now - t < MINUTE).collect(); 275 window.minute.sort_by(f64::total_cmp); 276 window.hour = booked.into_iter().filter(|(t, _)| now - t < HOUR).collect(); 277 window.hour.sort_by(|a, b| a.0.total_cmp(&b.0)); 278 window 279 }
281 pub fn dollars_last_hour(&self) -> f64 { 282 // `.max(0.0)` is not a clamp against a real negative - every addend is 283 // a non-negative cost, so the sum cannot be negative - it is here 284 // only to normalise the `-0.0` that `Iterator::sum` returns for an 285 // EMPTY iterator (Rust's chosen additive identity for floats is 286 // negative zero, so no recent spend sums to `-0.0`, not `0.0`), which 287 // otherwise survives to `Status::line()`'s `${:.4}` and prints as 288 // "$-0.0000" - a real, reproduced display bug (2026-09-22), not a 289 // hypothetical one. 290 self.hour.iter().map(|(_, usd)| usd).sum::<f64>().max(0.0) 291 }
Whether a request that could cost up to worst_case_usd may go out
at now: the question bucket, then the hour. No lifetime check - the
caller (Ledger::admit) has already refused before this runs if the
vendor said the account is out of credit, which is the only hard stop
on total spend.
298 pub fn verdict(&self, guards: &Guards, now: f64, worst_case_usd: f64) -> Result<(), Refusal> { 299 if let Some(retry_in) = self.bucket_empty_for(guards, now) { 300 return Err(Refusal::Throttled { 301 why: format!( 302 "{} questions in the last minute (bucket of {})", 303 self.minute.len(), 304 guards.max_questions_per_minute 305 ), 306 retry_in, 307 }); 308 } 309 if let Some(retry_in) = self.hour_closed_for(guards, now, worst_case_usd) { 310 return Err(Refusal::Throttled { 311 why: format!( 312 "${:.4} spent in the last hour (limit ${:.2})", 313 self.dollars_last_hour(), 314 guards.max_dollars_per_hour 315 ), 316 retry_in, 317 }); 318 } 319 Ok(()) 320 }
Seconds until the question bucket has a token again, or None if it
has one now. The token spent at minute[len - max] is the one whose
return brings the count below the limit.
325 pub fn bucket_empty_for(&self, guards: &Guards, now: f64) -> Option<f64> { 326 let max = guards.max_questions_per_minute as usize; 327 if self.minute.len() < max { 328 return None; 329 } 330 if max == 0 { 331 return Some(f64::INFINITY); 332 } 333 Some((self.minute[self.minute.len() - max] + MINUTE - now).max(0.0)) 334 }
Seconds until the hour's spend has slid down far enough to admit a
request costing worst_case_usd, or None if it would be admitted now.
338 pub fn hour_closed_for(&self, guards: &Guards, now: f64, worst_case_usd: f64) -> Option<f64> { 339 let mut spent = self.dollars_last_hour(); 340 if spent + worst_case_usd <= guards.max_dollars_per_hour { 341 return None; 342 } 343 for (time, usd) in &self.hour { 344 spent -= usd; 345 if spent + worst_case_usd <= guards.max_dollars_per_hour { 346 return Some((time + HOUR - now).max(0.0)); 347 } 348 } 349 // Bigger than an hour's whole allowance on its own: never admitted. 350 Some(f64::INFINITY) 351 } 352}
Everything the Bot panel and the budget MCP tool show, as of now.
Spent so far, lifetime - plainly, with no cap and no denominator (the
user's own ruling, 2026-09-22: no self-imposed number to show it
against). Booked from this ledger's own recorded token counts plus
one guessed opening line for whatever predates it - see
OPENING_ESTIMATE_USD's own doc comment for why, and why it stays
approximate.
Tokens in the question bucket: questions that could go out right now.
368 pub questions_available: u32,
Seconds until the next token returns, when the bucket is empty.
Seconds until an ordinary question would clear the hour breaker, when it is closed. "Ordinary" is a thousand-token question, about $0.00004.
The vendor's own word that the account is empty, if it has said so.
A thousand input tokens: what a typical question costs, for [Status].
381const TYPICAL_USD: f64 = 1_000.0 / 1e6 * jev_protocol::DOLLARS_PER_MTOK;
383impl Status {
One line for a panel: no cap, no denominator, just what has actually
been spent (the user's own ruling, 2026-09-22 - see Guards's own
doc comment) - marked "estimated" because it is booked from our own
recorded token counts plus a pre-ledger guess
(OPENING_ESTIMATE_USD's own doc comment), never a vendor-confirmed
number: TypeSafe exposes no usage/balance/credit endpoint to confirm
it against (../README.md / research/jev-api-digest.md §10,
checked directly 2026-09-22).
392 pub fn line(&self) -> String { 393 let g = &self.guards; 394 let mut line = format!( 395 "{}/{} questions in the last minute, ${:.4}/${:.2} in the last hour, ~${:.4} spent lifetime (estimated)", 396 self.questions_last_minute, 397 g.max_questions_per_minute, 398 self.dollars_last_hour, 399 g.max_dollars_per_hour, 400 self.lifetime_usd, 401 ); 402 if self.unsettled_holds > 0 { 403 line.push_str(&format!(", {} in flight", self.unsettled_holds)); 404 } 405 line 406 }
Whether questions are stopped by something waiting will not clear: the vendor's own out-of-credit answer, the only hard stop on total spend (there is no self-imposed lifetime cap).
What is stopping questions right now, in words, or None if nothing.
416 pub fn stopped(&self) -> Option<String> { 417 if let Some(no) = &self.out_of_credit { 418 return Some(format!("out of credit (API) - {} - top up, then Ledger::credit_restored", no.reason)); 419 } 420 if let Some(s) = self.hour_breaker_closed_for_s { 421 return Some(format!("hour breaker closed, reopens by itself in {s:.0}s; the bot picks its own goals")); 422 } 423 self.throttled_for_s.map(|s| { 424 format!("throttled: bucket empty, next question in {s:.0}s; the bot picks its own goals") 425 }) 426 } 427}
The vendor said the account has no credit.
The shared spend record. Cheap to construct - it is a directory path and nothing held open - so a caller reopens it whenever, rather than keeping one alive across a restart.
444impl Ledger {
$XDG_STATE_HOME/jev or ~/.local/state/jev, created if it does not
exist yet, with spend.jsonl seeded with the opening estimate if this
is the very first time anything here has opened it.
Open a ledger at a specific directory. [Self::open] is this with the
environment already resolved; tests point it at a private directory
instead of the real $XDG_STATE_HOME/jev, which every test in the
process shares.
463 pub fn path(&self) -> &Path { 464 &self.dir 465 } 466 467 fn spend_path(&self) -> PathBuf { 468 self.dir.join("spend.jsonl") 469 } 470 471 fn no_credit_path(&self) -> PathBuf { 472 self.dir.join("out-of-credit.json") 473 } 474 475 fn rate_limit_path(&self) -> PathBuf { 476 self.dir.join("last-rate-limit.json") 477 }
Write the one opening line, but only the first time this ledger is
ever created anywhere. create_new is what makes this race-free
across two processes starting at once: at most one of them wins the
create and the other sees AlreadyExists, never two opening lines.
483 fn seed_if_new(&self) -> Result<(), String> { 484 let path = self.spend_path(); 485 match OpenOptions::new().write(true).create_new(true).open(&path) { 486 Ok(mut file) => { 487 let seed = Entry { 488 time: now(), 489 binary: "ledger".to_owned(), 490 input_tokens: 0, 491 output_tokens: 0, 492 cost_usd: OPENING_ESTIMATE_USD, 493 request_id: None, 494 estimated: true, 495 reason: Some(OPENING_REASON.to_owned()), 496 hold: None, 497 }; 498 writeln!(file, "{}", to_line(&seed)?).map_err(|e| format!("seeding {}: {e}", path.display())) 499 } 500 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), 501 Err(e) => Err(format!("opening {}: {e}", path.display())), 502 } 503 }
The recent past as of now, read fresh from the file.
What the guards say as of now.
511 pub fn status(&self, now: f64, guards: Guards) -> Result<Status, String> { 512 let window = self.window(now)?; 513 let questions = window.minute.len() as u32; 514 Ok(Status { 515 now, 516 guards, 517 lifetime_usd: window.lifetime_usd, 518 questions_last_minute: questions, 519 questions_available: guards.max_questions_per_minute.saturating_sub(questions), 520 throttled_for_s: window.bucket_empty_for(&guards, now), 521 dollars_last_hour: window.dollars_last_hour(), 522 hour_breaker_closed_for_s: window.hour_closed_for(&guards, now, TYPICAL_USD), 523 unsettled_holds: window.unsettled, 524 out_of_credit: self.out_of_credit()?, 525 }) 526 }
Check a request against every guard and, if it passes, write its hold
- all under one lock, so no other process can be admitted between this
one's check and its hold.
nowis the caller's clock.
531 pub fn admit( 532 &self, 533 now: f64, 534 guards: &Guards, 535 worst_case_usd: f64, 536 binary: &str, 537 reason: &str, 538 ) -> Result<Hold, Refusal> { 539 if let Some(no) = self.out_of_credit().map_err(Refusal::Ledger)? { 540 return Err(Refusal::OutOfCredit(format!("the vendor reported no credit: {}", no.reason))); 541 } 542 with_lock(&self.spend_path(), |file| { 543 let window = Window::of(read_lines(file)?, now); 544 if let Err(refusal) = window.verdict(guards, now, worst_case_usd) { 545 return Ok(Err(refusal)); 546 } 547 let hold = Hold { 548 time: now, 549 binary: binary.to_owned(), 550 hold_id: hold_id(now), 551 held_usd: worst_case_usd, 552 reason: reason.to_owned(), 553 }; 554 file.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?; 555 writeln!(file, "{}", to_line(&hold)?).map_err(|e| e.to_string())?; 556 Ok(Ok(hold)) 557 }) 558 .map_err(Refusal::Ledger)? 559 }
Record what an admitted request actually cost, once it is known.
An admitted request that got no answer, so cost nothing: it still counts as a question at the moment it was admitted.
569 pub fn release(&self, hold: &Hold, now: f64, why: &str) -> Result<(), String> { 570 self.settle( 571 hold, 572 Entry { 573 time: now, 574 binary: hold.binary.clone(), 575 input_tokens: 0, 576 output_tokens: 0, 577 cost_usd: 0.0, 578 request_id: None, 579 estimated: false, 580 reason: Some(format!("not answered: {why}")), 581 hold: None, 582 }, 583 ) 584 }
Append one spend line as it is.
Whether the vendor has said the account has no credit.
595 pub fn out_of_credit(&self) -> Result<Option<NoCredit>, String> { 596 let path = self.no_credit_path(); 597 match std::fs::read_to_string(&path) { 598 Ok(text) => serde_json::from_str(&text) 599 .map(Some) 600 .map_err(|e| format!("reading {}: {e}", path.display())), 601 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), 602 Err(e) => Err(format!("reading {}: {e}", path.display())), 603 } 604 }
Record the vendor's out-of-credit answer. The first one's reason and time are kept.
608 pub fn mark_out_of_credit(&self, reason: impl Into<String>) -> Result<(), String> { 609 if self.out_of_credit()?.is_some() { 610 return Ok(()); 611 } 612 let no = NoCredit { reason: reason.into(), since: now() }; 613 let path = self.no_credit_path(); 614 let text = serde_json::to_string(&no).map_err(|e| e.to_string())?; 615 std::fs::write(&path, text).map_err(|e| format!("writing {}: {e}", path.display())) 616 }
The account has been topped up. Not an error if it was never marked.
Remember the most recent rate-limit snapshot any process has seen, so
the budget MCP tool can show it without reaching into whichever
process happened to make the last request. Best-effort: a failure
here must never fail the request that triggered it, so callers should
log and carry on rather than propagate this one.
638 pub fn last_rate_limit(&self) -> Option<crate::RateLimit> { 639 std::fs::read_to_string(self.rate_limit_path()) 640 .ok() 641 .and_then(|text| serde_json::from_str(&text).ok()) 642 } 643} 644 645fn hold_id(now: f64) -> String { 646 static NEXT: AtomicU64 = AtomicU64::new(0); 647 format!("{}-{}-{}", std::process::id(), (now * 1e6) as u64, NEXT.fetch_add(1, Ordering::Relaxed)) 648} 649 650fn to_line(value: &impl Serialize) -> Result<String, String> { 651 serde_json::to_string(value).map_err(|e| format!("serializing a ledger line: {e}")) 652}
Every well-formed line. A line that fails to parse - a torn write from a process killed mid-append - is skipped rather than failing the whole read: the ledger's job is to bound spend, and one unreadable line must not make every future request refuse to send by making the total unreadable.
659fn read_lines(file: &mut File) -> Result<Vec<Line>, String> { 660 file.seek(SeekFrom::Start(0)).map_err(|e| e.to_string())?; 661 let mut text = String::new(); 662 file.read_to_string(&mut text).map_err(|e| e.to_string())?; 663 Ok(text 664 .lines() 665 .filter(|line| !line.trim().is_empty()) 666 .filter_map(|line| match serde_json::from_str(line) { 667 Ok(entry) => Some(entry), 668 Err(e) => { 669 eprintln!("jev ledger: skipping an unreadable line: {e}"); 670 None 671 } 672 }) 673 .collect()) 674}
Hold an exclusive flock on path for the duration of f. The lock is
released when file drops at the end of this function, which is also
what happens if the process is killed with the lock held - flock is
tied to the open file descriptor, not written into the file itself, so
there is nothing on disk for a crash to leave stuck.
681fn with_lock<T>(path: &Path, f: impl FnOnce(&mut File) -> Result<T, String>) -> Result<T, String> { 682 let mut file = OpenOptions::new() 683 .create(true) 684 .read(true) 685 .write(true) 686 .open(path) 687 .map_err(|e| format!("opening {}: {e}", path.display()))?; 688 // SAFETY: `fd` is a valid, open file descriptor owned by `file` for the 689 // whole call; `LOCK_EX` blocks until any other holder releases it rather 690 // than failing, so there is no error path here to leave unlocked. 691 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 { 692 return Err(format!("locking {}: {}", path.display(), std::io::Error::last_os_error())); 693 } 694 let result = f(&mut file); 695 // SAFETY: same fd, still open; unlocking a lock we hold cannot fail in a 696 // way worth propagating, and the fd closing at the end of this function 697 // would release it anyway. 698 let _ = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) }; 699 result 700}
702fn state_dir() -> Result<PathBuf, String> { 703 if let Ok(xdg) = std::env::var("XDG_STATE_HOME") 704 && !xdg.trim().is_empty() 705 { 706 return Ok(PathBuf::from(xdg).join("jev")); 707 } 708 let home = std::env::var("HOME") 709 .map_err(|_| "neither XDG_STATE_HOME nor HOME is set".to_owned())?; 710 Ok(PathBuf::from(home).join(".local/state/jev")) 711} 712 713#[cfg(test)] 714mod tests { 715 use super::*;
Every test gets its own directory rather than the real
$XDG_STATE_HOME/jev - the ledger is process-global by design, and
buck2 runs #[test]s concurrently in one process, so sharing the
real path (or mutating the environment, which every thread shares)
would make one test's spend count toward another's assertions.
729 fn uniqueish() -> String { 730 static NEXT: AtomicU64 = AtomicU64::new(0); 731 format!("{}-{}", now(), NEXT.fetch_add(1, Ordering::Relaxed)) 732 } 733 734 fn spend(time: f64, cost_usd: f64) -> Entry { 735 Entry { 736 time, 737 binary: "test".into(), 738 input_tokens: 0, 739 output_tokens: 0, 740 cost_usd, 741 request_id: None, 742 estimated: false, 743 reason: None, 744 hold: None, 745 } 746 } 747 748 const GUARDS: Guards = Guards { max_questions_per_minute: 30, max_dollars_per_hour: 0.25 };
The fake clock starts two hours after the ledger's own seed line, so the opening estimate is out of every window, on a whole second so that its times survive the trip through JSON exactly.
757 fn ask(ledger: &Ledger, at: f64) -> Result<Hold, Refusal> { 758 ledger.admit(at, &GUARDS, 0.00005, "test", "a test question") 759 } 760 761 #[test] 762 fn a_new_ledger_seeds_the_opening_estimate_once() { 763 let ledger = sandbox(); 764 let lifetime = ledger.window(t0()).expect("window").lifetime_usd; 765 assert!((lifetime - OPENING_ESTIMATE_USD).abs() < 1e-9); 766 Ledger::at(ledger.path().to_path_buf()).expect("opening the same ledger again"); 767 let lifetime = ledger.window(t0()).expect("window").lifetime_usd; 768 assert!((lifetime - OPENING_ESTIMATE_USD).abs() < 1e-9, "seeded twice"); 769 } 770 771 #[test] 772 fn only_recent_lines_count_toward_the_windows() { 773 let ledger = sandbox(); 774 let at = t0(); 775 ledger.append(&spend(at - 5_400.0, 10.0)).expect("appending"); 776 ledger.append(&spend(at - 600.0, 0.05)).expect("appending"); 777 let window = ledger.window(at).expect("window"); 778 assert_eq!(window.minute.len(), 0); 779 assert!((window.dollars_last_hour() - 0.05).abs() < 1e-9, "the older line must not count"); 780 assert!(window.lifetime_usd > 10.0, "but it is still spent"); 781 }
Real, observed 2026-09-22 (budget, live window): with nothing spent
in the last hour, dollars_last_hour() is a sum over zero elements,
and Rust's chosen additive identity for floats is negative zero - so
the unclamped sum was -0.0, and Status::line() printed
"$-0.0000/$0.25 in the last hour". -0.0 == 0.0 is true, which is
exactly why an assertion against 0.0 alone would not have caught
this - the sign bit has to be checked directly.
790 #[test] 791 fn an_empty_hour_is_positive_zero_not_negative_zero() { 792 let ledger = sandbox(); 793 let dollars = ledger.window(t0()).expect("window").dollars_last_hour(); 794 assert_eq!(dollars, 0.0); 795 assert!(!dollars.is_sign_negative(), "{dollars} must not print as \"-0.0000\""); 796 assert_eq!(format!("{dollars:.4}"), "0.0000"); 797 }
The trip of 2026-09-21, replayed: more questions than the bucket holds are refused, nothing is written that anyone has to delete, and asking works again as the minute slides.
802 #[test] 803 fn the_question_bucket_throttles_then_heals_by_itself() { 804 let ledger = sandbox(); 805 let at = t0(); 806 for i in 0..30 { 807 ask(&ledger, at + f64::from(i)).expect("the first thirty are admitted"); 808 } 809 let refused = ask(&ledger, at + 30.0).expect_err("the thirty-first is not"); 810 let Refusal::Throttled { retry_in, .. } = refused else { panic!("{refused:?}") }; 811 // The oldest token (spent at `at`) comes back at `at + 60`. 812 assert!((retry_in - 30.0).abs() < 1e-6, "{retry_in}"); 813 assert!(ask(&ledger, at + 59.9).is_err(), "not before the minute is up"); 814 let status = ledger.status(at + 45.0, GUARDS).expect("status"); 815 assert_eq!(status.questions_available, 0); 816 assert!(status.stopped().expect("throttled").starts_with("throttled"), "{status:?}"); 817 // No pause file, nothing to clear: the window slides and it heals. 818 let files: Vec<_> = std::fs::read_dir(ledger.path()) 819 .expect("dir") 820 .map(|e| e.expect("entry").file_name()) 821 .collect(); 822 assert_eq!(files, vec![std::ffi::OsString::from("spend.jsonl")]); 823 ask(&ledger, at + 60.0).expect("a minute on, the first token is back"); 824 assert!(ask(&ledger, at + 60.5).is_err(), "and only the one"); 825 ask(&ledger, at + 61.0).expect("the second comes back a second later"); 826 let later = ledger.status(at + 200.0, GUARDS).expect("status"); 827 assert_eq!(later.questions_available, 30); 828 assert!(later.stopped().is_none(), "{later:?}"); 829 }
831 #[test] 832 fn the_hour_breaker_is_a_hard_stop_that_reopens_as_the_hour_slides() { 833 let ledger = sandbox(); 834 let at = t0(); 835 ledger.append(&spend(at, 0.20)).expect("appending"); 836 ledger.append(&spend(at + 600.0, 0.05)).expect("appending"); 837 let refused = ask(&ledger, at + 700.0).expect_err("$0.25 already spent this hour"); 838 let Refusal::Throttled { retry_in, why } = refused else { panic!("{refused:?}") }; 839 assert!(why.contains("last hour"), "{why}"); 840 // Only once the $0.20 line is an hour old does a question fit again. 841 assert!((retry_in - (HOUR - 700.0)).abs() < 1e-6, "{retry_in}"); 842 assert!(ask(&ledger, at + HOUR - 1.0).is_err()); 843 ask(&ledger, at + HOUR + 1.0).expect("reopened by itself"); 844 }
There is no lifetime cap (removed 2026-09-22, the user's own ruling): spending far past what the old $4 constant would have allowed is admitted fine, as long as the rate guards are clear.
849 #[test] 850 fn spending_past_the_old_four_dollar_figure_is_not_refused() { 851 let ledger = sandbox(); 852 let at = t0(); 853 ledger.append(&spend(at - 3.0 * HOUR, 40.0)).expect("appending"); 854 ask(&ledger, at).expect("no lifetime cap to hit"); 855 let window = ledger.window(at).expect("window"); 856 assert!(window.lifetime_usd > 40.0, "{}", window.lifetime_usd); 857 }
859 #[test] 860 fn a_hold_counts_its_worst_case_until_settled_then_the_real_cost() { 861 let ledger = sandbox(); 862 let at = t0(); 863 let hold = ledger.admit(at, &GUARDS, 0.01, "test", "why").expect("admitted"); 864 let window = ledger.window(at).expect("window"); 865 assert_eq!(window.unsettled, 1); 866 assert!((window.dollars_last_hour() - 0.01).abs() < 1e-12); 867 ledger.settle(&hold, spend(at + 0.2, 0.00002)).expect("settling"); 868 let window = ledger.window(at + 1.0).expect("window"); 869 assert_eq!(window.unsettled, 0); 870 assert_eq!(window.minute, vec![at], "one question, when it was admitted"); 871 assert!((window.dollars_last_hour() - 0.00002).abs() < 1e-12); 872 let other = ask(&ledger, at + 2.0).expect("admitted"); 873 ledger.release(&other, at + 3.0, "timed out").expect("releasing"); 874 let window = ledger.window(at + 4.0).expect("window"); 875 assert_eq!(window.minute.len(), 2, "an unanswered question is still a question"); 876 assert_eq!(window.unsettled, 0); 877 }
Two processes are two Ledgers on one directory. Asking from both at
once, hard, admits exactly the bucket's worth between them: the check
and the hold are one locked step, so neither slips in between the
other's.
883 #[test] 884 fn two_processes_share_one_bucket() { 885 let first = sandbox(); 886 let second = Ledger::at(first.path().to_path_buf()).expect("the second process's handle"); 887 let at = t0(); 888 let threads: Vec<_> = [first, second] 889 .into_iter() 890 .map(|ledger| std::thread::spawn(move || (0..40).filter(|_| ask(&ledger, at).is_ok()).count())) 891 .collect(); 892 let admitted: usize = threads.into_iter().map(|t| t.join().expect("thread")).sum(); 893 assert_eq!(admitted, 30); 894 }
896 #[test] 897 fn out_of_credit_holds_until_credit_is_restored() { 898 let ledger = sandbox(); 899 ledger.mark_out_of_credit("first").expect("marking"); 900 ledger.mark_out_of_credit("second").expect("marking again"); 901 assert_eq!(ledger.out_of_credit().expect("reading").expect("marked").reason, "first"); 902 assert!(matches!(ask(&ledger, t0()), Err(Refusal::OutOfCredit(_)))); 903 ledger.credit_restored().expect("restoring"); 904 ask(&ledger, t0()).expect("asking again"); 905 ledger.credit_restored().expect("restoring twice is not an error"); 906 } 907 908 #[test] 909 fn lines_from_before_holds_still_read() { 910 let old = r#"{"time":1790024291.15,"binary":"native","input_tokens":521,"output_tokens":31,"cost_usd":0.000021882,"request_id":"req_x","estimated":false,"reason":"zbanks goal choice"}"#; 911 let line: Line = serde_json::from_str(old).expect("an old line"); 912 assert!(matches!(line, Line::Spend(Entry { hold: None, .. }))); 913 } 914}