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}