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}