Skip to main content

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}