fd_policy/lease.rs
1//! Delegation-aware budget leases.
2//!
3//! The existing [`BudgetUsage::check_against`](crate::budget::BudgetUsage::check_against)
4//! gate is *stateless*: it compares an accumulated usage snapshot against a
5//! [`Budget`](crate::budget::Budget) cap. That is correct for a single linear
6//! run, but it has a known failure mode under **delegation fan-out** — the
7//! Token-Budgets delegation class ([arXiv:2606.04056]). When a parent task
8//! delegates to N children and each child checks its own spend against the same
9//! parent cap, the children can collectively spend up to `N ×` the cap: every
10//! child believes it owns the whole budget.
11//!
12//! A [`BudgetLease`] closes that gap. All leases descended from one root share a
13//! single atomic remaining-budget pool ([`SharedBudget`]) — the one source of
14//! truth for the entire delegation tree. A child is handed a *sub-lease carved
15//! from* the parent's authority (not a copy placed alongside it), and every
16//! [`BudgetLease::spend`] decrements the single shared pool. Total spend across
17//! parent + all children therefore can never exceed the root cap, regardless of
18//! how the tree fans out or how concurrently the children run.
19//!
20//! Ownership does the rest of the work: [`BudgetLease`] is intentionally **not**
21//! [`Copy`] and **not** [`Clone`], so a lease moved into a delegated child cannot
22//! also be used by the parent — that is a compile error, not a runtime check.
23//! The runtime path ([`LeaseError::Inactive`]) only backstops the cases the
24//! borrow checker cannot see (e.g. a lease retained past [`BudgetLease::close`]).
25//!
26//! [arXiv:2606.04056]: https://arxiv.org/abs/2606.04056
27
28use std::sync::atomic::{AtomicU64, Ordering};
29use std::sync::Arc;
30
31/// Paper this primitive is anchored on (Token Budgets, delegation-fanout class).
32pub const LEASE_ANCHOR: &str = "arXiv:2606.04056";
33
34/// The single shared remaining-budget pool for one delegation tree.
35///
36/// Every [`BudgetLease`] descended from a common root holds an `Arc` to the
37/// *same* `SharedBudget`. Spending is an atomic, lock-free reservation that can
38/// never take the pool below zero — this is the hard ceiling for the whole tree.
39#[derive(Debug)]
40pub struct SharedBudget {
41 /// Budget units still available across the entire delegation tree.
42 remaining: AtomicU64,
43}
44
45impl SharedBudget {
46 fn new(total: u64) -> Arc<Self> {
47 Arc::new(Self {
48 remaining: AtomicU64::new(total),
49 })
50 }
51
52 /// Units still spendable across the whole tree, right now.
53 pub fn remaining(&self) -> u64 {
54 self.remaining.load(Ordering::SeqCst)
55 }
56
57 /// Atomically reserve `amount` from the shared pool.
58 ///
59 /// Returns `true` iff the full `amount` was available and has been removed.
60 /// The reservation is all-or-nothing: a failed take removes nothing, so the
61 /// pool can never go negative even under concurrent contention.
62 fn try_take(&self, amount: u64) -> bool {
63 self.remaining
64 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |cur| {
65 cur.checked_sub(amount)
66 })
67 .is_ok()
68 }
69}
70
71/// Why a lease operation was rejected.
72#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
73pub enum LeaseError {
74 /// The request exceeded this lease's own carved spending authority.
75 #[error("lease cap exceeded: requested {requested}, cap {cap}")]
76 LocalCapExceeded { requested: u64, cap: u64 },
77
78 /// The shared pool for the whole delegation tree is exhausted.
79 #[error("shared budget exhausted: requested {requested}, pool remaining {pool_remaining}")]
80 PoolExhausted { requested: u64, pool_remaining: u64 },
81
82 /// The lease was already consumed (e.g. via [`BudgetLease::close`]).
83 #[error("lease is no longer active")]
84 Inactive,
85}
86
87/// A move-only handle to a slice of a shared delegation budget.
88///
89/// Not [`Copy`], not [`Clone`]: handing a lease to a delegated child *moves* it,
90/// so the parent cannot keep spending the same authority. See the module docs.
91///
92/// # Move semantics are enforced at compile time
93///
94/// [`close`](BudgetLease::close) consumes the lease by value, so any use after
95/// closing fails to compile:
96///
97/// ```compile_fail
98/// use fd_policy::lease::BudgetLease;
99/// let mut root = BudgetLease::root(100);
100/// let _unused = root.close(); // moves `root`
101/// let _ = root.remaining_cap(); // error[E0382]: borrow of moved value
102/// ```
103#[derive(Debug)]
104pub struct BudgetLease {
105 /// The single shared pool, shared by every lease in this delegation tree.
106 pool: Arc<SharedBudget>,
107 /// Spending authority still held *by this lease* (carved from its parent).
108 cap: u64,
109 /// Cleared once the lease is consumed; gates the runtime-rejected path.
110 active: bool,
111}
112
113impl BudgetLease {
114 /// Create the root lease for a fresh delegation tree.
115 ///
116 /// The root holds the full `total` as both its shared pool and its own cap.
117 pub fn root(total: u64) -> Self {
118 Self {
119 pool: SharedBudget::new(total),
120 cap: total,
121 active: true,
122 }
123 }
124
125 /// Spend `amount` units against this lease.
126 ///
127 /// Rejected — spending nothing — if it would exceed *either* this lease's own
128 /// carved cap *or* the shared pool. The pool decrement is atomic, so this is
129 /// the enforcement point that makes total tree spend never exceed the root.
130 pub fn spend(&mut self, amount: u64) -> Result<(), LeaseError> {
131 if !self.active {
132 return Err(LeaseError::Inactive);
133 }
134 if amount == 0 {
135 return Ok(());
136 }
137 if amount > self.cap {
138 return Err(LeaseError::LocalCapExceeded {
139 requested: amount,
140 cap: self.cap,
141 });
142 }
143 // Reserve from the single shared pool — the hard ceiling for the whole
144 // delegation tree. Atomic + all-or-nothing, so concurrent children can
145 // never collectively overshoot.
146 if !self.pool.try_take(amount) {
147 return Err(LeaseError::PoolExhausted {
148 requested: amount,
149 pool_remaining: self.pool.remaining(),
150 });
151 }
152 self.cap -= amount;
153 Ok(())
154 }
155
156 /// Carve a sub-lease of `amount` out of this lease's authority.
157 ///
158 /// The `amount` is moved *out of* the parent's cap (not copied alongside it),
159 /// so authority is conserved: the parent can no longer spend the carved
160 /// slice. The child shares the *same* pool, so its spend draws down the one
161 /// shared remaining-budget. This is the recommended delegation primitive.
162 pub fn delegate(&mut self, amount: u64) -> Result<BudgetLease, LeaseError> {
163 if !self.active {
164 return Err(LeaseError::Inactive);
165 }
166 if amount > self.cap {
167 return Err(LeaseError::LocalCapExceeded {
168 requested: amount,
169 cap: self.cap,
170 });
171 }
172 self.cap -= amount;
173 Ok(BudgetLease {
174 pool: Arc::clone(&self.pool),
175 cap: amount,
176 active: true,
177 })
178 }
179
180 /// Spawn a child that draws from the shared pool, bounded only by whatever is
181 /// globally left right now.
182 ///
183 /// This models the *dangerous* fan-out the paper warns about — a child handed
184 /// authority up to the full remaining budget — done *safely*: because the
185 /// child shares the one atomic pool, N such children still cannot collectively
186 /// overshoot the root cap. (The naive bug copies the remaining-budget per
187 /// child instead of sharing it; that is what overshoots `N ×`.)
188 pub fn subordinate(&self) -> BudgetLease {
189 BudgetLease {
190 pool: Arc::clone(&self.pool),
191 cap: self.pool.remaining(),
192 active: true,
193 }
194 }
195
196 /// Spending authority still held by this lease.
197 pub fn remaining_cap(&self) -> u64 {
198 if self.active {
199 self.cap
200 } else {
201 0
202 }
203 }
204
205 /// Units still spendable across the whole delegation tree.
206 pub fn pool_remaining(&self) -> u64 {
207 self.pool.remaining()
208 }
209
210 /// Whether this lease can still be spent.
211 pub fn is_active(&self) -> bool {
212 self.active
213 }
214
215 /// Consume the lease, returning its unused carved authority.
216 ///
217 /// Takes `self` by value: the move makes any later use a compile error (see
218 /// the type-level doctest). Unused authority is *not* returned to the pool —
219 /// the pool already reflects only actual spend, so under-spending simply
220 /// leaves headroom for the rest of the tree.
221 pub fn close(mut self) -> u64 {
222 let unused = self.cap;
223 self.active = false;
224 self.cap = 0;
225 unused
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use std::thread;
233
234 #[test]
235 fn root_holds_full_budget_on_both_axes() {
236 let lease = BudgetLease::root(100);
237 assert_eq!(lease.remaining_cap(), 100);
238 assert_eq!(lease.pool_remaining(), 100);
239 assert!(lease.is_active());
240 }
241
242 #[test]
243 fn spend_decrements_cap_and_pool_together() {
244 let mut lease = BudgetLease::root(100);
245 lease.spend(30).unwrap();
246 assert_eq!(lease.remaining_cap(), 70);
247 assert_eq!(lease.pool_remaining(), 70);
248 }
249
250 #[test]
251 fn spend_beyond_local_cap_is_rejected_and_spends_nothing() {
252 let mut lease = BudgetLease::root(100);
253 let err = lease.spend(101).unwrap_err();
254 assert_eq!(
255 err,
256 LeaseError::LocalCapExceeded {
257 requested: 101,
258 cap: 100
259 }
260 );
261 // Nothing was taken.
262 assert_eq!(lease.pool_remaining(), 100);
263 }
264
265 #[test]
266 fn delegate_carves_authority_out_of_parent() {
267 let mut parent = BudgetLease::root(100);
268 let child = parent.delegate(40).unwrap();
269 // Authority conserved: 60 parent + 40 child == 100, pool unchanged.
270 assert_eq!(parent.remaining_cap(), 60);
271 assert_eq!(child.remaining_cap(), 40);
272 assert_eq!(parent.pool_remaining(), 100);
273 assert_eq!(child.pool_remaining(), 100);
274 }
275
276 #[test]
277 fn child_spend_draws_down_the_shared_pool() {
278 let mut parent = BudgetLease::root(100);
279 let mut child = parent.delegate(40).unwrap();
280 child.spend(25).unwrap();
281 // The parent sees the child's spend reflected in the shared pool.
282 assert_eq!(parent.pool_remaining(), 75);
283 assert_eq!(child.remaining_cap(), 15);
284 assert_eq!(parent.remaining_cap(), 60); // parent's own authority untouched
285 }
286
287 #[test]
288 fn delegating_more_than_held_is_rejected() {
289 let mut parent = BudgetLease::root(50);
290 assert_eq!(
291 parent.delegate(51).unwrap_err(),
292 LeaseError::LocalCapExceeded {
293 requested: 51,
294 cap: 50
295 }
296 );
297 }
298
299 #[test]
300 fn close_consumes_and_reports_unused_authority() {
301 let mut lease = BudgetLease::root(100);
302 lease.spend(30).unwrap();
303 assert_eq!(lease.close(), 70);
304 }
305
306 /// The paper's experiment: 1 parent → 3 children, concurrent spend.
307 ///
308 /// With a single shared pool the tree can never overshoot the root cap, no
309 /// matter how aggressively the children spend. The naive copied-counter
310 /// baseline (each child gets its own counter) overshoots `~N ×`.
311 #[test]
312 fn fanout_under_concurrent_spend_never_overshoots_root_cap() {
313 const CAP: u64 = 9_000;
314 const CHILDREN: u64 = 3;
315
316 // --- Shared-pool lease: each child may try to spend the WHOLE remaining
317 // budget (over-allocated authority), but all draw from one pool. ---
318 let root = BudgetLease::root(CAP);
319 let handles: Vec<_> = (0..CHILDREN)
320 .map(|_| {
321 // subordinate() hands the child a cap equal to the full remaining
322 // budget — the worst-case fan-out — yet sharing the one pool.
323 let mut child = root.subordinate();
324 thread::spawn(move || {
325 let mut spent = 0u64;
326 // Hammer 1 unit at a time until the shared pool refuses.
327 while child.spend(1).is_ok() {
328 spent += 1;
329 }
330 spent
331 })
332 })
333 .collect();
334
335 let lease_total: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
336
337 // 0/N overshoot: total actual spend is exactly the cap, never more.
338 assert_eq!(lease_total, CAP, "shared-pool fan-out must not overshoot");
339 assert_eq!(root.pool_remaining(), 0);
340
341 // --- Naive copied-counter baseline: each child gets its OWN counter
342 // initialized to the full cap. This is the bug the lease fixes. ---
343 let baseline_handles: Vec<_> = (0..CHILDREN)
344 .map(|_| {
345 thread::spawn(move || {
346 // A private copy of "remaining = CAP" per child.
347 let mut remaining = CAP;
348 let mut spent = 0u64;
349 while remaining > 0 {
350 remaining -= 1;
351 spent += 1;
352 }
353 spent
354 })
355 })
356 .collect();
357
358 let baseline_total: u64 = baseline_handles
359 .into_iter()
360 .map(|h| h.join().unwrap())
361 .sum();
362
363 // The baseline overshoots by ~(N-1)× the cap — documents the failure mode
364 // the shared pool eliminates.
365 assert_eq!(baseline_total, CAP * CHILDREN);
366 assert!(
367 baseline_total > CAP,
368 "naive copied-counter baseline overshoots the cap"
369 );
370 assert!(
371 lease_total < baseline_total,
372 "shared-pool lease must spend strictly less than the copied-counter baseline"
373 );
374 }
375
376 #[test]
377 fn carved_fanout_conserves_authority_exactly() {
378 // Equal carve across 3 children leaves the parent with nothing and the
379 // children collectively holding the whole cap — no slice is duplicated.
380 let mut parent = BudgetLease::root(90);
381 let a = parent.delegate(30).unwrap();
382 let b = parent.delegate(30).unwrap();
383 let c = parent.delegate(30).unwrap();
384 assert_eq!(parent.remaining_cap(), 0);
385 assert_eq!(
386 a.remaining_cap() + b.remaining_cap() + c.remaining_cap(),
387 90
388 );
389 // A fourth carve now has no authority to draw from.
390 assert_eq!(
391 parent.delegate(1).unwrap_err(),
392 LeaseError::LocalCapExceeded {
393 requested: 1,
394 cap: 0
395 }
396 );
397 }
398
399 #[test]
400 fn spend_after_close_is_runtime_rejected() {
401 // The borrow checker stops use-after-move; for any handle that outlives
402 // its logical lifetime by other means, the active flag backstops it.
403 let mut lease = BudgetLease::root(10);
404 lease.active = false; // simulate a consumed-but-retained handle
405 assert_eq!(lease.spend(1).unwrap_err(), LeaseError::Inactive);
406 assert_eq!(lease.delegate(1).unwrap_err(), LeaseError::Inactive);
407 }
408}