jevcrates.git / jev-http / src / ledger.rs
ledger.rsannotatedledger.rssource914 lines · 40.2 KB · raw

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_minute tokens; 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's paused.json was "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_usd crossed a self-imposed JEV_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.
73use std::collections::HashMap;
74use std::fs::{File, OpenOptions};
75use std::io::{Read, Seek, SeekFrom, Write};
76use std::os::unix::io::AsRawFd;
77use std::path::{Path, PathBuf};
78use std::sync::atomic::{AtomicU64, Ordering};
79use std::time::{SystemTime, UNIX_EPOCH};
81use serde::{Deserialize, Serialize};

Seconds in the windows the guards watch.

84pub const MINUTE: f64 = 60.0;
85pub const HOUR: f64 = 3600.0;

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.

105const OPENING_ESTIMATE_USD: f64 = 0.1517;
106const OPENING_REASON: &str =
107    "spend before the ledger existed, 2026-09-20 - revised 2026-09-22 against the TypeSafe dashboard's own last-7-day reading ($0.1876) minus this ledger's real spend at that moment";

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.

114pub fn now() -> f64 {
115    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0)
116}

One request's real cost, as the ledger keeps it. Serialize/Deserialize are the wire format: one JSON object per line.

120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Entry {

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.

126    pub binary: String,
127    #[serde(default)]
128    pub input_tokens: u64,
129    #[serde(default)]
130    pub output_tokens: u64,
131    pub cost_usd: f64,
132    #[serde(default)]
133    pub request_id: Option<String>,

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.

136    #[serde(default)]
137    pub estimated: bool,
138    #[serde(default)]
139    pub reason: Option<String>,

The [Hold] this settles. Lines written before holds existed (2026-09-21) have none, and each of them is a question of its own.

142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub hold: Option<String>,
144}

A question admitted and not yet answered, written in the same lock as the check that admitted it and before the request goes out.

148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct Hold {
150    pub time: f64,
151    pub binary: String,

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.

155    pub held_usd: f64,
156    pub reason: String,
157}

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.

161#[derive(Deserialize)]
162#[serde(untagged)]
163enum Line {
164    Hold(Hold),
165    Spend(Entry),
166}

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.

174#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
175pub struct Guards {

The question bucket's size; each token returns a minute after it is spent.

177    pub max_questions_per_minute: u32,

Hard while over, reopens as the hour slides.

179    pub max_dollars_per_hour: f64,
180}
182impl Guards {

JEV_MAX_QUESTIONS_PER_MINUTE (30) and JEV_MAX_DOLLARS_PER_HOUR ($0.25).

185    pub fn from_env() -> Self {
186        Self {
187            max_questions_per_minute: env_or("JEV_MAX_QUESTIONS_PER_MINUTE", 30),
188            max_dollars_per_hour: env_or("JEV_MAX_DOLLARS_PER_HOUR", 0.25),
189        }
190    }
191}
193fn env_or<T: std::str::FromStr>(name: &str, default: T) -> T {
194    std::env::var(name).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(default)
195}

What the ledger says about the recent past, read in one pass: everything a guard decides from.

199#[derive(Debug, Clone, Default)]
200pub struct Window {
201    pub lifetime_usd: f64,

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.

208    pub unsettled: u32,
209}

Why a request was not admitted.

212#[derive(Debug, Clone, PartialEq)]
213pub enum Refusal {

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.

224    Ledger(String),
225}
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.

355#[derive(Debug, Clone, Serialize)]
356pub struct Status {
357    pub now: f64,
358    pub guards: Guards,

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.

365    pub lifetime_usd: f64,
366    pub questions_last_minute: u32,

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.

370    pub throttled_for_s: Option<f64>,
371    pub dollars_last_hour: f64,

Seconds until an ordinary question would clear the hour breaker, when it is closed. "Ordinary" is a thousand-token question, about $0.00004.

374    pub hour_breaker_closed_for_s: Option<f64>,
375    pub unsettled_holds: u32,

The vendor's own word that the account is empty, if it has said so.

377    pub out_of_credit: Option<NoCredit>,
378}

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).

411    pub fn stopped_for_good(&self) -> bool {
412        self.out_of_credit.is_some()
413    }

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.

430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct NoCredit {
432    pub reason: String,
433    pub since: f64,
434}

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.

439#[derive(Debug, Clone)]
440pub struct Ledger {
441    dir: PathBuf,
442}
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.

448    pub fn open() -> Result<Self, String> {
449        Self::at(state_dir()?)
450    }

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.

456    pub fn at(dir: PathBuf) -> Result<Self, String> {
457        std::fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
458        let ledger = Self { dir };
459        ledger.seed_if_new()?;
460        Ok(ledger)
461    }
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.

506    pub fn window(&self, now: f64) -> Result<Window, String> {
507        with_lock(&self.spend_path(), |file| Ok(Window::of(read_lines(file)?, now)))
508    }

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. now is 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.

562    pub fn settle(&self, hold: &Hold, mut entry: Entry) -> Result<(), String> {
563        entry.hold = Some(hold.hold_id.clone());
564        self.append(&entry)
565    }

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.

587    pub fn append(&self, entry: &Entry) -> Result<(), String> {
588        with_lock(&self.spend_path(), |file| {
589            file.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?;
590            writeln!(file, "{}", to_line(entry)?).map_err(|e| e.to_string())
591        })
592    }

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.

619    pub fn credit_restored(&self) -> Result<(), String> {
620        match std::fs::remove_file(self.no_credit_path()) {
621            Ok(()) => Ok(()),
622            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
623            Err(e) => Err(e.to_string()),
624        }
625    }

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.

632    pub fn note_rate_limit(&self, snapshot: &crate::RateLimit) -> Result<(), String> {
633        let path = self.rate_limit_path();
634        let text = serde_json::to_string(snapshot).map_err(|e| e.to_string())?;
635        std::fs::write(&path, text).map_err(|e| format!("writing {}: {e}", path.display()))
636    }
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.

722    fn sandbox() -> Ledger {
723        let dir = std::env::temp_dir()
724            .join(format!("jev-ledger-test-{}", std::process::id()))
725            .join(uniqueish());
726        Ledger::at(dir).expect("opening a fresh ledger")
727    }
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.

753    fn t0() -> f64 {
754        (now() + 2.0 * HOUR).floor()
755    }
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}