Skip to main content

agentplane/core/
budget.rs

1//! Budgets — bounded cost, enforced deterministically.
2//!
3//! # Why spend is journaled
4//!
5//! A budget decision is control flow: "may this effect run?" changes what the
6//! run does. So it has to live in the deterministic zone, and everything it
7//! reads has to be reproducible.
8//!
9//! That rules out asking a provider what something cost at replay time — the
10//! answer moves. Instead each effect reports its consumption when it completes,
11//! the number goes into the `EffectDone` record, and replay adds up the same
12//! figures the original run did. A replayed run therefore reaches the same
13//! budget verdict at the same point, which is the only way an exhausted budget
14//! can be part of a faithful history rather than an artefact of when you looked.
15//!
16//! # A metered budget stops *after* the limit, not before it
17//!
18//! A token or cost limit cannot be a hard ceiling, because an operation's cost
19//! is not known until it has run. What is enforced is: *once consumption has
20//! reached the limit, no further operation starts.* A run therefore overshoots
21//! a metered ceiling by at most one operation's cost **per step it has in
22//! flight** — a plan's ready set is dispatched concurrently, and every step in
23//! it may be holding a call the ledger has admitted and not yet billed.
24//! `max_parallel_steps` is what bounds that number; without it the bound is the
25//! plan's own width.
26//!
27//! That is stated rather than hidden because the alternative — pretending to a
28//! hard cap — is how somebody sizes a limit at exactly their ceiling and is
29//! surprised. Where a true ceiling matters, set `max_effects` too: a count is
30//! known in advance, so admission takes the slot as it checks it and that limit
31//! is exact however wide the plan runs.
32//!
33//! # One announcement, one slot, on every pass
34//!
35//! The ledger is the same on both sides of a replay, and that is arithmetic
36//! rather than intent. An attempt takes its slot when it is admitted and adds
37//! its cost when the call returns; the pass that reads that attempt back out of
38//! the journal takes one slot and adds everything the attempt's records report
39//! — including a figure a later record superseded, which the live pass had
40//! already added.
41//!
42//! An arm that bills twice, or one that drops a superseded figure, moves where
43//! a resumed run stops without changing anything a status assertion can see:
44//! the run concludes `Exhausted` against a ceiling its own history never
45//! reached, at a point no record contains. So the arms are held to one rule,
46//! and the rule is asserted as a tally rather than as an outcome.
47//!
48//! # Why wall-clock limits are opt-in
49//!
50//! Elapsed time cannot be checked without reading a clock, and a clock read is
51//! an effect. A wall-clock budget therefore costs one journaled effect per step
52//! boundary. That is cheap but not free, so it is only paid when asked for.
53//!
54//! The reading has to be journaled rather than ambient, and that is the whole
55//! reason it costs anything: a verdict taken from the wall would be a different
56//! verdict every time the run was looked at, and an exhausted run would replay
57//! as healthy. What follows from the opt-in is worth stating plainly — the
58//! step's first effect is this reading when a wall-clock ceiling is declared
59//! and the skill's own when it is not, so that history replays under a *raised*
60//! ceiling and not under a build that removed it.
61//!
62//! Elapsed time is the distance between the extremes of what the run has read,
63//! never between the first and last *arrival*: a ready set is dispatched
64//! concurrently, so arrival order belongs to the scheduler, and a ceiling that
65//! depended on it would fire on some passes over one history and not on others.
66//!
67//! The limit that follows from reading at *boundaries* is stated rather than
68//! implied: the ceiling stops the next step, so a single step that overruns it
69//! is not interrupted. Nothing here cancels work in flight — that would abort
70//! an effect mid-call and manufacture the unknown outcome the protocol exists
71//! to refuse. What bounds one call is the driver's own timeout; this bounds how
72//! long a *run* goes on making new ones.
73
74use serde::{Deserialize, Serialize};
75
76use crate::core::Timestamp;
77
78/// What one effect consumed.
79///
80/// Deliberately unit-agnostic: the engine never learns what a token is or which
81/// currency `minor_units` is in. It adds them up and compares them to a limit.
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct Spend {
85    /// Model tokens, or any other metered unit the deployment counts.
86    #[serde(default, skip_serializing_if = "is_zero_u64")]
87    pub tokens: u64,
88    /// Money in minor units — cents, pence, whatever the deployment uses.
89    ///
90    /// Integer rather than float: money that rounds differently on two machines
91    /// is money that produces two different budget verdicts.
92    ///
93    /// **Unsigned, and that is a security property rather than tidiness.** Every
94    /// ceiling in this crate — a [`Budget`], a [`TenantQuota`], a
95    /// [`StandingAuthority`] — is enforced by comparing an accumulated `Spend`
96    /// against a limit. A *negative* amount reverses the accumulation, so a
97    /// single one un-spends everything before it and the ceiling stops being
98    /// one. That was reachable while this was an `i64`: a €500 standing
99    /// authority could be drawn to zero, drawn again for −€400, and then drawn
100    /// for a further €400 — €900 against a €500 mandate, with every check
101    /// passing. Refusing negatives in each of the three enforcement paths would
102    /// have been three places to remember; making the state unrepresentable is
103    /// none.
104    ///
105    /// A refund or a credit is therefore not a `Spend`. It is a new
106    /// authorization, which is what leaves both decisions on the record.
107    ///
108    /// [`TenantQuota`]: crate::quota::TenantQuota
109    /// [`StandingAuthority`]: crate::authority::StandingAuthority
110    #[serde(default, skip_serializing_if = "is_zero_u64")]
111    pub minor_units: u64,
112}
113
114// By-reference because `skip_serializing_if` requires it.
115#[allow(clippy::trivially_copy_pass_by_ref)]
116fn is_zero_u64(v: &u64) -> bool {
117    *v == 0
118}
119
120impl Spend {
121    /// Nothing consumed.
122    pub const ZERO: Self = Self {
123        tokens: 0,
124        minor_units: 0,
125    };
126
127    #[must_use]
128    pub const fn tokens(n: u64) -> Self {
129        Self {
130            tokens: n,
131            minor_units: 0,
132        }
133    }
134
135    #[must_use]
136    pub const fn money(minor_units: u64) -> Self {
137        Self {
138            tokens: 0,
139            minor_units,
140        }
141    }
142
143    /// By-reference form, for `skip_serializing_if`.
144    ///
145    /// One predicate under one name. Two spellings of "did this cost
146    /// anything" is two places for the answer to drift, and the by-reference
147    /// form exists only because `skip_serializing_if` hands out a reference.
148    #[allow(clippy::trivially_copy_pass_by_ref)]
149    #[must_use]
150    pub const fn is_free_ref(v: &Self) -> bool {
151        v.is_free()
152    }
153
154    /// Whether this cost nothing at all.
155    #[must_use]
156    pub const fn is_free(self) -> bool {
157        self.tokens == 0 && self.minor_units == 0
158    }
159
160    /// Saturating so a runaway meter cannot wrap into a smaller number and
161    /// silently re-open a budget that was already blown.
162    #[must_use]
163    pub fn plus(self, other: Self) -> Self {
164        Self {
165            tokens: self.tokens.saturating_add(other.tokens),
166            minor_units: self.minor_units.saturating_add(other.minor_units),
167        }
168    }
169}
170
171/// What it cost, in a sentence a person reads.
172///
173/// This type reaches operators through refusals — a standing authority short of
174/// what a draw asked for names both amounts — where `{:?}` puts Rust syntax in
175/// the middle of an English sentence. A zero is still printed: it says the
176/// other dimension was unspent, where omitting it leaves a reader to guess
177/// whether it was unmetered.
178impl std::fmt::Display for Spend {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        write!(
181            f,
182            "{} tokens and {} minor units",
183            self.tokens, self.minor_units
184        )
185    }
186}
187
188impl std::ops::AddAssign for Spend {
189    fn add_assign(&mut self, rhs: Self) {
190        *self = self.plus(rhs);
191    }
192}
193
194/// What a run is allowed to consume.
195///
196/// Every limit is optional; `Budget::unlimited()` is a legitimate choice for a
197/// run that only touches free, local effects. It is not the default, because
198/// "nobody set a limit" and "somebody decided no limit was needed" should not
199/// look the same in a config file.
200///
201/// # A ceiling gates every effect, not only the metered ones
202///
203/// Worth stating once, because the field names invite the opposite reading:
204/// [`admit_effect`](Ledger::admit_effect) evaluates *all* of these limits before
205/// *every* effect, whatever kind it is. A run holding a token ceiling passes it
206/// on the way to a read-only tool call, and a run that has reached any one
207/// ceiling performs no further operation of any kind. So `max_tokens` is not
208/// "what the model may spend" — it is a gate the whole run walks through.
209///
210/// The corollary is that zero is not a useful value for any ceiling here except
211/// [`max_replans`](Self::max_replans) and [`max_denials`](Self::max_denials): at
212/// zero the limit is reached before the run starts, and the agent can never do
213/// anything at all. Refused on both paths that can carry one — the manifest at
214/// parse, and [`RuntimeBuilder::budget`] at build — from the single rule in
215/// [`bricked_ceiling`](Self::bricked_ceiling).
216///
217/// [`RuntimeBuilder::budget`]: crate::runtime::RuntimeBuilder::budget
218#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
219pub struct Budget {
220    /// Plan nodes this run may execute, checked before each one starts.
221    ///
222    /// Zero permits no step at all, so a run carrying it cannot begin.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub max_steps: Option<usize>,
225    /// Externally visible operations, of every kind.
226    ///
227    /// The blunt instrument that stops a loop nobody predicted, independent of
228    /// what each operation happens to cost — a free local read costs one here
229    /// exactly as a model completion does. Zero permits no effect at all.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub max_effects: Option<usize>,
232    /// Metered units the run may consume in total.
233    ///
234    /// Compared before **every** effect, including the effects that consume no
235    /// tokens, so this bounds when the run stops rather than only what the model
236    /// costs. Zero refuses the first effect of any kind.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub max_tokens: Option<u64>,
239    /// Money in minor units the run may spend in total.
240    ///
241    /// Gates **every** effect, exactly like [`max_tokens`](Self::max_tokens):
242    /// a free effect still has to pass it. Zero refuses the first one.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub max_minor_units: Option<u64>,
245    /// How many times a run may change its plan.
246    ///
247    /// A run that replans without bound is a run that has stopped making
248    /// progress and started thrashing, and the ceiling is what turns that from
249    /// an unbounded spend into a reported fault.
250    ///
251    /// Zero is meaningful here, unlike the ceilings above: it says the plan the
252    /// run started with is the plan it finishes with, and a run that never
253    /// replans is unaffected by it.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub max_replans: Option<u32>,
256    /// Wall-clock ceiling.
257    ///
258    /// Costs one journaled clock read per step boundary when set — see the
259    /// module docs. Checked against
260    /// [`Consumed::elapsed_secs`], which counts second
261    /// boundaries rather than measuring a duration and starts at zero, so a zero
262    /// ceiling refuses the first step.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub max_wallclock_secs: Option<u64>,
265    /// How many of a plan's ready steps may run at once.
266    ///
267    /// A ready set is every node whose predecessors are done, so nothing in it
268    /// depends on anything else in it and running them concurrently is the
269    /// point of writing a graph rather than a list. What that width costs is
270    /// paid outside this process — connections, provider rate limits, and the
271    /// metered ceilings above, which are checked before an operation and
272    /// billed after it, so each step in flight may be holding one operation's
273    /// worth of unbilled spend.
274    ///
275    /// Absent means the plan's own width is the bound, which is the right
276    /// default for a graph the embedder wrote and the wrong one for a graph
277    /// anything else may widen. Zero permits no step at all.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub max_parallel_steps: Option<usize>,
280    /// How many times this run may be refused before it is stopped.
281    ///
282    /// **A run that keeps being refused is probing.** Refusals carry a uniform
283    /// message precisely so a model cannot tell one from another
284    /// ([`REFUSED`](crate::core::REFUSED)), but the refused/allowed bit itself
285    /// still leaks one bit per attempt, and nothing short of fabricating success
286    /// removes that. What bounds the channel is bounding the attempts.
287    ///
288    /// **Both refusal paths count, and they behave differently.** A sink
289    /// refusal — an untrusted value in a protected field, a sensitivity over a
290    /// ceiling — comes back to a tool-calling model as `REFUSED` and the loop
291    /// continues, so those accumulate and this is what stops them. An engine
292    /// denial is not model-facing at all: in that same loop it ends the run
293    /// outright, which is a stricter bound than any ceiling, and it accumulates
294    /// only where a run's own code catches
295    /// [`StepError::Denied`](crate::core::StepError::Denied) and carries on.
296    /// Counting only the second would leave the ceiling naming a channel it did
297    /// not reach.
298    ///
299    /// It is an operational ceiling as much as a security one: a run stuck in a
300    /// denial loop has stopped making progress, exactly like one that replans
301    /// without bound.
302    ///
303    /// Zero is meaningful here too, and for a different reason than
304    /// [`max_replans`](Self::max_replans): this one is counted *after* the
305    /// refusal and compared with `>`, so zero says the first refusal ends the
306    /// run. A run nothing refuses never notices it.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub max_denials: Option<u32>,
309    /// Bytes this run may send **into sinks**, in total.
310    ///
311    /// Volume is the axis a label does not have. Ten thousand records marked
312    /// `Internal` pass every gate one record passes, and every ceiling above is
313    /// a cost control that bounds *work*: an extraction sized just under an
314    /// effect count is invisible to all of them. This bounds the quantity that
315    /// left.
316    ///
317    /// Measured as the canonical size of each effect's outbound value — a tool
318    /// call's arguments, a model call's prompt, a peer call's payload — which is
319    /// the figure `EffectStarted.outbound_bytes` records. An effect binding no
320    /// outbound value costs nothing here, so a run of pure reads is unaffected.
321    ///
322    /// **Exact, unlike every metered ceiling above.** A token cost is unknown
323    /// until the call returns, so those refuse once consumption has *reached*
324    /// the limit and the run overshoots by at most one operation. This size is
325    /// known before dispatch, so the effect that would cross the ceiling is the
326    /// effect refused: nothing over the limit is ever sent.
327    ///
328    /// Zero is meaningful, like [`max_denials`](Self::max_denials) and unlike
329    /// the metered ceilings: it says this run may read and may not send, which
330    /// is a coherent thing to ask of an agent. So it is not a bricked ceiling.
331    ///
332    /// What it is not is an anomaly detector. *Forty times the median for this
333    /// capability* is a threshold a deployment sets against its own traffic,
334    /// and the journaled figure is what makes that an ordinary query. A ceiling
335    /// bounds the worst case; the figure catches the case that stayed under it.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub max_egress_bytes: Option<u64>,
338    /// The most tokens one operation of this run can report.
339    ///
340    /// Not a ceiling the ledger checks — a metered cost is unknown until the
341    /// call returns — but the size of the overshoot the ceilings above permit:
342    /// a run stops once [`max_tokens`](Self::max_tokens) is reached, so it can
343    /// end one operation past it per step in flight. A tenant's spend quota
344    /// reserves the ceiling **plus** that overshoot at admission, and this is
345    /// the figure it reserves per step, so under a token quota a run that
346    /// declares none is refused.
347    ///
348    /// A manifest derives it from its model roles (`max_input_tokens` plus
349    /// `max_tokens` per call), and a model call reporting more input than its
350    /// role declares fails. An embedder's own effects cannot be inspected, so
351    /// for those it is a declaration the reservation is only as exact as —
352    /// the same footing as a price.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub max_call_tokens: Option<u64>,
355    /// The most money, in minor units, one operation of this run can report.
356    ///
357    /// [`max_call_tokens`](Self::max_call_tokens) for
358    /// [`max_minor_units`](Self::max_minor_units): the per-step overshoot a
359    /// tenant's money quota reserves beside the ceiling. A manifest derives it
360    /// from each role's per-call token maxima at the role's declared price.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub max_call_minor_units: Option<u64>,
363}
364
365impl Budget {
366    /// The first ceiling set to zero, if any — the field name, for a message.
367    ///
368    /// Zero is not a small limit here; it is a limit already reached. Every
369    /// ceiling this reports is compared **before** the work and against every
370    /// effect of every kind, so at zero the run is refused its first operation:
371    /// a read-only tool call, a local lookup, an agent that declares no models
372    /// at all. Such a plane does not run once and stop — it fails identically
373    /// on every run it will ever make, which makes it a wiring mistake rather
374    /// than a budget.
375    ///
376    /// Three are excluded, because zero is a coherent instruction for each:
377    /// [`max_replans`](Self::max_replans) says *do not replan*,
378    /// [`max_denials`](Self::max_denials) says *the first refusal ends the
379    /// run*, and [`max_egress_bytes`](Self::max_egress_bytes) says *this run
380    /// may read and may not send*. A run that never replans, is never refused,
381    /// or sends nothing is unaffected by its own — which is what separates
382    /// them from a ceiling that refuses the first operation of any kind.
383    #[must_use]
384    pub const fn bricked_ceiling(&self) -> Option<&'static str> {
385        // `matches!` rather than `== Some(0)` so this stays `const`.
386        if matches!(self.max_steps, Some(0)) {
387            return Some("max_steps");
388        }
389        if matches!(self.max_effects, Some(0)) {
390            return Some("max_effects");
391        }
392        if matches!(self.max_tokens, Some(0)) {
393            return Some("max_tokens");
394        }
395        if matches!(self.max_minor_units, Some(0)) {
396            return Some("max_minor_units");
397        }
398        if matches!(self.max_wallclock_secs, Some(0)) {
399            return Some("max_wallclock_secs");
400        }
401        if matches!(self.max_parallel_steps, Some(0)) {
402            return Some("max_parallel_steps");
403        }
404        None
405    }
406
407    /// No limits. An explicit choice, not an absence of one.
408    #[must_use]
409    pub const fn unlimited() -> Self {
410        Self {
411            max_steps: None,
412            max_effects: None,
413            max_tokens: None,
414            max_minor_units: None,
415            max_replans: None,
416            max_wallclock_secs: None,
417            max_denials: None,
418            max_parallel_steps: None,
419            max_egress_bytes: None,
420            max_call_tokens: None,
421            max_call_minor_units: None,
422        }
423    }
424
425    #[must_use]
426    pub const fn steps(mut self, n: usize) -> Self {
427        self.max_steps = Some(n);
428        self
429    }
430
431    #[must_use]
432    pub const fn effects(mut self, n: usize) -> Self {
433        self.max_effects = Some(n);
434        self
435    }
436
437    #[must_use]
438    pub const fn tokens(mut self, n: u64) -> Self {
439        self.max_tokens = Some(n);
440        self
441    }
442
443    #[must_use]
444    pub const fn minor_units(mut self, n: u64) -> Self {
445        self.max_minor_units = Some(n);
446        self
447    }
448
449    #[must_use]
450    pub const fn replans(mut self, n: u32) -> Self {
451        self.max_replans = Some(n);
452        self
453    }
454
455    /// How many refusals this run may accrue — sink and engine alike.
456    #[must_use]
457    pub const fn denials(mut self, n: u32) -> Self {
458        self.max_denials = Some(n);
459        self
460    }
461
462    /// How many bytes this run may send into sinks.
463    #[must_use]
464    pub const fn egress_bytes(mut self, n: u64) -> Self {
465        self.max_egress_bytes = Some(n);
466        self
467    }
468
469    /// The most tokens one operation may report. See
470    /// [`max_call_tokens`](Self::max_call_tokens).
471    #[must_use]
472    pub const fn call_tokens(mut self, n: u64) -> Self {
473        self.max_call_tokens = Some(n);
474        self
475    }
476
477    /// The most money one operation may report. See
478    /// [`max_call_minor_units`](Self::max_call_minor_units).
479    #[must_use]
480    pub const fn call_minor_units(mut self, n: u64) -> Self {
481        self.max_call_minor_units = Some(n);
482        self
483    }
484
485    #[must_use]
486    pub const fn wallclock_secs(mut self, n: u64) -> Self {
487        self.max_wallclock_secs = Some(n);
488        self
489    }
490
491    /// Bound the width of a ready set. See
492    /// [`max_parallel_steps`](Self::max_parallel_steps).
493    #[must_use]
494    pub const fn parallel_steps(mut self, n: usize) -> Self {
495        self.max_parallel_steps = Some(n);
496        self
497    }
498
499    /// How many ready steps may be dispatched at once — the plan's own width
500    /// when nothing narrows it.
501    #[must_use]
502    pub const fn parallelism(&self) -> usize {
503        match self.max_parallel_steps {
504            Some(n) => n,
505            None => usize::MAX,
506        }
507    }
508
509    #[must_use]
510    pub const fn tracks_wallclock(&self) -> bool {
511        self.max_wallclock_secs.is_some()
512    }
513}
514
515/// Running totals for one run.
516#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
517pub struct Consumed {
518    pub steps: usize,
519    /// Effects **dispatched**, not effects that succeeded.
520    ///
521    /// A call that fails still consumed a call — it occupied a connection, hit
522    /// a rate limit, and may have cost money on the way to failing. Counting
523    /// only successes leaves the one loop a ceiling most needs to stop, a
524    /// failing call retried forever, costing nothing on paper.
525    pub effects: usize,
526    pub spend: Spend,
527    /// Whole seconds between the first and last journaled clock reading.
528    ///
529    /// A difference of unix timestamps rather than a duration, so it counts
530    /// second *boundaries crossed*: the same 1.4 s of work reads as `1` or `2`
531    /// depending on where in a second it began, and two runs of one workload
532    /// can report different figures. That is deliberate and is what makes the
533    /// ceiling replayable — a duration measured off the wall clock would give a
534    /// different verdict on every look, and a resumed run would replay as
535    /// healthy. What it costs is that this is not a stopwatch: a ceiling set
536    /// close to a step's real cost admits a step at one starting instant and
537    /// refuses the same step a fraction of a second later.
538    pub elapsed_secs: u64,
539    /// Policy refusals this run has accrued.
540    #[serde(default)]
541    pub denials: u32,
542    /// Bytes this run has sent into sinks.
543    ///
544    /// Accumulated from each announced attempt's outbound value, on the pass
545    /// that announces it and on every pass that replays it. Recomputed from the
546    /// effect rather than read back from the record, which is sound *here* and
547    /// not for spend: the outbound value lives inside `descriptor.args` and so
548    /// inside the effect key, and a value that changed would diverge before it
549    /// could be billed. Spend has no such cover, which is why it is journaled.
550    #[serde(default)]
551    pub egress_bytes: u64,
552}
553
554/// Which limit stopped the run, and where it stood.
555///
556/// Carries the numbers rather than a message: an operator raising a limit needs
557/// to know what it actually reached, and "budget exhausted" does not say.
558///
559/// # Why the messages state a rule and not a tally
560///
561/// Every metered ceiling here is a **pre-dispatch gate on reaching the limit**:
562/// nothing further starts once the figure is at the ceiling, because a step that
563/// would exceed it must not half-run and then be stopped. The refusal is
564/// therefore raised at `used == allowed`, where *`{allowed} permitted, {used}
565/// consumed`* prints two equal numbers beside the word *exhausted* and reads
566/// like an off-by-one. Telling otherwise means finding the comparison in this
567/// file, and a refusal message is the one artifact put in front of somebody who
568/// never will. So each states its rule. [`Egress`](Self::Egress) is the
569/// exception twice over: an outbound size is known before dispatch, so that
570/// ceiling is exact rather than reached, and its message says what the refused
571/// call would have sent.
572#[derive(
573    Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error, schemars::JsonSchema,
574)]
575#[non_exhaustive]
576#[serde(tag = "limit", rename_all = "snake_case", deny_unknown_fields)]
577pub enum BudgetExceeded {
578    #[error("step budget exhausted: {allowed} step(s) permitted")]
579    Steps { allowed: usize },
580
581    #[error(
582        "effect budget exhausted: nothing further starts at or past {allowed} \
583         operation(s); {used} performed"
584    )]
585    Effects { allowed: usize, used: usize },
586
587    #[error("replan budget exhausted: {allowed} replan(s) permitted")]
588    Replans { allowed: u32 },
589
590    /// The run was refused by the policy more often than it is allowed to be.
591    ///
592    /// Not a policy failure — a *probing* ceiling. See
593    /// [`Budget::max_denials`].
594    #[error("denial budget exhausted: {allowed} refusal(s) permitted")]
595    Denials { allowed: u32 },
596
597    /// A refusal read back from the journal.
598    ///
599    /// Carries the recorded limit rather than the one in force now, because a
600    /// run replayed under a larger budget still stopped where it stopped.
601    #[error("{limit} (recorded: {used})")]
602    Recorded {
603        #[serde(rename = "recorded_limit")]
604        limit: String,
605        used: String,
606    },
607
608    #[error(
609        "token budget exhausted: nothing further starts at or past {allowed} \
610         token(s); {used} consumed"
611    )]
612    Tokens { allowed: u64, used: u64 },
613
614    #[error(
615        "cost budget exhausted: nothing further starts at or past {allowed} \
616         minor unit(s); {used} spent"
617    )]
618    Money { allowed: u64, used: u64 },
619
620    #[error(
621        "time budget exhausted: nothing further starts at or past {allowed}s; \
622         {used}s elapsed"
623    )]
624    Wallclock { allowed: u64, used: u64 },
625
626    /// The run tried to send more than it may.
627    ///
628    /// Carries `attempted` as well as `used`, which the metered ceilings cannot:
629    /// this one is checked against a size known before dispatch, so an operator
630    /// raising the limit sees both where the run stood and what it was about to
631    /// send — and can tell a ceiling that is too low from one call that is too
632    /// big.
633    #[error(
634        "egress budget exhausted: {allowed} byte(s) permitted, {used} sent, and \
635         this call would send {attempted} more"
636    )]
637    Egress {
638        allowed: u64,
639        used: u64,
640        attempted: u64,
641    },
642
643    /// A tool's cross-run rate ceiling had no room for this call.
644    ///
645    /// Not this run's consumption: the count is the tenant's, across runs, so
646    /// `reached` is how many calls to `grant` already fell in the window. The
647    /// window slides, so a resume once enough of them have aged out continues
648    /// the run.
649    #[error(
650        "rate ceiling reached: '{grant}' admits {allowed} call(s) per {window_seconds}s          across runs, and {reached} already fall in the window"
651    )]
652    Rate {
653        grant: String,
654        allowed: u32,
655        window_seconds: u64,
656        reached: u64,
657    },
658}
659
660impl BudgetExceeded {
661    /// Which limit was hit, for a metric label.
662    ///
663    /// The variant only — never the rendered message. `Display` here embeds the
664    /// allowed and used figures, and a label carrying those is a new time series
665    /// per distinct budget, which is how a metrics backend falls over. See
666    /// `runtime::metrics`.
667    #[must_use]
668    pub const fn as_str(&self) -> &'static str {
669        match self {
670            Self::Steps { .. } => "steps",
671            Self::Effects { .. } => "effects",
672            Self::Replans { .. } => "replans",
673            Self::Denials { .. } => "denials",
674            Self::Recorded { .. } => "recorded",
675            Self::Tokens { .. } => "tokens",
676            Self::Money { .. } => "money",
677            Self::Wallclock { .. } => "wallclock",
678            Self::Egress { .. } => "egress_bytes",
679            Self::Rate { .. } => "rate",
680        }
681    }
682}
683
684/// Tracks a run's consumption against its budget.
685///
686/// Lives entirely in the deterministic zone: it reads only numbers that came
687/// out of the journal, so a replayed run reaches the same verdict at the same
688/// point as the original.
689///
690/// Every ceiling it holds gates *every* effect: [`admit_effect`](Self::admit_effect)
691/// evaluates the effect count, the token total, the money total and the clock
692/// before each operation, whatever kind that operation is. A token ceiling is
693/// therefore not a model-spend ceiling — a run that has reached it stops making
694/// free local calls too, which is exactly what makes a zero one uninhabitable.
695#[derive(Debug, Clone, Default)]
696pub struct Ledger {
697    budget: Budget,
698    consumed: Consumed,
699    /// The extremes of every instant this run has read, when it reads any.
700    ///
701    /// `None` on a run with no wall-clock ceiling: the readings that would fill
702    /// these cost a journaled effect apiece, and a ceiling nobody declared is
703    /// not worth one per step.
704    first: Option<Timestamp>,
705    last: Option<Timestamp>,
706    /// What *this pass* dispatched, as opposed to what it read back.
707    ///
708    /// The budget verdict runs on `consumed`, which bills replayed history
709    /// exactly as it was billed live so a resumed run reaches the same
710    /// exhaustion at the same point. The tenant's quota ledger must not: a run
711    /// that suspends and resumes N times would accrue its prefix N times, and
712    /// a strict verification would bill a historical run's whole spend into
713    /// the current period on every look. This figure carries only the effects
714    /// the current pass actually performed, and it is what settlement accrues.
715    live: Spend,
716}
717
718impl Ledger {
719    #[must_use]
720    pub const fn new(budget: Budget) -> Self {
721        Self {
722            budget,
723            consumed: Consumed {
724                steps: 0,
725                effects: 0,
726                spend: Spend {
727                    tokens: 0,
728                    minor_units: 0,
729                },
730                elapsed_secs: 0,
731                denials: 0,
732                egress_bytes: 0,
733            },
734            live: Spend {
735                tokens: 0,
736                minor_units: 0,
737            },
738            first: None,
739            last: None,
740        }
741    }
742
743    #[must_use]
744    pub const fn budget(&self) -> Budget {
745        self.budget
746    }
747
748    /// What the current pass dispatched, excluding everything read from
749    /// history. This is the figure a tenant's period ledger accrues; the run's
750    /// own budget verdict uses [`consumed`](Self::consumed), which includes
751    /// the replayed prefix.
752    #[must_use]
753    pub const fn live_spend(&self) -> Spend {
754        self.live
755    }
756
757    #[must_use]
758    pub const fn consumed(&self) -> Consumed {
759        self.consumed
760    }
761
762    /// Whether elapsed time needs measuring at all.
763    #[must_use]
764    pub const fn tracks_wallclock(&self) -> bool {
765        self.budget.tracks_wallclock()
766    }
767
768    /// Fold in a journaled clock reading.
769    ///
770    /// Elapsed time is the distance between the earliest and the latest instant
771    /// this run has read, and both are taken as extremes rather than as *first*
772    /// and *last seen* — a ready set is dispatched concurrently, so "the one
773    /// that arrived first" is a property of the scheduler, and a ceiling whose
774    /// verdict depends on that is a ceiling that fires on some runs of the same
775    /// history and not on others. Extremes are order-independent, so every pass
776    /// over the same readings computes the same elapsed figure.
777    ///
778    /// Saturating rather than signed: a clock that steps backwards between two
779    /// reads must not un-spend the time already elapsed.
780    pub fn observe_clock(&mut self, at: Timestamp) {
781        self.first = Some(match self.first {
782            Some(f) if f <= at => f,
783            _ => at,
784        });
785        self.last = Some(match self.last {
786            Some(l) if l >= at => l,
787            _ => at,
788        });
789        if let (Some(f), Some(l)) = (self.first, self.last) {
790            self.consumed.elapsed_secs =
791                u64::try_from((l.unix_timestamp() - f.unix_timestamp()).max(0)).unwrap_or(u64::MAX);
792        }
793    }
794
795    /// Check whether another step may start, given `pending` already admitted
796    /// and not yet finished.
797    ///
798    /// Checked *before* dispatch: a step that would exceed the budget must not
799    /// half-run and then be stopped.
800    ///
801    /// `pending` is what makes this hold for a plan with any width. A step is
802    /// counted when it finishes, so a whole ready set admitted against the
803    /// same figure is a whole ready set that passes — three branches admitted
804    /// under a ceiling of two, each check truthfully reporting nothing spent
805    /// yet. The caller admits a ready set one at a time and says how many of
806    /// them it has already taken, so the ceiling bounds the plan rather than
807    /// the sequential prefix of it.
808    pub fn admit_step(&self, pending: usize) -> Result<(), BudgetExceeded> {
809        if let Some(max) = self.budget.max_steps
810            && self.consumed.steps + pending >= max
811        {
812            return Err(BudgetExceeded::Steps { allowed: max });
813        }
814        self.check_denials()?;
815        self.check_time()
816    }
817
818    pub const fn record_step(&mut self) {
819        self.consumed.steps += 1;
820    }
821
822    /// Check whether another effect may be performed, **and take its slot**.
823    ///
824    /// Before dispatch — after is too late, since the point of a budget is to
825    /// stop the money leaving. Note the semantics for metered limits: an
826    /// operation's cost is unknown until it runs, so this refuses once
827    /// consumption has *reached* the limit. A run overshoots those by at most
828    /// one operation per step dispatched in parallel, which is what
829    /// [`Budget::max_parallel_steps`] bounds.
830    ///
831    /// `max_effects` is exact, and taking the slot here is what makes it so.
832    /// Checking without counting leaves a window between the verdict and the
833    /// announcement, and steps in one ready set run concurrently: at the last
834    /// remaining slot every one of them is told yes. Counting under the same
835    /// lock that checked means the second caller sees the first one's slot.
836    ///
837    /// # Errors
838    ///
839    /// The first ceiling this effect would cross. Nothing is counted when the
840    /// answer is a refusal.
841    pub fn admit_effect(&mut self, outbound: u64) -> Result<(), BudgetExceeded> {
842        self.can_admit_effect(outbound)?;
843        self.consumed.effects += 1;
844        self.consumed.egress_bytes = self.consumed.egress_bytes.saturating_add(outbound);
845        Ok(())
846    }
847
848    /// Whether another effect would be admitted, without taking its slot.
849    ///
850    /// For the one caller that asks the question early and dispatches through
851    /// [`admit_effect`](Self::admit_effect) afterwards: a resume re-asking
852    /// whether a journaled refusal still stands.
853    ///
854    /// # Errors
855    ///
856    /// The first ceiling the next effect would cross.
857    pub fn can_admit_effect(&self, outbound: u64) -> Result<(), BudgetExceeded> {
858        // Checked here as well as at the denial itself, and that is the half
859        // that actually binds: a probing loop swallows the error it gets back,
860        // so refusing the *next* attempt is what stops the probing rather than
861        // merely reporting it.
862        self.check_denials()?;
863        if let Some(max) = self.budget.max_effects
864            && self.consumed.effects >= max
865        {
866            return Err(BudgetExceeded::Effects {
867                allowed: max,
868                used: self.consumed.effects,
869            });
870        }
871        if let Some(max) = self.budget.max_tokens
872            && self.consumed.spend.tokens >= max
873        {
874            return Err(BudgetExceeded::Tokens {
875                allowed: max,
876                used: self.consumed.spend.tokens,
877            });
878        }
879        if let Some(max) = self.budget.max_minor_units
880            && self.consumed.spend.minor_units >= max
881        {
882            return Err(BudgetExceeded::Money {
883                allowed: max,
884                used: self.consumed.spend.minor_units,
885            });
886        }
887        // `>` against the total *this effect would reach*, not `>=` against the
888        // total so far. Every ceiling above compares a cost it cannot know yet
889        // and therefore refuses once consumption has reached the limit, leaving
890        // the run to overshoot by one operation. This size is in hand before
891        // dispatch, so the comparison is the exact one and nothing over the
892        // ceiling is ever sent.
893        if let Some(max) = self.budget.max_egress_bytes
894            && self.consumed.egress_bytes.saturating_add(outbound) > max
895        {
896            return Err(BudgetExceeded::Egress {
897                allowed: max,
898                used: self.consumed.egress_bytes,
899                attempted: outbound,
900            });
901        }
902        self.check_time()
903    }
904
905    /// Take an effect's slot without checking a ceiling.
906    ///
907    /// For the announcements no ceiling gates: a compensating call, which is
908    /// exempt because refusing to undo is how a run ends with a charged card
909    /// and no order, and a durable wait, which is registered rather than
910    /// dispatched. Both still happened, so both still count — the alternative
911    /// is a ceiling a run walks through by phrasing its work as an undo.
912    pub const fn count_effect(&mut self, outbound: u64) {
913        self.consumed.effects += 1;
914        self.consumed.egress_bytes = self.consumed.egress_bytes.saturating_add(outbound);
915    }
916
917    /// Add what an announced attempt cost, without taking a slot.
918    ///
919    /// The slot was taken when the attempt was admitted, so this is the second
920    /// half of one billing: the cost is not known until the call returns, and
921    /// the count was.
922    pub fn record_spend(&mut self, spend: Spend) {
923        self.consumed.spend += spend;
924    }
925
926    /// [`record_spend`](Self::record_spend) for an attempt this pass actually
927    /// dispatched.
928    ///
929    /// The distinction feeds the tenant's period ledger: replayed spend is
930    /// billed to the run's own budget so a resume exhausts where the original
931    /// did, but only live spend accrues at settlement — otherwise every
932    /// suspend/resume cycle re-accrues the prefix and every strict pass bills
933    /// history into today's period.
934    pub fn record_live_spend(&mut self, spend: Spend) {
935        self.record_spend(spend);
936        self.live += spend;
937    }
938
939    /// Bill an announced attempt read back from the journal: one slot, and
940    /// everything its records say it cost.
941    ///
942    /// The mirror of [`admit_effect`](Self::admit_effect) plus
943    /// [`record_spend`](Self::record_spend), which is the pair the live path
944    /// performs — a replayed run must reach the same verdict at the same
945    /// point, and it can only do that if one announcement costs one slot on
946    /// both paths.
947    pub fn replay_effect(&mut self, spend: Spend, outbound: u64) {
948        self.consumed.effects += 1;
949        self.consumed.spend += spend;
950        self.consumed.egress_bytes = self.consumed.egress_bytes.saturating_add(outbound);
951    }
952
953    /// Count a policy refusal, and report if that was one too many.
954    ///
955    /// Counted *after* the refusal rather than checked before it: the run is not
956    /// asking permission to be denied, and a refusal that has already been
957    /// journaled has already happened. What the ceiling stops is the *next*
958    /// attempt — which is the one that would learn something.
959    ///
960    /// # Errors
961    ///
962    /// [`BudgetExceeded::Denials`] once the run has been refused more often than
963    /// its budget allows.
964    pub fn record_denial(&mut self) -> Result<(), BudgetExceeded> {
965        self.consumed.denials += 1;
966        if let Some(max) = self.budget.max_denials
967            && self.consumed.denials > max
968        {
969            return Err(BudgetExceeded::Denials { allowed: max });
970        }
971        Ok(())
972    }
973
974    /// Whether this run may be put in front of the policy again.
975    ///
976    /// Checked *before* the policy is consulted, which is the only placement
977    /// that works: a refusal is journaled as it happens, so a ceiling applied
978    /// afterwards bounds nothing an observer can see. Refusing here means the
979    /// attempt produces no new record and no new bit.
980    ///
981    /// # Errors
982    ///
983    /// [`BudgetExceeded::Denials`] once the run has spent its refusals.
984    pub fn admit_policy_check(&self) -> Result<(), BudgetExceeded> {
985        self.check_denials()
986    }
987
988    fn check_denials(&self) -> Result<(), BudgetExceeded> {
989        if let Some(max) = self.budget.max_denials
990            && self.consumed.denials > max
991        {
992            return Err(BudgetExceeded::Denials { allowed: max });
993        }
994        Ok(())
995    }
996
997    fn check_time(&self) -> Result<(), BudgetExceeded> {
998        if let Some(max) = self.budget.max_wallclock_secs
999            && self.consumed.elapsed_secs >= max
1000        {
1001            return Err(BudgetExceeded::Wallclock {
1002                allowed: max,
1003                used: self.consumed.elapsed_secs,
1004            });
1005        }
1006        Ok(())
1007    }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013
1014    #[test]
1015    fn an_unlimited_budget_admits_everything() {
1016        let mut l = Ledger::new(Budget::unlimited());
1017        for _ in 0..1000 {
1018            l.admit_effect(0).unwrap();
1019            l.record_spend(Spend::tokens(10_000));
1020        }
1021        l.admit_step(0).unwrap();
1022    }
1023
1024    #[test]
1025    fn the_step_limit_stops_the_next_step() {
1026        let mut l = Ledger::new(Budget::default().steps(2));
1027        l.admit_step(0).unwrap();
1028        l.record_step();
1029        l.admit_step(0).unwrap();
1030        l.record_step();
1031        assert!(matches!(
1032            l.admit_step(0).unwrap_err(),
1033            BudgetExceeded::Steps { allowed: 2 }
1034        ));
1035    }
1036
1037    /// The blunt instrument: a loop nobody predicted is stopped by count, even
1038    /// when every individual operation is free.
1039    #[test]
1040    fn the_effect_limit_stops_a_runaway_loop_of_free_operations() {
1041        let mut l = Ledger::new(Budget::default().effects(3));
1042        for _ in 0..3 {
1043            l.admit_effect(0).unwrap();
1044            l.record_spend(Spend::default());
1045        }
1046        assert!(matches!(
1047            l.admit_effect(0).unwrap_err(),
1048            BudgetExceeded::Effects {
1049                allowed: 3,
1050                used: 3
1051            }
1052        ));
1053    }
1054
1055    #[test]
1056    fn the_token_limit_reports_where_it_stood() {
1057        let mut l = Ledger::new(Budget::default().tokens(100));
1058        l.admit_effect(0).unwrap();
1059        l.record_spend(Spend::tokens(150));
1060        match l.admit_effect(0).unwrap_err() {
1061            BudgetExceeded::Tokens { allowed, used } => {
1062                assert_eq!(
1063                    (allowed, used),
1064                    (100, 150),
1065                    "raise-the-limit needs the numbers"
1066                );
1067            }
1068            other => panic!("got {other:?}"),
1069        }
1070    }
1071
1072    #[test]
1073    fn the_cost_limit_uses_integers() {
1074        let mut l = Ledger::new(Budget::default().minor_units(500));
1075        l.admit_effect(0).unwrap();
1076        l.record_spend(Spend::money(499));
1077        l.admit_effect(0).expect("still under");
1078        l.record_spend(Spend::money(2));
1079        assert!(matches!(
1080            l.admit_effect(0).unwrap_err(),
1081            BudgetExceeded::Money {
1082                allowed: 500,
1083                used: 501
1084            }
1085        ));
1086    }
1087
1088    #[test]
1089    fn elapsed_time_is_only_checked_when_asked_for() {
1090        let base = Timestamp::from_unix_timestamp(1_700_000_000).expect("a valid instant");
1091        let plus = |secs: i64| {
1092            Timestamp::from_unix_timestamp(base.unix_timestamp() + secs).expect("a valid instant")
1093        };
1094
1095        let mut unbounded = Ledger::new(Budget::default().steps(10));
1096        assert!(!unbounded.tracks_wallclock());
1097        unbounded.observe_clock(base);
1098        unbounded.observe_clock(plus(99_999));
1099        unbounded
1100            .admit_step(0)
1101            .expect("no wall-clock limit was set");
1102
1103        let mut bounded = Ledger::new(Budget::default().wallclock_secs(60));
1104        assert!(bounded.tracks_wallclock());
1105        // Deliberately out of order: a concurrent wave reads the clock in
1106        // whatever order it happens to run, and the ceiling must not depend on
1107        // which reading arrived first.
1108        bounded.observe_clock(plus(61));
1109        bounded.observe_clock(base);
1110        assert!(matches!(
1111            bounded.admit_step(0).unwrap_err(),
1112            BudgetExceeded::Wallclock {
1113                allowed: 60,
1114                used: 61
1115            }
1116        ));
1117    }
1118
1119    /// A negative amount must not be *representable*, not merely refused.
1120    ///
1121    /// Every ceiling in this crate is an accumulate-and-compare, so one negative
1122    /// figure un-spends everything before it. When `minor_units` was an `i64`
1123    /// that was reachable and demonstrated: a €500 standing authority drawn to
1124    /// zero, drawn again for −€400, then drawn for a further €400 — €900 against
1125    /// a €500 mandate, with the ceiling check passing at every step.
1126    ///
1127    /// The fix was the type, so the compiler covers every caller and the three
1128    /// enforcement paths need no rule of their own. What the compiler does *not*
1129    /// cover is the deserialization boundary — a journal record, a stored row, a
1130    /// manifest — and that is what this pins. Flip the field back to a signed
1131    /// integer and these parse successfully instead of failing, which is the
1132    /// only externally visible difference the change makes.
1133    #[test]
1134    fn a_negative_amount_does_not_deserialize() {
1135        serde_json::from_str::<Spend>(r#"{"minor_units":-1}"#)
1136            .expect_err("a negative spend un-spends every ceiling comparing against it");
1137        serde_json::from_str::<Budget>(r#"{"max_minor_units":-1}"#)
1138            .expect_err("a negative ceiling is not a ceiling");
1139
1140        // The positive half, so a refuse-everything change cannot pass: the
1141        // ordinary forms still parse, and to the value they name.
1142        assert_eq!(
1143            serde_json::from_str::<Spend>(r#"{"minor_units":250}"#).expect("an ordinary spend"),
1144            Spend::money(250)
1145        );
1146        assert_eq!(
1147            serde_json::from_str::<Budget>(r#"{"max_minor_units":250}"#)
1148                .expect("an ordinary ceiling")
1149                .max_minor_units,
1150            Some(250)
1151        );
1152    }
1153
1154    /// A wrapping meter would silently re-open a budget that was already blown.
1155    #[test]
1156    fn spend_saturates_rather_than_wrapping() {
1157        let huge = Spend {
1158            tokens: u64::MAX,
1159            minor_units: u64::MAX,
1160        };
1161        let sum = huge.plus(Spend::tokens(10)).plus(Spend::money(10));
1162        assert_eq!(sum.tokens, u64::MAX);
1163        assert_eq!(sum.minor_units, u64::MAX);
1164    }
1165
1166    /// The same journaled figures produce the same verdict — the property that
1167    /// makes an exhausted budget part of history rather than an artefact.
1168    #[test]
1169    fn the_same_figures_produce_the_same_verdict() {
1170        let replay = |spends: &[Spend]| {
1171            let mut l = Ledger::new(Budget::default().tokens(100));
1172            let mut stopped_at = None;
1173            for (i, s) in spends.iter().enumerate() {
1174                if l.admit_effect(0).is_err() {
1175                    stopped_at = Some(i);
1176                    break;
1177                }
1178                l.record_spend(*s);
1179            }
1180            stopped_at
1181        };
1182        let spends = [Spend::tokens(40); 5];
1183        assert_eq!(
1184            replay(&spends),
1185            replay(&spends),
1186            "same figures, same verdict"
1187        );
1188        // 0, 40, 80 all admit; at 120 consumption has reached the 100 limit.
1189        assert_eq!(replay(&spends), Some(3));
1190    }
1191
1192    /// Literal vectors because this enum is part of the durable conclusion
1193    /// format. A serialize-then-deserialize test proves only that two copies of
1194    /// the same mistake agree.
1195    #[test]
1196    fn exhaustion_has_a_stable_tagged_format() {
1197        let vectors = [
1198            (
1199                BudgetExceeded::Steps { allowed: 2 },
1200                r#"{"limit":"steps","allowed":2}"#,
1201            ),
1202            (
1203                BudgetExceeded::Effects {
1204                    allowed: 3,
1205                    used: 4,
1206                },
1207                r#"{"limit":"effects","allowed":3,"used":4}"#,
1208            ),
1209            (
1210                BudgetExceeded::Replans { allowed: 5 },
1211                r#"{"limit":"replans","allowed":5}"#,
1212            ),
1213            (
1214                BudgetExceeded::Denials { allowed: 6 },
1215                r#"{"limit":"denials","allowed":6}"#,
1216            ),
1217            (
1218                BudgetExceeded::Recorded {
1219                    limit: "effect budget exhausted".to_owned(),
1220                    used: "3 performed".to_owned(),
1221                },
1222                r#"{"limit":"recorded","recorded_limit":"effect budget exhausted","used":"3 performed"}"#,
1223            ),
1224            (
1225                BudgetExceeded::Tokens {
1226                    allowed: 7,
1227                    used: 8,
1228                },
1229                r#"{"limit":"tokens","allowed":7,"used":8}"#,
1230            ),
1231            (
1232                BudgetExceeded::Money {
1233                    allowed: 9,
1234                    used: 10,
1235                },
1236                r#"{"limit":"money","allowed":9,"used":10}"#,
1237            ),
1238            (
1239                BudgetExceeded::Wallclock {
1240                    allowed: 11,
1241                    used: 12,
1242                },
1243                r#"{"limit":"wallclock","allowed":11,"used":12}"#,
1244            ),
1245        ];
1246
1247        for (value, literal) in vectors {
1248            assert_eq!(serde_json::to_string(&value).unwrap(), literal);
1249            assert_eq!(
1250                serde_json::from_str::<BudgetExceeded>(literal).unwrap(),
1251                value
1252            );
1253        }
1254    }
1255
1256    /// A metered budget is overshot by at most one operation, because an
1257    /// operation's cost is not known until it has run.
1258    ///
1259    /// Stated as a test so nobody has to discover it by sizing a limit at their
1260    /// actual ceiling.
1261    #[test]
1262    fn a_metered_budget_overshoots_by_at_most_one_operation() {
1263        let mut l = Ledger::new(Budget::default().tokens(100));
1264        l.admit_effect(0).unwrap();
1265        l.record_spend(Spend::tokens(99));
1266        l.admit_effect(0).expect("99 has not reached 100");
1267        l.record_spend(Spend::tokens(1_000_000));
1268
1269        assert!(l.admit_effect(0).is_err(), "but nothing further starts");
1270        assert_eq!(l.consumed().spend.tokens, 1_000_099);
1271    }
1272
1273    /// An effect *count* limit is exact, because counts are known in advance.
1274    #[test]
1275    fn an_effect_count_budget_is_exact() {
1276        let mut l = Ledger::new(Budget::default().effects(2));
1277        l.admit_effect(0).unwrap();
1278        l.record_spend(Spend::tokens(1));
1279        l.admit_effect(0).unwrap();
1280        l.record_spend(Spend::tokens(1));
1281        assert!(l.admit_effect(0).is_err());
1282        assert_eq!(l.consumed().effects, 2, "never more than asked for");
1283    }
1284}
1285
1286#[cfg(test)]
1287mod denial_tests {
1288    use super::*;
1289
1290    /// The ceiling admits exactly as many refusals as it names.
1291    #[test]
1292    fn the_denial_ceiling_admits_what_it_says_and_no_more() {
1293        let mut ledger = Ledger::new(Budget::unlimited().denials(3));
1294        for i in 1..=3 {
1295            assert!(
1296                ledger.record_denial().is_ok(),
1297                "refusal {i} is within a ceiling of 3"
1298            );
1299        }
1300        assert!(matches!(
1301            ledger.record_denial(),
1302            Err(BudgetExceeded::Denials { allowed: 3 })
1303        ));
1304    }
1305
1306    /// Past the ceiling, nothing further is admitted.
1307    ///
1308    /// The half that actually binds: a probing loop swallows the error it gets
1309    /// back, so what stops the probing is refusing the *next* attempt rather
1310    /// than reporting the last one.
1311    #[test]
1312    fn past_the_ceiling_no_further_effect_is_admitted() {
1313        let mut ledger = Ledger::new(Budget::unlimited().denials(1));
1314        assert!(ledger.admit_effect(0).is_ok());
1315        let _ = ledger.record_denial();
1316        assert!(
1317            ledger.admit_effect(0).is_ok(),
1318            "one refusal is within a ceiling of one"
1319        );
1320        let _ = ledger.record_denial();
1321        assert!(
1322            matches!(
1323                ledger.admit_effect(0),
1324                Err(BudgetExceeded::Denials { allowed: 1 })
1325            ),
1326            "past the ceiling the next attempt must be refused before it is \
1327             performed, or the loop keeps learning"
1328        );
1329        assert!(
1330            matches!(
1331                ledger.admit_step(0),
1332                Err(BudgetExceeded::Denials { allowed: 1 })
1333            ),
1334            "and the run must not be admitted to a further step"
1335        );
1336    }
1337
1338    /// No ceiling set means no ceiling applied.
1339    #[test]
1340    fn an_unset_denial_ceiling_does_not_bind() {
1341        let mut ledger = Ledger::new(Budget::unlimited());
1342        for _ in 0..1_000 {
1343            assert!(ledger.record_denial().is_ok());
1344        }
1345        assert!(ledger.admit_effect(0).is_ok());
1346    }
1347}