Skip to main content

chio_kernel_core/
budget_split.rs

1//! Sibling-sum budget enforcement for delegated capability tokens.
2//!
3//! `CapabilityToken::validate_schema` already enforces the per-token cap
4//! `budget_share_bps <= 10_000`. That check is necessary but not sufficient:
5//! it does not see siblings. A parent at `5_000` bps could mint two children
6//! at `5_000` bps each, and per-token validation would happily accept both,
7//! letting the children jointly claim 100% of the parent's authority while
8//! the parent itself only owns 50%.
9//!
10//! [`BudgetSplit`] and the [`BudgetRegistry`] trait close that gap. When a
11//! verifier admits a freshly delegated child, it asks the registry whether
12//! the parent has enough remaining headroom. If the running sum of admitted
13//! sibling shares plus the new child's share would exceed the parent's
14//! share, the registry rejects the child and verification fails closed.
15//!
16//! The split type is intentionally pure: it owns no clock, no I/O, and no
17//! revocation state. Callers that need a hosted in-memory registry use
18//! [`InMemoryBudgetRegistry`] (gated behind the crate `std` feature).
19//! `no_std` consumers can implement the [`BudgetRegistry`] trait against
20//! their own storage.
21//!
22//! ## Overflow safety
23//!
24//! [`BudgetSplit::current_total_child_bps`] returns a `u32` and the admit
25//! check uses `u32` arithmetic so two `u16::MAX` siblings cannot overflow
26//! into a wraparound that silently passes the cap. The parent share itself
27//! is bounded by [`MAX_BUDGET_SHARE_BPS`] which the per-token validator
28//! enforces at load time.
29
30use alloc::collections::BTreeMap;
31use alloc::string::{String, ToString};
32use core::fmt;
33
34/// Hard ceiling on any single token's budget share in basis points.
35///
36/// 10000 bps = 100%. Per-token validation already rejects values above this
37/// threshold; the constant is re-exported so registry implementations can
38/// share the same bound.
39pub const MAX_BUDGET_SHARE_BPS: u16 = 10_000;
40
41/// A single admitted child edge: the share it claimed plus a reference count
42/// of the active evaluations currently holding it.
43///
44/// The share is charged against the parent as long as the edge exists. The
45/// `holders` refcount exists because the registry stores exactly ONE edge per
46/// (parent, child) pair, yet OVERLAPPING evaluations of the same delegated
47/// capability each depend on that single edge concurrently. A boolean "who
48/// inserted it" owner is unsound: if the inserting evaluation is cancelled it
49/// would free the edge while a re-admitting sibling evaluation still holds the
50/// capability, letting an oversubscribing sibling be admitted against the
51/// wrongly-returned share. Every [`AdmitMode::Lease`] admit (fresh insert OR
52/// idempotent re-admit) takes one lease (`holders += 1`); every release drops
53/// one (`holders -= 1`). The edge is freed - and its share returned to the
54/// parent - only when the LAST real holder releases (`holders` reaches 0).
55///
56/// `holders` counts ONLY live releasable holders. An edge can legitimately
57/// have `holders == 0`: a verifier-only surface (see [`AdmitMode::VerifyOnly`])
58/// commits a fresh child's share for sibling-sum accounting without acquiring a
59/// lease, because it has no cleanup path that would ever release one. Such a
60/// committed edge is never pinned past its real holders: a later real dispatch
61/// that leases it (`0 -> 1`) and releases (`1 -> 0`) frees the edge, and a
62/// verify-only re-admit never touches the count.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct ChildAdmission {
65    /// The share this child claimed when it was first admitted, in basis
66    /// points. Every lease on this edge shares the same recorded value; a
67    /// re-admit with a different share is rejected as a [`BudgetSplitError`].
68    pub share_bps: u16,
69    /// Count of active evaluations holding a lease on this edge. Access is
70    /// serialized by the registry's lock, so a plain counter (rather than an
71    /// atomic) is sufficient: the increment/decrement is always performed
72    /// inside the same critical section as the insert/remove decision.
73    pub holders: usize,
74}
75
76/// A live record of how much of a parent's budget has already been delegated
77/// to admitted children.
78///
79/// The struct is owned by a [`BudgetRegistry`] implementation. Each verified
80/// delegation either admits a new child (incrementing the running sum) or
81/// fails closed because the proposed share would push the sum past the
82/// parent's own share.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct BudgetSplit {
85    /// Capability ID of the parent token whose budget is being split.
86    pub parent_token_id: String,
87    /// The parent's own share in basis points. Bounded by
88    /// [`MAX_BUDGET_SHARE_BPS`].
89    pub parent_share_bps: u16,
90    /// Map from child capability ID to its admitted share and active-holder
91    /// reference count. See [`ChildAdmission`] for why the count is required.
92    pub children: BTreeMap<String, ChildAdmission>,
93}
94
95/// Errors raised by [`BudgetSplit`] admission and release operations.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum BudgetSplitError {
98    /// The proposed child share alone exceeds the per-token cap of
99    /// [`MAX_BUDGET_SHARE_BPS`]. Per-token validation usually catches this
100    /// first; the registry repeats the check so a bypass cannot widen the
101    /// surface.
102    ChildShareExceedsCap {
103        /// The child capability ID that was rejected.
104        child_id: String,
105        /// The proposed child share in basis points.
106        share_bps: u16,
107    },
108    /// Admitting this child would push the sum of sibling shares past the
109    /// parent's own share. The first sibling that hits this branch is
110    /// rejected; previously admitted siblings are not retroactively evicted.
111    OversubscribedSiblings {
112        /// The child capability ID that was rejected.
113        child_id: String,
114        /// The proposed child share in basis points.
115        share_bps: u16,
116        /// The sum of already-admitted siblings, in basis points.
117        current_total_child_bps: u32,
118        /// The parent's own share, in basis points.
119        parent_share_bps: u16,
120    },
121    /// The parent token is unknown to the registry. This happens when the
122    /// verifier sees a delegation chain whose root was never registered, or
123    /// after a parent has been evicted.
124    UnknownParent {
125        /// The parent capability ID that was looked up.
126        parent_token_id: String,
127    },
128    /// The child token has already been admitted under this parent. The
129    /// registry is idempotent: re-admitting the same child with the same
130    /// share takes an additional holder lease on the existing edge and
131    /// succeeds, but a different share is a hard failure because it would let
132    /// an attacker rewrite the split after the fact.
133    DuplicateChild {
134        /// The child capability ID that was already present.
135        child_id: String,
136    },
137    /// The release path was asked to remove a child with a different share
138    /// than the one originally admitted. This is a hard failure because it
139    /// means the caller is no longer unwinding the same delegation edge.
140    ReleaseShareMismatch {
141        /// The child capability ID whose admitted share differed.
142        child_id: String,
143        /// The share supplied by the caller during release.
144        expected_share_bps: u16,
145        /// The share recorded during admission.
146        actual_share_bps: u16,
147    },
148}
149
150impl fmt::Display for BudgetSplitError {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        match self {
153            BudgetSplitError::ChildShareExceedsCap {
154                child_id,
155                share_bps,
156            } => write!(
157                f,
158                "child {child_id} share {share_bps} bps exceeds the {} bps per-token cap",
159                MAX_BUDGET_SHARE_BPS
160            ),
161            BudgetSplitError::OversubscribedSiblings {
162                child_id,
163                share_bps,
164                current_total_child_bps,
165                parent_share_bps,
166            } => write!(
167                f,
168                "child {child_id} share {share_bps} bps + sibling sum {current_total_child_bps} bps > parent share {parent_share_bps} bps"
169            ),
170            BudgetSplitError::UnknownParent { parent_token_id } => {
171                write!(f, "parent capability {parent_token_id} is not registered")
172            }
173            BudgetSplitError::DuplicateChild { child_id } => write!(
174                f,
175                "child capability {child_id} already admitted under this parent with a different share"
176            ),
177            BudgetSplitError::ReleaseShareMismatch {
178                child_id,
179                expected_share_bps,
180                actual_share_bps,
181            } => write!(
182                f,
183                "child capability {child_id} release share {expected_share_bps} bps does not match admitted share {actual_share_bps} bps"
184            ),
185        }
186    }
187}
188
189/// Whether an admit acquires a releasable holder lease or only checks
190/// admissibility.
191///
192/// Verifier-only surfaces (portable/preflight verdicts, adapter one-shot
193/// evaluations) answer "would this child admit?" as part of producing a
194/// verdict, but they have NO cleanup path that would release a lease. Taking a
195/// holder lease on those paths leaks the refcount upward forever: a later real
196/// dispatch that unwinds its own lease can never drive `holders` back to zero,
197/// so the edge stays pinned and oversubscribing siblings are wrongly denied
198/// until the parent is evicted. `VerifyOnly` runs the same admissibility checks
199/// (and commits a fresh child's share for sibling-sum accounting) WITHOUT
200/// acquiring a lease it will never release.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum AdmitMode {
203    /// Acquire one holder lease the caller MUST release on its cleanup/drop
204    /// path. A fresh edge is inserted with `holders == 1`; an idempotent
205    /// re-admit takes an ADDITIONAL lease (`holders += 1`).
206    Lease,
207    /// Check admissibility only. A fresh admissible child commits its share
208    /// with `holders == 0` (charged for sibling-sum accounting, but no
209    /// releasable holder exists); an already-present edge is left completely
210    /// unchanged. Never increments `holders`, so a surface with no release path
211    /// cannot pin the edge past its real holders.
212    VerifyOnly,
213}
214
215impl BudgetSplit {
216    /// Build an empty split for `parent_token_id` with the given parent
217    /// share. The parent share is clamped to [`MAX_BUDGET_SHARE_BPS`] by the
218    /// per-token validator before this constructor is called; it is the
219    /// caller's responsibility not to fabricate a higher value here.
220    #[must_use]
221    pub fn new(parent_token_id: String, parent_share_bps: u16) -> Self {
222        Self {
223            parent_token_id,
224            parent_share_bps,
225            children: BTreeMap::new(),
226        }
227    }
228
229    /// Return the running sum of admitted sibling shares as a `u32` to avoid
230    /// overflow even on a maximally adversarial set of admitted children.
231    ///
232    /// Each present edge counts its share exactly once regardless of how many
233    /// overlapping evaluations hold it: the share is charged against the parent
234    /// per edge, not per holder.
235    #[must_use]
236    pub fn current_total_child_bps(&self) -> u32 {
237        self.children
238            .values()
239            .map(|admission| u32::from(admission.share_bps))
240            .sum()
241    }
242
243    /// Return the share currently recorded for `child_id`, if this child has
244    /// an admitted edge under this parent (at least one active holder).
245    #[must_use]
246    pub fn child_share_bps(&self, child_id: &str) -> Option<u16> {
247        self.children
248            .get(child_id)
249            .map(|admission| admission.share_bps)
250    }
251
252    /// Return the number of active evaluations currently holding a lease on
253    /// `child_id` under this parent, or `None` when no edge exists. Used by
254    /// tests and operator dashboards to inspect concurrent-holder depth.
255    #[must_use]
256    pub fn child_holders(&self, child_id: &str) -> Option<usize> {
257        self.children
258            .get(child_id)
259            .map(|admission| admission.holders)
260    }
261
262    /// Try to admit a child under this parent, acquiring one holder lease.
263    ///
264    /// Returns `Ok(())` when the child is admitted. A fresh child inserts a new
265    /// edge with one holder and increments the running sum. Re-admitting the
266    /// same child id with the same share is idempotent for the running sum but
267    /// takes an ADDITIONAL holder lease (`holders += 1`) so an overlapping
268    /// evaluation's later release cannot free an edge another live evaluation
269    /// still depends on.
270    ///
271    /// Returns [`BudgetSplitError`] - acquiring NO lease - when the child share
272    /// alone exceeds the per-token cap, when the proposed share would
273    /// oversubscribe the parent, or when a different share has already been
274    /// recorded for this child id.
275    pub fn try_admit_child(
276        &mut self,
277        child_id: String,
278        share_bps: u16,
279    ) -> Result<(), BudgetSplitError> {
280        self.admit_child(child_id, share_bps, AdmitMode::Lease)
281    }
282
283    /// Check whether `child_id` at `share_bps` would admit under this parent
284    /// WITHOUT acquiring a holder lease (see [`AdmitMode::VerifyOnly`]).
285    ///
286    /// Runs the same per-token cap, sibling-sum oversubscription, and
287    /// duplicate-share checks as [`Self::try_admit_child`]. A fresh admissible
288    /// child commits its share (so a later sibling sees it in the running sum)
289    /// but records `holders == 0`; an already-present child is left untouched.
290    /// This is the entry point for verifier-only surfaces (portable/preflight
291    /// verdicts, adapter one-shot evaluations) that produce a verdict but have
292    /// no cleanup path to release a lease. Because it never increments
293    /// `holders`, a later real dispatch that unwinds its own lease can still
294    /// drive the edge back to zero and free it.
295    pub fn verify_child_admission(
296        &mut self,
297        child_id: String,
298        share_bps: u16,
299    ) -> Result<(), BudgetSplitError> {
300        self.admit_child(child_id, share_bps, AdmitMode::VerifyOnly)
301    }
302
303    fn admit_child(
304        &mut self,
305        child_id: String,
306        share_bps: u16,
307        mode: AdmitMode,
308    ) -> Result<(), BudgetSplitError> {
309        if share_bps > MAX_BUDGET_SHARE_BPS {
310            return Err(BudgetSplitError::ChildShareExceedsCap {
311                child_id,
312                share_bps,
313            });
314        }
315        if let Some(existing) = self.children.get_mut(&child_id) {
316            if existing.share_bps == share_bps {
317                // Idempotent re-admit: the share is already charged against the
318                // parent, so never touch the running sum. A Lease takes an
319                // EXTRA holder on the existing edge so an overlapping
320                // evaluation's later release cannot free an edge another live
321                // evaluation still depends on. A VerifyOnly check leaves the
322                // holder count untouched: it never releases, so incrementing
323                // here would pin the edge upward forever.
324                if let AdmitMode::Lease = mode {
325                    existing.holders = existing.holders.saturating_add(1);
326                }
327                return Ok(());
328            }
329            return Err(BudgetSplitError::DuplicateChild { child_id });
330        }
331        let running = self.current_total_child_bps();
332        let proposed_total = running.saturating_add(u32::from(share_bps));
333        if proposed_total > u32::from(self.parent_share_bps) {
334            return Err(BudgetSplitError::OversubscribedSiblings {
335                child_id,
336                share_bps,
337                current_total_child_bps: running,
338                parent_share_bps: self.parent_share_bps,
339            });
340        }
341        // Fresh edge. A Lease records itself as the first live holder; a
342        // VerifyOnly commit charges the share for sibling-sum accounting but
343        // records NO holder, so it never has to be released to free the edge.
344        let holders = match mode {
345            AdmitMode::Lease => 1,
346            AdmitMode::VerifyOnly => 0,
347        };
348        self.children
349            .insert(child_id, ChildAdmission { share_bps, holders });
350        Ok(())
351    }
352
353    /// Release one holder lease on a child admission recorded under this parent.
354    ///
355    /// Missing children are treated as already released so pre-dispatch cleanup
356    /// paths can call this after both admission failures and later denial
357    /// failures without branching on partial state. A mismatched share is still
358    /// rejected because it indicates the caller is trying to unwind a different
359    /// child edge; a mismatch drops NO lease.
360    ///
361    /// The edge is removed - returning its share to the parent - only when the
362    /// LAST holder releases (`holders` reaches 0). A non-last release just
363    /// decrements the count, keeping the share charged so an overlapping
364    /// evaluation that still holds the edge stays protected against an
365    /// oversubscribing sibling. Fail-closed: over-releasing another
366    /// evaluation's live share (a budget bypass) is the worse failure, so the
367    /// edge is never freed while a holder remains.
368    pub fn release_child(
369        &mut self,
370        child_id: &str,
371        expected_share_bps: u16,
372    ) -> Result<(), BudgetSplitError> {
373        let Some(admission) = self.children.get_mut(child_id) else {
374            return Ok(());
375        };
376        if admission.share_bps != expected_share_bps {
377            return Err(BudgetSplitError::ReleaseShareMismatch {
378                child_id: child_id.to_string(),
379                expected_share_bps,
380                actual_share_bps: admission.share_bps,
381            });
382        }
383        admission.holders = admission.holders.saturating_sub(1);
384        if admission.holders == 0 {
385            let _ = self.children.remove(child_id);
386        }
387        Ok(())
388    }
389}
390
391/// A registry of live [`BudgetSplit`]s, keyed by parent capability id.
392///
393/// The verifier calls [`BudgetRegistry::try_admit_child`] before issuing a
394/// `VerifiedCapability` for any token whose `delegation_chain` is non-empty.
395/// `register_parent` is called when a new parent token enters the system,
396/// and `evict_parent` is called when a parent is revoked or expires.
397///
398/// A federated registry that gossips splits across kernels has the same
399/// trait surface; the in-memory implementation in this crate only models
400/// single-process enforcement.
401pub trait BudgetRegistry {
402    /// Register a parent token's share so subsequent delegations can be
403    /// admitted against it. Re-registering the same parent with the same
404    /// share is idempotent. Re-registering with a different share is a hard
405    /// failure: the caller probably fed two distinct tokens with the same
406    /// id, which is a delegation-graph bug.
407    fn register_parent(
408        &mut self,
409        parent_token_id: String,
410        parent_share_bps: u16,
411    ) -> Result<(), BudgetSplitError>;
412
413    /// Try to admit a child token under the given parent.
414    ///
415    /// Unknown parents fail closed. Callers that have verifier-owned parent
416    /// lineage or a parent snapshot must call [`BudgetRegistry::register_parent`]
417    /// before admitting children. This prevents a verifier from fabricating a
418    /// missing parent share at [`MAX_BUDGET_SHARE_BPS`].
419    fn try_admit_child(
420        &mut self,
421        parent_token_id: &str,
422        child_token_id: String,
423        share_bps: u16,
424    ) -> Result<(), BudgetSplitError>;
425
426    /// Check whether a child token would admit under the given parent WITHOUT
427    /// acquiring a holder lease.
428    ///
429    /// Same fail-closed checks as [`BudgetRegistry::try_admit_child`] (unknown
430    /// parents fail closed), but a fresh admissible child is committed with no
431    /// releasable holder and an already-present child is left untouched. This
432    /// is the entry point for verifier-only surfaces that produce a verdict but
433    /// never release a lease. See [`AdmitMode::VerifyOnly`].
434    fn verify_child_admission(
435        &mut self,
436        parent_token_id: &str,
437        child_token_id: String,
438        share_bps: u16,
439    ) -> Result<(), BudgetSplitError>;
440
441    /// Release a previously admitted child token under the given parent.
442    ///
443    /// Unknown parents and missing children are idempotent no-ops so cleanup
444    /// can safely run after revocation or after an admission attempt that
445    /// failed before mutating the registry. Share mismatches fail closed.
446    fn release_child(
447        &mut self,
448        parent_token_id: &str,
449        child_token_id: &str,
450        expected_share_bps: u16,
451    ) -> Result<(), BudgetSplitError>;
452
453    /// Drop the parent's split from the registry. Idempotent; calling
454    /// `evict_parent` on an unregistered parent is a no-op so revocation
455    /// races do not abort verification.
456    fn evict_parent(&mut self, parent_token_id: &str);
457}
458
459/// A no-op [`BudgetRegistry`] used by callers that have not yet wired the
460/// budget plumbing.
461///
462/// `register_parent` and `try_admit_child` always return `Ok(())` and the
463/// registry never tracks state. This is suitable for compatibility entry points
464/// that only check signature, issuer trust, and time bounds; new code
465/// should use a real registry so sibling oversubscription is rejected.
466#[derive(Debug, Default, Clone, Copy)]
467pub struct NoopBudgetRegistry;
468
469impl BudgetRegistry for NoopBudgetRegistry {
470    fn register_parent(
471        &mut self,
472        _parent_token_id: String,
473        _parent_share_bps: u16,
474    ) -> Result<(), BudgetSplitError> {
475        Ok(())
476    }
477
478    fn try_admit_child(
479        &mut self,
480        _parent_token_id: &str,
481        _child_token_id: String,
482        _share_bps: u16,
483    ) -> Result<(), BudgetSplitError> {
484        Ok(())
485    }
486
487    fn verify_child_admission(
488        &mut self,
489        _parent_token_id: &str,
490        _child_token_id: String,
491        _share_bps: u16,
492    ) -> Result<(), BudgetSplitError> {
493        Ok(())
494    }
495
496    fn release_child(
497        &mut self,
498        _parent_token_id: &str,
499        _child_token_id: &str,
500        _expected_share_bps: u16,
501    ) -> Result<(), BudgetSplitError> {
502        Ok(())
503    }
504
505    fn evict_parent(&mut self, _parent_token_id: &str) {}
506}
507
508/// Pure in-memory [`BudgetRegistry`] backed by a [`BTreeMap`].
509///
510/// This implementation is `no_std` friendly: it keeps a deterministic map
511/// keyed by parent capability id and never reaches for clocks, locks, or
512/// allocators outside `alloc`. Hosted callers that need shared mutable
513/// access across threads should wrap it in their own `Mutex` / `RwLock`.
514#[derive(Debug, Default, Clone)]
515pub struct InMemoryBudgetRegistry {
516    splits: BTreeMap<String, BudgetSplit>,
517}
518
519impl InMemoryBudgetRegistry {
520    /// Build an empty registry.
521    #[must_use]
522    pub fn new() -> Self {
523        Self {
524            splits: BTreeMap::new(),
525        }
526    }
527
528    /// Borrow the live split for `parent_token_id`, if any. Useful for
529    /// tests and operator dashboards that want to inspect headroom.
530    #[must_use]
531    pub fn split(&self, parent_token_id: &str) -> Option<&BudgetSplit> {
532        self.splits.get(parent_token_id)
533    }
534}
535
536impl BudgetRegistry for InMemoryBudgetRegistry {
537    fn register_parent(
538        &mut self,
539        parent_token_id: String,
540        parent_share_bps: u16,
541    ) -> Result<(), BudgetSplitError> {
542        if parent_share_bps > MAX_BUDGET_SHARE_BPS {
543            return Err(BudgetSplitError::ChildShareExceedsCap {
544                child_id: parent_token_id,
545                share_bps: parent_share_bps,
546            });
547        }
548        if let Some(existing) = self.splits.get(&parent_token_id) {
549            if existing.parent_share_bps == parent_share_bps {
550                return Ok(());
551            }
552            return Err(BudgetSplitError::DuplicateChild {
553                child_id: parent_token_id,
554            });
555        }
556        self.splits.insert(
557            parent_token_id.clone(),
558            BudgetSplit::new(parent_token_id, parent_share_bps),
559        );
560        Ok(())
561    }
562
563    fn try_admit_child(
564        &mut self,
565        parent_token_id: &str,
566        child_token_id: String,
567        share_bps: u16,
568    ) -> Result<(), BudgetSplitError> {
569        match self.splits.get_mut(parent_token_id) {
570            Some(split) => split.try_admit_child(child_token_id, share_bps),
571            None => Err(BudgetSplitError::UnknownParent {
572                parent_token_id: parent_token_id.into(),
573            }),
574        }
575    }
576
577    fn verify_child_admission(
578        &mut self,
579        parent_token_id: &str,
580        child_token_id: String,
581        share_bps: u16,
582    ) -> Result<(), BudgetSplitError> {
583        match self.splits.get_mut(parent_token_id) {
584            Some(split) => split.verify_child_admission(child_token_id, share_bps),
585            None => Err(BudgetSplitError::UnknownParent {
586                parent_token_id: parent_token_id.into(),
587            }),
588        }
589    }
590
591    fn release_child(
592        &mut self,
593        parent_token_id: &str,
594        child_token_id: &str,
595        expected_share_bps: u16,
596    ) -> Result<(), BudgetSplitError> {
597        match self.splits.get_mut(parent_token_id) {
598            Some(split) => split.release_child(child_token_id, expected_share_bps),
599            None => Ok(()),
600        }
601    }
602
603    fn evict_parent(&mut self, parent_token_id: &str) {
604        let _ = self.splits.remove(parent_token_id);
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use alloc::string::ToString;
612
613    #[test]
614    fn empty_split_total_is_zero() {
615        let split = BudgetSplit::new("parent".to_string(), 5_000);
616        assert_eq!(split.current_total_child_bps(), 0);
617    }
618
619    #[test]
620    fn admit_first_child_under_parent_succeeds() {
621        let mut split = BudgetSplit::new("parent".to_string(), 5_000);
622        split
623            .try_admit_child("child-a".to_string(), 4_000)
624            .expect("first child fits");
625        assert_eq!(split.current_total_child_bps(), 4_000);
626    }
627
628    #[test]
629    fn second_oversubscribed_sibling_is_rejected() {
630        let mut split = BudgetSplit::new("parent".to_string(), 5_000);
631        split
632            .try_admit_child("child-a".to_string(), 4_000)
633            .expect("first child fits");
634        let err = split
635            .try_admit_child("child-b".to_string(), 4_000)
636            .expect_err("second child must oversubscribe");
637        match err {
638            BudgetSplitError::OversubscribedSiblings {
639                child_id,
640                share_bps,
641                current_total_child_bps,
642                parent_share_bps,
643            } => {
644                assert_eq!(child_id, "child-b");
645                assert_eq!(share_bps, 4_000);
646                assert_eq!(current_total_child_bps, 4_000);
647                assert_eq!(parent_share_bps, 5_000);
648            }
649            other => panic!("unexpected error: {other:?}"),
650        }
651        // Failed child must not be recorded.
652        assert_eq!(split.current_total_child_bps(), 4_000);
653    }
654
655    #[test]
656    fn child_share_exceeding_cap_is_rejected() {
657        let mut split = BudgetSplit::new("parent".to_string(), MAX_BUDGET_SHARE_BPS);
658        let err = split
659            .try_admit_child("child-a".to_string(), MAX_BUDGET_SHARE_BPS + 1)
660            .expect_err("over-cap share must fail");
661        assert!(matches!(err, BudgetSplitError::ChildShareExceedsCap { .. }));
662    }
663
664    #[test]
665    fn duplicate_child_with_same_share_is_idempotent() {
666        let mut split = BudgetSplit::new("parent".to_string(), 5_000);
667        split
668            .try_admit_child("child-a".to_string(), 4_000)
669            .expect("first admit");
670        split
671            .try_admit_child("child-a".to_string(), 4_000)
672            .expect("idempotent re-admit");
673        assert_eq!(split.current_total_child_bps(), 4_000);
674    }
675
676    #[test]
677    fn duplicate_child_with_different_share_fails() {
678        let mut split = BudgetSplit::new("parent".to_string(), 5_000);
679        split
680            .try_admit_child("child-a".to_string(), 2_000)
681            .expect("first admit");
682        let err = split
683            .try_admit_child("child-a".to_string(), 3_000)
684            .expect_err("different share must fail");
685        assert!(matches!(err, BudgetSplitError::DuplicateChild { .. }));
686        // Original share is preserved.
687        assert_eq!(split.current_total_child_bps(), 2_000);
688    }
689
690    #[test]
691    fn release_child_restores_parent_headroom() {
692        let mut split = BudgetSplit::new("parent".to_string(), 5_000);
693        split
694            .try_admit_child("child-a".to_string(), 4_000)
695            .expect("child admits");
696        assert_eq!(split.current_total_child_bps(), 4_000);
697
698        split
699            .release_child("child-a", 4_000)
700            .expect("matching release succeeds");
701        assert_eq!(split.current_total_child_bps(), 0);
702
703        split
704            .try_admit_child("child-b".to_string(), 5_000)
705            .expect("released child should restore full headroom");
706    }
707
708    #[test]
709    fn release_child_rejects_share_mismatch() {
710        let mut split = BudgetSplit::new("parent".to_string(), 5_000);
711        split
712            .try_admit_child("child-a".to_string(), 4_000)
713            .expect("child admits");
714
715        let error = split
716            .release_child("child-a", 3_000)
717            .expect_err("mismatched release must fail");
718        assert!(matches!(
719            error,
720            BudgetSplitError::ReleaseShareMismatch { .. }
721        ));
722        assert_eq!(split.current_total_child_bps(), 4_000);
723    }
724
725    #[test]
726    fn in_memory_registry_rejects_unknown_parent() {
727        let mut registry = InMemoryBudgetRegistry::new();
728        let err = registry
729            .try_admit_child("missing", "child".to_string(), 1_000)
730            .expect_err("unknown parent must fail closed");
731        assert!(matches!(err, BudgetSplitError::UnknownParent { .. }));
732        assert!(registry.split("missing").is_none());
733    }
734
735    #[test]
736    fn registered_parent_enforces_sibling_sum_across_calls() {
737        let mut registry = InMemoryBudgetRegistry::new();
738        registry
739            .register_parent("p".to_string(), MAX_BUDGET_SHARE_BPS)
740            .expect("register parent snapshot");
741        registry
742            .try_admit_child("p", "child-a".to_string(), 6_000)
743            .expect("first child admits under registered parent");
744        let err = registry
745            .try_admit_child("p", "child-b".to_string(), 5_000)
746            .expect_err("second sibling must oversubscribe (6_000 + 5_000 > 10_000)");
747        assert!(matches!(
748            err,
749            BudgetSplitError::OversubscribedSiblings { .. }
750        ));
751    }
752
753    #[test]
754    fn in_memory_registry_release_is_idempotent_and_restores_headroom() {
755        let mut registry = InMemoryBudgetRegistry::new();
756        registry
757            .register_parent("p".to_string(), 5_000)
758            .expect("register parent snapshot");
759        registry
760            .try_admit_child("p", "child-a".to_string(), 4_000)
761            .expect("child admits");
762        registry
763            .release_child("p", "child-a", 4_000)
764            .expect("matching release succeeds");
765        registry
766            .release_child("p", "child-a", 4_000)
767            .expect("missing child release is idempotent");
768        registry
769            .release_child("missing-parent", "child-a", 4_000)
770            .expect("missing parent release is idempotent");
771        registry
772            .try_admit_child("p", "child-b".to_string(), 5_000)
773            .expect("released child should restore full headroom");
774    }
775
776    #[test]
777    fn overlapping_holders_release_frees_only_on_last() {
778        // The make-or-break concurrency case. Two OVERLAPPING evaluations A and
779        // B admit the SAME delegated child (each takes a lease; holders == 2).
780        // A cleans up first: the edge must NOT be freed because B still holds
781        // it, so an oversubscribing sibling stays DENIED. Only once B also
782        // releases (holders reaches 0) is the edge freed and the share returned
783        // to the parent, admitting the sibling. RED under the old boolean-owner
784        // model: A's release would free B's live edge and the sibling would be
785        // wrongly admitted while B still holds the capability.
786        let mut registry = InMemoryBudgetRegistry::new();
787        registry
788            .register_parent("p".to_string(), MAX_BUDGET_SHARE_BPS)
789            .expect("register parent");
790
791        // Eval A admits the child at 6_000 bps (fresh edge, one lease).
792        registry
793            .try_admit_child("p", "child".to_string(), 6_000)
794            .expect("A admits the child");
795        // Eval B idempotently re-admits the SAME child + share (second lease).
796        registry
797            .try_admit_child("p", "child".to_string(), 6_000)
798            .expect("B re-admits the same child");
799        assert_eq!(
800            registry
801                .split("p")
802                .and_then(|split| split.child_holders("child")),
803            Some(2),
804            "both overlapping evaluations must hold the single child edge"
805        );
806
807        // A cleans up. One lease drops (holders 2 -> 1); the edge survives.
808        registry
809            .release_child("p", "child", 6_000)
810            .expect("A releases its lease");
811        assert_eq!(
812            registry
813                .split("p")
814                .and_then(|split| split.child_holders("child")),
815            Some(1),
816            "A's cleanup must NOT free the edge B still holds"
817        );
818        // 6_000 (still held by B) + 5_000 > 10_000 parent share: sibling denied.
819        let denied = registry
820            .try_admit_child("p", "sibling".to_string(), 5_000)
821            .expect_err("an oversubscribing sibling must stay denied while B holds the child");
822        assert!(matches!(
823            denied,
824            BudgetSplitError::OversubscribedSiblings { .. }
825        ));
826
827        // B cleans up. Last lease drops (holders 1 -> 0); the edge is freed.
828        registry
829            .release_child("p", "child", 6_000)
830            .expect("B releases the last lease");
831        assert_eq!(
832            registry
833                .split("p")
834                .and_then(|split| split.child_holders("child")),
835            None,
836            "the last release must free the edge and return the share to the parent"
837        );
838        // The freed 6_000 share is now available: the sibling can admit.
839        registry
840            .try_admit_child("p", "sibling".to_string(), 5_000)
841            .expect("the sibling admits once both holders have released");
842    }
843
844    #[test]
845    fn single_holder_release_frees_edge_no_leak() {
846        // A single evaluation admits then releases: the edge is freed at
847        // holders 1 -> 0 with no leak, restoring the parent's full headroom.
848        let mut registry = InMemoryBudgetRegistry::new();
849        registry
850            .register_parent("p".to_string(), MAX_BUDGET_SHARE_BPS)
851            .expect("register parent");
852        registry
853            .try_admit_child("p", "child".to_string(), 6_000)
854            .expect("child admits");
855        assert_eq!(
856            registry
857                .split("p")
858                .and_then(|split| split.child_holders("child")),
859            Some(1)
860        );
861        registry
862            .release_child("p", "child", 6_000)
863            .expect("single holder release frees the edge");
864        assert_eq!(
865            registry
866                .split("p")
867                .and_then(|split| split.child_holders("child")),
868            None,
869            "the sole holder's release must free the edge (no leak)"
870        );
871        registry
872            .try_admit_child("p", "sibling".to_string(), MAX_BUDGET_SHARE_BPS)
873            .expect("full parent headroom is restored after the release");
874    }
875
876    #[test]
877    fn verify_only_readmit_does_not_change_holders() {
878        // A verifier-only re-admit (portable/preflight verdict) has NO release
879        // path, so it MUST NOT take a holder lease. A real dispatch holds
880        // child's edge (holders == 1). Repeated verify-only re-admits of the
881        // SAME child must leave the count at the real-holder count, so the
882        // real dispatch's later release still frees the edge. If a verify-only
883        // re-admit did `holders += 1`, the release would drop the count to a
884        // non-zero value and the edge would stay pinned forever.
885        let mut registry = InMemoryBudgetRegistry::new();
886        registry
887            .register_parent("p".to_string(), MAX_BUDGET_SHARE_BPS)
888            .expect("register parent");
889
890        // A real dispatch leases child's edge (holders == 1).
891        registry
892            .try_admit_child("p", "child".to_string(), 6_000)
893            .expect("real dispatch leases the child");
894        // Several verifier-only re-admits of the SAME child (each Ok, admissible)
895        // must not touch the holder count.
896        for _ in 0..3 {
897            registry
898                .verify_child_admission("p", "child".to_string(), 6_000)
899                .expect("verify-only re-admit is admissible");
900        }
901        assert_eq!(
902            registry
903                .split("p")
904                .and_then(|split| split.child_holders("child")),
905            Some(1),
906            "verifier-only re-admits must leave holders at the real-holder count"
907        );
908
909        // The single real holder releases: holders 1 -> 0, so the edge is freed
910        // and the full parent share is returned despite the verify-only traffic.
911        registry
912            .release_child("p", "child", 6_000)
913            .expect("the real holder releases its only lease");
914        assert_eq!(
915            registry
916                .split("p")
917                .and_then(|split| split.child_holders("child")),
918            None,
919            "the last REAL holder's release must free the edge (verify-only did not leak)"
920        );
921        registry
922            .try_admit_child("p", "sibling".to_string(), MAX_BUDGET_SHARE_BPS)
923            .expect("full headroom restored: no verify-only lease was leaked");
924    }
925
926    #[test]
927    fn verify_only_fresh_admit_commits_share_without_a_holder() {
928        // A verifier-only admit of a FRESH child commits its share for
929        // sibling-sum accounting (so a later sibling is denied at the hot path)
930        // but records NO releasable holder: holders == 0.
931        let mut registry = InMemoryBudgetRegistry::new();
932        registry
933            .register_parent("p".to_string(), 5_000)
934            .expect("register parent");
935        registry
936            .verify_child_admission("p", "child-a".to_string(), 4_000)
937            .expect("fresh verify-only admit is admissible");
938        assert_eq!(
939            registry
940                .split("p")
941                .and_then(|split| split.child_share_bps("child-a")),
942            Some(4_000),
943            "a fresh verify-only admit must commit the child's share"
944        );
945        assert_eq!(
946            registry
947                .split("p")
948                .and_then(|split| split.child_holders("child-a")),
949            Some(0),
950            "a verify-only commit records no releasable holder"
951        );
952        // The committed share is visible to the oversubscription check: a
953        // second sibling that would exceed the parent share is denied.
954        let denied = registry
955            .verify_child_admission("p", "child-b".to_string(), 4_000)
956            .expect_err("4_000 + 4_000 > 5_000 parent share must be denied");
957        assert!(matches!(
958            denied,
959            BudgetSplitError::OversubscribedSiblings { .. }
960        ));
961    }
962
963    #[test]
964    fn verify_only_commit_is_superseded_by_real_lease_lifecycle() {
965        // A verify-only commit (holders == 0) that is later leased by a real
966        // dispatch (holders 0 -> 1) is freed when that real holder releases
967        // (holders 1 -> 0): the edge is never pinned past its real holders.
968        let mut registry = InMemoryBudgetRegistry::new();
969        registry
970            .register_parent("p".to_string(), MAX_BUDGET_SHARE_BPS)
971            .expect("register parent");
972        registry
973            .verify_child_admission("p", "child".to_string(), 6_000)
974            .expect("verify-only commits the fresh edge");
975        // A real dispatch leases the already-committed edge.
976        registry
977            .try_admit_child("p", "child".to_string(), 6_000)
978            .expect("real dispatch leases the committed edge");
979        assert_eq!(
980            registry
981                .split("p")
982                .and_then(|split| split.child_holders("child")),
983            Some(1),
984            "the real lease is the sole holder on the verify-only-committed edge"
985        );
986        registry
987            .release_child("p", "child", 6_000)
988            .expect("the real holder releases");
989        assert_eq!(
990            registry
991                .split("p")
992                .and_then(|split| split.child_holders("child")),
993            None,
994            "releasing the last real holder frees the edge (no verify-only pin)"
995        );
996    }
997
998    #[test]
999    fn in_memory_registry_evict_is_idempotent() {
1000        let mut registry = InMemoryBudgetRegistry::new();
1001        registry
1002            .register_parent("p".to_string(), 5_000)
1003            .expect("register");
1004        registry.evict_parent("p");
1005        registry.evict_parent("p");
1006        assert!(registry.split("p").is_none());
1007    }
1008
1009    #[test]
1010    fn in_memory_registry_admits_under_registered_parent() {
1011        let mut registry = InMemoryBudgetRegistry::new();
1012        registry
1013            .register_parent("p".to_string(), 5_000)
1014            .expect("register");
1015        registry
1016            .try_admit_child("p", "c1".to_string(), 4_000)
1017            .expect("first child fits");
1018        let err = registry
1019            .try_admit_child("p", "c2".to_string(), 4_000)
1020            .expect_err("second child must oversubscribe");
1021        assert!(matches!(
1022            err,
1023            BudgetSplitError::OversubscribedSiblings { .. }
1024        ));
1025    }
1026
1027    #[test]
1028    fn registered_parent_share_takes_precedence_over_default() {
1029        // When the parent is already registered, the auto-register
1030        // default is ignored: the registered share is the ceiling.
1031        let mut registry = InMemoryBudgetRegistry::new();
1032        registry
1033            .register_parent("p".to_string(), 5_000)
1034            .expect("register");
1035        let err = registry
1036            .try_admit_child("p", "c1".to_string(), 6_000)
1037            .expect_err("child larger than registered parent share must fail");
1038        assert!(matches!(
1039            err,
1040            BudgetSplitError::OversubscribedSiblings { .. }
1041        ));
1042    }
1043
1044    #[test]
1045    fn noop_registry_admits_anything() {
1046        let mut registry = NoopBudgetRegistry;
1047        registry
1048            .register_parent("p".to_string(), MAX_BUDGET_SHARE_BPS)
1049            .expect("noop register");
1050        registry
1051            .try_admit_child("p", "c1".to_string(), MAX_BUDGET_SHARE_BPS)
1052            .expect("noop admit 1");
1053        registry
1054            .try_admit_child("p", "c2".to_string(), MAX_BUDGET_SHARE_BPS)
1055            .expect("noop admit 2");
1056    }
1057
1058    #[test]
1059    fn current_total_avoids_u16_overflow() {
1060        let mut split = BudgetSplit {
1061            parent_token_id: "parent".to_string(),
1062            parent_share_bps: MAX_BUDGET_SHARE_BPS,
1063            children: BTreeMap::new(),
1064        };
1065        // Synthetic stress: two MAX_BUDGET_SHARE_BPS siblings would sum past
1066        // u16::MAX. The registry would never admit both, but the running
1067        // sum itself must compute in u32 without wraparound.
1068        split.children.insert(
1069            "c1".to_string(),
1070            ChildAdmission {
1071                share_bps: MAX_BUDGET_SHARE_BPS,
1072                holders: 1,
1073            },
1074        );
1075        split.children.insert(
1076            "c2".to_string(),
1077            ChildAdmission {
1078                share_bps: MAX_BUDGET_SHARE_BPS,
1079                holders: 1,
1080            },
1081        );
1082        assert_eq!(
1083            split.current_total_child_bps(),
1084            u32::from(MAX_BUDGET_SHARE_BPS) * 2
1085        );
1086    }
1087}