jevcrates.git / jev-http / src / ledger.rs
ledger.rsannotatedledger.rssource914 lines · 40.2 KB · raw
1//! What every binary that can spend has ever spent, on disk, so that no
2//! amount of restarting, crashing or running two of them at once can lose
3//! track of the total.
4//!
5//! **Why a file and not a counter in each process.** jevsnes's `native`
6//! and `jevprobe`, jevhooks, lmjtfy's eval and anything else that builds a
7//! [`crate::Jev`] are separate processes with no shared memory. A total kept
8//! in one process's memory only sees that process's own spend; two of them
9//! running at once could each think the account had spent nothing so far.
10//! The file is what makes "lifetime" mean the account, not the process.
11//!
12//! **Why `flock` and not a database.** One file, appended to, read
13//! occasionally, by at most a handful of processes that mostly are not
14//! actually running at the same moment - a lock file is the whole mechanism a
15//! database would give here, without a dependency that cannot compile for
16//! nothing (this crate is already the host-only one that opens sockets and
17//! files at all; see its README). `flock(2)`'s lock is released
18//! when the file descriptor closes, including when the process is killed, so
19//! a crash mid-write cannot leave the ledger wedged - which is the property
20//! that matters more than losing one torn write to a crash nobody could have
21//! flushed cleanly anyway.
22//!
23//! **The guards, and which of them a human ever has to touch.** Every request
24//! is admitted by [`Ledger::admit`], which reads the file, decides, and - in
25//! the same `flock` - appends a [`Hold`] for the request it admitted, so two
26//! processes admitting at the same moment are serialised on the lock and the
27//! second one sees the first one's question. Nothing about the guards lives
28//! in any process's memory; the file IS the bucket.
29//!
30//! - **Questions a minute: a throttle, never a pause.** The bucket holds
31//!   `max_questions_per_minute` tokens; a question spends one and it comes back
32//!   exactly a minute later, which is the sliding window read straight off the
33//!   ledger's recent lines. An empty bucket refuses with
34//!   [`Refusal::Throttled`] and how long until the next token returns; the
35//!   caller keeps playing without asking (the bot takes its own pick), and the
36//!   bucket refills as the window slides. There is no file to delete, because
37//!   there is no state but the ledger: on 2026-09-21 the old breaker's
38//!   `paused.json` was "cleared" by an agent deleting it - a guard being routed
39//!   around, which this design makes pointless rather than forbidden.
40//! - **Dollars an hour: a hard stop that reopens by itself.** While the last
41//!   hour's spend plus this request's worst case is over
42//!   `max_dollars_per_hour`, nothing goes out, from any process. It reopens
43//!   once enough of that spend is more than an hour old - never by a human.
44//!   This is a RATE guard, not a budget: it bounds how fast money can go out,
45//!   not how much, ever - see below for why there is no lifetime cap here.
46//! - **There is no lifetime cap.** Removed 2026-09-22, the user's own ruling
47//!   ("not this random $4 number") on an earlier version of this
48//!   file that refused once `lifetime_usd` crossed a self-imposed
49//!   `JEV_LIFETIME_CAP_USD`: a made-up ceiling on the account's own money is
50//!   not this project's number to invent. [`Status::line`] shows what has
51//!   been spent, plainly, with no denominator.
52//! - **Out of credit** (the vendor said so) is the one on-disk flag,
53//!   `out-of-credit.json`, because it is a fact about the account that no
54//!   amount of waiting changes - the only hard stop on total spend now;
55//!   [`Ledger::credit_restored`] clears it once the account has been topped
56//!   up.
57//!
58//! **Holds.** A hold counts as a question at the moment it was admitted and
59//! as its worst-case cost until the request's real cost is appended against
60//! it ([`Ledger::settle`]) or it is released unanswered ([`Ledger::release`]).
61//! A process killed mid-request leaves its hold unsettled, which keeps
62//! counting its worst case - a few hundredths of a cent - forever: the
63//! conservative direction.
64//!
65//! **Why the running total is read from the file every time, not cached.**
66//! Two files that must agree - a fast running total and the log it summarises
67//! - are a state that can drift, and a cache that has drifted toward
68//! "we have spent less than we have" is exactly the failure this exists to
69//! prevent. Summing a file of a few thousand lines costs low single-digit
70//! milliseconds, which is nothing next to the hundred-plus milliseconds the
71//! network call itself takes.
72
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};
80
81use serde::{Deserialize, Serialize};
82
83/// Seconds in the windows the guards watch.
84pub const MINUTE: f64 = 60.0;
85pub const HOUR: f64 = 3600.0;
86
87/// What crashed-before-the-ledger-existed spend is booked as, absent a
88/// vendor usage endpoint to read the true figure from (digest §7b/§10: no
89/// such endpoint exists, re-confirmed 2026-09-22 by probing `api.typesafe.ai`
90/// directly and reading `docs.typesafe.ai`'s own index - see
91/// `../README.md`'s "The lifetime ledger" section).
92///
93/// **Corrected 2026-09-22, from the user's own TypeSafe dashboard reading**
94/// (last 7 days, $0.1876 total spend - the account's whole history, since it
95/// is only a couple of days old): that figure minus this ledger's own real,
96/// measured spend at the same moment ($0.0359, from its own recorded token
97/// counts) leaves $0.1517 of real spend that predates the ledger - not the
98/// $0.25 guessed here on 2026-09-20. The guess was about 65% too high. This
99/// is the same move as correcting `Failure::OutOfCredit`'s classification
100/// when a real example turns up (`../CLAUDE.md`): a documented guess, fixed
101/// once better data exists, in the same commit as the measurement. It is
102/// still a guess, not a vendor-confirmed figure - the dashboard itself is
103/// marked "*Estimated" - which is why the lifetime total stays flagged
104/// "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";
108
109/// Now, as seconds since the epoch. A plain `f64` rather than a formatted
110/// timestamp: nothing here does timezone arithmetic, and no `time`/`chrono`
111/// crate is in this project's dependency graph to spend on formatting one.
112/// Everything below takes `now` as an argument instead of calling this, so a
113/// 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}
117
118/// One request's real cost, as the ledger keeps it. `Serialize`/`Deserialize`
119/// are the wire format: one JSON object per line.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Entry {
122    /// Seconds since the epoch.
123    pub time: f64,
124    /// Which binary spent this - `native`, `zbanks`, `jevprobe`, ... - so a
125    /// 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>,
134    /// True for the one opening line seeded when the ledger is created, and
135    /// 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>,
140    /// The [`Hold`] this settles. Lines written before holds existed
141    /// (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}
145
146/// A question admitted and not yet answered, written in the same lock as the
147/// 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,
152    /// Unique across processes: pid, clock, and a per-process counter.
153    pub hold_id: String,
154    /// The most this request could cost; counted as spent until settled.
155    pub held_usd: f64,
156    pub reason: String,
157}
158
159/// A line of the file. A hold is told apart by its `hold_id`, which no
160/// [`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}
167
168/// The two RATE limits a request is admitted against. There is no lifetime
169/// cap here - removed 2026-09-22, the user's own ruling ("not this
170/// random $4 number"): a self-imposed ceiling on an account's own money is a
171/// number this project has no standing to invent. The only hard stop on
172/// total spend is the vendor's own out-of-credit answer (`Refusal::OutOfCredit`),
173/// because that is a FACT about the account, not a guess.
174#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
175pub struct Guards {
176    /// The question bucket's size; each token returns a minute after it is spent.
177    pub max_questions_per_minute: u32,
178    /// Hard while over, reopens as the hour slides.
179    pub max_dollars_per_hour: f64,
180}
181
182impl Guards {
183    /// `JEV_MAX_QUESTIONS_PER_MINUTE` (30) and `JEV_MAX_DOLLARS_PER_HOUR`
184    /// ($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}
192
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}
196
197/// What the ledger says about the recent past, read in one pass: everything a
198/// guard decides from.
199#[derive(Debug, Clone, Default)]
200pub struct Window {
201    pub lifetime_usd: f64,
202    /// When each question of the last minute was admitted, oldest first.
203    pub minute: Vec<f64>,
204    /// Every amount booked in the last hour and when, oldest first.
205    pub hour: Vec<(f64, f64)>,
206    /// Holds not yet settled or released: requests in flight, or ones whose
207    /// process died mid-request.
208    pub unsettled: u32,
209}
210
211/// Why a request was not admitted.
212#[derive(Debug, Clone, PartialEq)]
213pub enum Refusal {
214    /// A guard that clears itself: in `retry_in` seconds the same request
215    /// would be admitted, if nothing else is asked meanwhile. The caller goes
216    /// on without an answer; nobody has to do anything.
217    Throttled { why: String, retry_in: f64 },
218    /// The vendor said the account has no credit. Never clears by waiting -
219    /// the only hard stop on total spend (there is no self-imposed lifetime
220    /// cap; see `Guards`'s own doc comment).
221    OutOfCredit(String),
222    /// The ledger could not be read or written, so nothing is admitted: a
223    /// guard that cannot see the spend fails closed.
224    Ledger(String),
225}
226
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 {
238    /// 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    }
280
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    }
292
293    /// Whether a request that could cost up to `worst_case_usd` may go out
294    /// at `now`: the question bucket, then the hour. No lifetime check - the
295    /// caller (`Ledger::admit`) has already refused before this runs if the
296    /// vendor said the account is out of credit, which is the only hard stop
297    /// 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    }
321
322    /// Seconds until the question bucket has a token again, or `None` if it
323    /// has one now. The token spent at `minute[len - max]` is the one whose
324    /// 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    }
335
336    /// Seconds until the hour's spend has slid down far enough to admit a
337    /// 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}
353
354/// 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,
359    /// Spent so far, lifetime - plainly, with no cap and no denominator (the
360    /// user's own ruling, 2026-09-22: no self-imposed number to show it
361    /// against). Booked from this ledger's own recorded token counts plus
362    /// one guessed opening line for whatever predates it - see
363    /// `OPENING_ESTIMATE_USD`'s own doc comment for why, and why it stays
364    /// approximate.
365    pub lifetime_usd: f64,
366    pub questions_last_minute: u32,
367    /// Tokens in the question bucket: questions that could go out right now.
368    pub questions_available: u32,
369    /// Seconds until the next token returns, when the bucket is empty.
370    pub throttled_for_s: Option<f64>,
371    pub dollars_last_hour: f64,
372    /// Seconds until an ordinary question would clear the hour breaker, when
373    /// 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,
376    /// The vendor's own word that the account is empty, if it has said so.
377    pub out_of_credit: Option<NoCredit>,
378}
379
380/// A thousand input tokens: what a typical question costs, for [`Status`].
381const TYPICAL_USD: f64 = 1_000.0 / 1e6 * jev_protocol::DOLLARS_PER_MTOK;
382
383impl Status {
384    /// One line for a panel: no cap, no denominator, just what has actually
385    /// been spent (the user's own ruling, 2026-09-22 - see `Guards`'s own
386    /// doc comment) - marked "estimated" because it is booked from our own
387    /// recorded token counts plus a pre-ledger guess
388    /// (`OPENING_ESTIMATE_USD`'s own doc comment), never a vendor-confirmed
389    /// number: TypeSafe exposes no usage/balance/credit endpoint to confirm
390    /// it against (`../README.md` / `research/jev-api-digest.md` §10,
391    /// 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    }
407
408    /// Whether questions are stopped by something waiting will not clear:
409    /// the vendor's own out-of-credit answer, the only hard stop on total
410    /// spend (there is no self-imposed lifetime cap).
411    pub fn stopped_for_good(&self) -> bool {
412        self.out_of_credit.is_some()
413    }
414
415    /// 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}
428
429/// 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}
435
436/// The shared spend record. Cheap to construct - it is a directory path and
437/// nothing held open - so a caller reopens it whenever, rather than keeping
438/// one alive across a restart.
439#[derive(Debug, Clone)]
440pub struct Ledger {
441    dir: PathBuf,
442}
443
444impl Ledger {
445    /// `$XDG_STATE_HOME/jev` or `~/.local/state/jev`, created if it does not
446    /// exist yet, with `spend.jsonl` seeded with the opening estimate if this
447    /// is the very first time anything here has opened it.
448    pub fn open() -> Result<Self, String> {
449        Self::at(state_dir()?)
450    }
451
452    /// Open a ledger at a specific directory. [`Self::open`] is this with the
453    /// environment already resolved; tests point it at a private directory
454    /// instead of the real `$XDG_STATE_HOME/jev`, which every test in the
455    /// 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    }
462
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    }
478
479    /// Write the one opening line, but only the first time this ledger is
480    /// ever created anywhere. `create_new` is what makes this race-free
481    /// across two processes starting at once: at most one of them wins the
482    /// 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    }
504
505    /// 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    }
509
510    /// 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    }
527
528    /// Check a request against every guard and, if it passes, write its hold
529    /// - all under one lock, so no other process can be admitted between this
530    /// 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    }
560
561    /// 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    }
566
567    /// An admitted request that got no answer, so cost nothing: it still
568    /// 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    }
585
586    /// 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    }
593
594    /// 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    }
605
606    /// Record the vendor's out-of-credit answer. The first one's reason and
607    /// 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    }
617
618    /// 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    }
626
627    /// Remember the most recent rate-limit snapshot any process has seen, so
628    /// the `budget` MCP tool can show it without reaching into whichever
629    /// process happened to make the last request. Best-effort: a failure
630    /// here must never fail the request that triggered it, so callers should
631    /// 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    }
637
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}
653
654/// Every well-formed line. A line that fails to parse - a torn
655/// write from a process killed mid-append - is skipped rather than failing
656/// the whole read: the ledger's job is to bound spend, and one unreadable
657/// line must not make every future request refuse to send by making the
658/// 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}
675
676/// Hold an exclusive `flock` on `path` for the duration of `f`. The lock is
677/// released when `file` drops at the end of this function, which is also
678/// what happens if the process is killed with the lock held - `flock` is
679/// tied to the open file descriptor, not written into the file itself, so
680/// 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}
701
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::*;
716
717    /// Every test gets its own directory rather than the real
718    /// `$XDG_STATE_HOME/jev` - the ledger is process-global by design, and
719    /// `buck2` runs `#[test]`s concurrently in one process, so sharing the
720    /// real path (or mutating the environment, which every thread shares)
721    /// 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    }
728
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 };
749
750    /// The fake clock starts two hours after the ledger's own seed line, so
751    /// the opening estimate is out of every window, on a whole second so
752    /// that its times survive the trip through JSON exactly.
753    fn t0() -> f64 {
754        (now() + 2.0 * HOUR).floor()
755    }
756
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    }
782
783    /// Real, observed 2026-09-22 (`budget`, live window): with nothing spent
784    /// in the last hour, `dollars_last_hour()` is a sum over zero elements,
785    /// and Rust's chosen additive identity for floats is negative zero - so
786    /// the unclamped sum was `-0.0`, and `Status::line()` printed
787    /// "$-0.0000/$0.25 in the last hour". `-0.0 == 0.0` is true, which is
788    /// exactly why an assertion against `0.0` alone would not have caught
789    /// 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    }
798
799    /// The trip of 2026-09-21, replayed: more questions than the bucket
800    /// holds are refused, nothing is written that anyone has to delete, and
801    /// 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    }
830
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    }
845
846    /// There is no lifetime cap (removed 2026-09-22, the user's own ruling):
847    /// spending far past what the old $4 constant would have allowed is
848    /// 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    }
858
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    }
878
879    /// Two processes are two `Ledger`s on one directory. Asking from both at
880    /// once, hard, admits exactly the bucket's worth between them: the check
881    /// and the hold are one locked step, so neither slips in between the
882    /// 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    }
895
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}