Skip to main content

chio_kernel_core/
evaluate.rs

1//! Pure-compute verdict evaluation.
2//!
3//! [`evaluate`] walks a `(capability, request, guards)` tuple through the
4//! sync checks that do not require I/O or mutable kernel state:
5//!
6//! 1. Issuer trust + signature + time-bound verification via
7//!    [`crate::capability_verify::verify_capability`].
8//! 2. Subject-binding check (agent_id == capability.subject hex).
9//! 3. Portable scope match via [`crate::scope::resolve_matching_grants`].
10//! 4. Guard pipeline: every registered guard is invoked in order;
11//!    fail-closed on error or `Deny`.
12//!
13//! What it does NOT do (fenced into `chio-kernel` proper):
14//!
15//! - Revocation membership lookup (stateful `RevocationStore`).
16//! - Budget mutation (stateful `BudgetStore`).
17//! - Delegation-chain ancestor inspection against the receipt store.
18//! - DPoP proof verification with nonce replay (LRU-backed).
19//! - Governed-transaction policy evaluation (pulls in chio-governance).
20//! - Payment authorisation (async adapter trait).
21//! - Tool dispatch to wrapped servers (async transport).
22//! - Receipt persistence / Merkle checkpointing (SQL / IO).
23//!
24//! Callers (`chio-kernel::ChioKernel::evaluate_tool_call_sync`, the browser
25//! and mobile adapters) wrap this pure core in the I/O checks they need.
26//!
27//! Verified-core boundary note:
28//! `formal/proof-manifest.toml` names this module as covered Rust surface for
29//! the current bounded verified core. The covered semantics stop at pure
30//! capability verification, subject binding, portable scope matching, and the
31//! synchronous guard pipeline; revocation lookups, budget mutation, DPoP, and
32//! tool dispatch stay outside this module and outside the present proof claim.
33
34use alloc::string::{String, ToString};
35
36use chio_core_types::capability::{
37    crypto_floor::CapabilityCryptoFloor, features::CapabilityNegotiation, scope::ChioScope,
38    token::CapabilityToken,
39};
40use chio_core_types::crypto::PublicKey;
41
42use crate::budget_split::{BudgetRegistry, NoopBudgetRegistry};
43use crate::capability_verify::{
44    admit_delegated_budget, verify_capability_with_floor, CapabilityError, TrustRootResolver,
45    VerifiedCapability,
46};
47use crate::clock::Clock;
48use crate::guard::{Guard, GuardContext, PortableToolCallRequest};
49use crate::normalized::{NormalizationError, NormalizedEvaluationVerdict};
50use crate::scope::resolve_matching_grants;
51use crate::Verdict;
52
53/// Inputs to [`evaluate`]. Grouped into a struct so the call site stays
54/// tidy and future fields (e.g. a policy-digest override) can be added
55/// without breaking the public signature.
56pub struct EvaluateInput<'a> {
57    /// Tool call request being evaluated.
58    pub request: &'a PortableToolCallRequest,
59    /// The capability token authorising this call.
60    pub capability: &'a CapabilityToken,
61    /// Trusted issuer public keys (typically CA + kernel + authority).
62    pub trusted_issuers: &'a [PublicKey],
63    /// Clock used for time-bound enforcement.
64    pub clock: &'a dyn Clock,
65    /// Guard pipeline. Evaluated in order, fail-closed on deny or error.
66    pub guards: &'a [&'a dyn Guard],
67    /// Optional filesystem roots from the owning session, passed through to
68    /// guards that enforce root-based resource protection.
69    pub session_filesystem_roots: Option<&'a [String]>,
70}
71
72/// Verdict + context produced by [`evaluate`].
73///
74/// On `Verdict::Allow` the caller is handed the `VerifiedCapability` and
75/// the matched grant index; the full kernel uses those to drive budget
76/// accounting, receipt construction, and tool dispatch.
77#[derive(Debug, Clone)]
78pub struct EvaluationVerdict {
79    /// The three-valued verdict. `PendingApproval` is never produced by
80    /// the core; only Allow / Deny flow out of this module.
81    pub verdict: Verdict,
82    /// Human-readable deny reason when `verdict == Deny`.
83    pub reason: Option<String>,
84    /// Grant index that admitted the request. Populated on Allow.
85    pub matched_grant_index: Option<usize>,
86    /// Verified capability snapshot. Populated when signature + time
87    /// checks succeeded, even if a later guard denied.
88    pub verified: Option<VerifiedCapability>,
89}
90
91impl EvaluationVerdict {
92    /// Is this an allow verdict?
93    #[must_use]
94    pub fn is_allow(&self) -> bool {
95        self.verdict == Verdict::Allow
96    }
97
98    /// Is this a deny verdict?
99    #[must_use]
100    pub fn is_deny(&self) -> bool {
101        self.verdict == Verdict::Deny
102    }
103
104    /// Project this evaluation result into the proof-facing normalized AST.
105    pub fn normalized(
106        &self,
107        request: &PortableToolCallRequest,
108    ) -> Result<NormalizedEvaluationVerdict, NormalizationError> {
109        NormalizedEvaluationVerdict::try_from_evaluation(request, self)
110    }
111}
112
113/// Errors the portable core can raise.
114///
115/// These are portable-kernel equivalents of the
116/// `chio_kernel::KernelError` variants that can be produced without any
117/// I/O. The caller in `chio-kernel` maps them back onto its richer
118/// `KernelError` surface for backward compatibility.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum KernelCoreError {
121    /// Capability signature or issuer trust failed.
122    InvalidCapability(CapabilityError),
123    /// Subject mismatch: `request.agent_id` != `capability.subject`.
124    SubjectMismatch { expected: String, actual: String },
125    /// No grant in scope covers the requested tool/server.
126    OutOfScope { tool: String, server: String },
127    /// Portable scope matching failed closed on an unsupported constraint.
128    ConstraintError { reason: String },
129    /// The capability requires stateful enforcement unavailable in this runtime.
130    UnsupportedCapabilityFeature { feature: String },
131    /// A guard returned a fail-closed error.
132    GuardError { guard: String, reason: String },
133    /// A guard denied the request outright.
134    GuardDenied { guard: String },
135}
136
137impl KernelCoreError {
138    /// Human-readable reason for the deny verdict.
139    #[must_use]
140    pub fn deny_reason(&self) -> String {
141        match self {
142            KernelCoreError::InvalidCapability(error) => match error {
143                CapabilityError::UntrustedIssuer => {
144                    "capability issuer is not a trusted CA".to_string()
145                }
146                CapabilityError::InvalidSignature => "capability signature is invalid".to_string(),
147                CapabilityError::CryptoFloorRejected(msg) => {
148                    let mut out = String::from("capability rejected by crypto floor: ");
149                    out.push_str(msg);
150                    out
151                }
152                CapabilityError::NotYetValid => "capability not yet valid".to_string(),
153                CapabilityError::Expired => "capability has expired".to_string(),
154                CapabilityError::AttenuationViolation(msg) => {
155                    let mut out = String::from("capability rejected by chain binding: ");
156                    out.push_str(msg);
157                    out
158                }
159                CapabilityError::BudgetSplitRejected(err) => {
160                    let mut out = String::from("capability budget split rejected: ");
161                    // alloc::fmt is available; use core formatting.
162                    let formatted = alloc::format!("{err}");
163                    out.push_str(&formatted);
164                    out
165                }
166                CapabilityError::Internal(msg) => {
167                    let mut out = String::from("capability verification failed: ");
168                    out.push_str(msg);
169                    out
170                }
171            },
172            KernelCoreError::SubjectMismatch { expected, actual } => {
173                let mut out = String::from("request agent ");
174                out.push_str(actual);
175                out.push_str(" does not match capability subject ");
176                out.push_str(expected);
177                out
178            }
179            KernelCoreError::OutOfScope { tool, server } => {
180                let mut out = String::from("requested tool ");
181                out.push_str(tool);
182                out.push_str(" on server ");
183                out.push_str(server);
184                out.push_str(" is not in capability scope");
185                out
186            }
187            KernelCoreError::ConstraintError { reason } => {
188                let mut out = String::from("constraint evaluation failed: ");
189                out.push_str(reason);
190                out
191            }
192            KernelCoreError::UnsupportedCapabilityFeature { feature } => {
193                let mut out = String::from("capability feature unsupported on this runtime: ");
194                out.push_str(feature);
195                out
196            }
197            KernelCoreError::GuardError { guard, reason } => {
198                let mut out = String::from("guard \"");
199                out.push_str(guard);
200                out.push_str("\" error (fail-closed): ");
201                out.push_str(reason);
202                out
203            }
204            KernelCoreError::GuardDenied { guard } => {
205                let mut out = String::from("guard \"");
206                out.push_str(guard);
207                out.push_str("\" denied the request");
208                out
209            }
210        }
211    }
212}
213
214fn out_of_scope_error(request: &PortableToolCallRequest) -> KernelCoreError {
215    KernelCoreError::OutOfScope {
216        tool: request.tool_name.clone(),
217        server: request.server_id.clone(),
218    }
219}
220
221fn resolve_matched_grant_index(
222    scope: &ChioScope,
223    request: &PortableToolCallRequest,
224) -> Result<usize, KernelCoreError> {
225    let matches = match resolve_matching_grants(
226        scope,
227        &request.tool_name,
228        &request.server_id,
229        &request.arguments,
230    ) {
231        Ok(matches) => matches,
232        Err(crate::ScopeMatchError::OutOfScope) => return Err(out_of_scope_error(request)),
233        Err(crate::ScopeMatchError::ConstraintError(reason)) => {
234            return Err(KernelCoreError::ConstraintError { reason });
235        }
236    };
237
238    matches
239        .first()
240        .map(|matched| matched.index)
241        .ok_or_else(|| out_of_scope_error(request))
242}
243
244/// Primary entry point for the portable kernel core.
245///
246/// Performs in order:
247///
248/// 1. Capability signature / issuer / time-bound verification.
249/// 2. Subject binding (agent_id match).
250/// 3. Portable scope match.
251/// 4. Guard pipeline (fail-closed).
252///
253/// Returns `Ok(EvaluationVerdict)` for Allow or Deny. An `Err` is only
254/// returned when the underlying `verify_canonical` machinery reports an
255/// internal failure that is not a clean verify-false; semantically this
256/// is still a deny at the caller's level and chio-kernel maps it onto
257/// `KernelError::Internal`.
258pub fn evaluate(input: EvaluateInput<'_>) -> EvaluationVerdict {
259    evaluate_with_crypto_floor(input, CapabilityCryptoFloor::AllowClassical)
260}
261
262/// Evaluate with a configured capability crypto floor.
263///
264/// [`evaluate`] defaults to the allow-classical posture. Kernels that load
265/// `policy.crypto_floor` must call this entry point so capability tokens cannot
266/// bypass the PQ floor.
267///
268/// This entry point uses a [`NoopBudgetRegistry`]; the full
269/// [`evaluate_with_crypto_floor_and_budgets`] entry point lets a hosted
270/// kernel inject its own [`BudgetRegistry`] so sibling-sum oversubscription
271/// is rejected at evaluation time.
272///
273/// This entry point does not enforce chain binding for attenuated delegated
274/// tokens because it has no trust-root resolver. Production kernels that
275/// accept attenuation should call [`evaluate_with_full_floor`].
276pub fn evaluate_with_crypto_floor(
277    input: EvaluateInput<'_>,
278    crypto_floor: CapabilityCryptoFloor,
279) -> EvaluationVerdict {
280    let mut budgets = NoopBudgetRegistry;
281    evaluate_with_crypto_floor_and_budgets(input, crypto_floor, &mut budgets)
282}
283
284/// Evaluate with both a configured crypto floor and a sibling-sum budget
285/// registry. Hosted kernels that track delegated children should call this
286/// so an oversubscribed sibling fails closed after the request is otherwise
287/// allowed.
288pub fn evaluate_with_crypto_floor_and_budgets(
289    input: EvaluateInput<'_>,
290    crypto_floor: CapabilityCryptoFloor,
291    budgets: &mut dyn BudgetRegistry,
292) -> EvaluationVerdict {
293    // Step 1: capability verification.
294    if input.capability.attenuation_proof.is_some() {
295        let core_err = KernelCoreError::InvalidCapability(CapabilityError::AttenuationViolation(
296            "chain-binding requires a trust-root resolver on the evaluate path".to_string(),
297        ));
298        return deny(core_err, None, None);
299    }
300
301    let mut verify_only_budgets = NoopBudgetRegistry;
302    let verified = match verify_capability_with_floor(
303        input.capability,
304        input.trusted_issuers,
305        input.clock,
306        crypto_floor,
307        &mut verify_only_budgets,
308    ) {
309        Ok(verified) => verified,
310        Err(error) => {
311            let core_err = KernelCoreError::InvalidCapability(error);
312            return deny(core_err, None, None);
313        }
314    };
315
316    finish_verified_evaluation(input, verified, budgets)
317}
318
319/// Full current-semantics evaluation entry point.
320///
321/// Same five steps as [`evaluate_with_crypto_floor`] but uses
322/// [`crate::capability_verify::verify_capability_full`] for capability
323/// verification, which threads:
324///
325/// - the peer-negotiated feature profile, and
326/// - the per-issuer trust-root resolver
327///
328/// in addition to the crypto floor. Production kernels (`chio-kernel`,
329/// `chio-kernel-browser`, `chio-kernel-mobile`, `chio-cpp-kernel-ffi`,
330/// `chio-ag-ui-proxy`) call this path so chain-binding and feature validation
331/// are exercised on the hot path.
332pub fn evaluate_with_full_floor(
333    input: EvaluateInput<'_>,
334    crypto_floor: CapabilityCryptoFloor,
335    peer: &CapabilityNegotiation,
336    trust_root: &dyn TrustRootResolver,
337    budgets: &mut dyn BudgetRegistry,
338) -> EvaluationVerdict {
339    evaluate_with_full_floor_and_root(input, crypto_floor, peer, None, trust_root, budgets)
340}
341
342/// Full evaluation with authenticated optional-family root evidence.
343pub fn evaluate_with_full_floor_and_root(
344    input: EvaluateInput<'_>,
345    crypto_floor: CapabilityCryptoFloor,
346    peer: &CapabilityNegotiation,
347    direct_root: Option<&CapabilityToken>,
348    trust_root: &dyn TrustRootResolver,
349    budgets: &mut dyn BudgetRegistry,
350) -> EvaluationVerdict {
351    // Step 1: capability verification with all defenses except
352    // persistent sibling-sum admission. Admission mutates the supplied
353    // registry, so defer it until subject, scope, and guard checks have
354    // passed. Otherwise a validly signed token for the wrong request can
355    // consume sibling share and starve later valid siblings.
356    let mut verify_only_budgets = NoopBudgetRegistry;
357    let verified = match crate::capability_verify::verify_capability_full_with_root(
358        input.capability,
359        input.trusted_issuers,
360        input.clock,
361        crypto_floor,
362        crate::capability_verify::CapabilityFeatureContext { peer, direct_root },
363        trust_root,
364        &mut verify_only_budgets,
365    ) {
366        Ok(verified) => verified,
367        Err(error) => {
368            let core_err = KernelCoreError::InvalidCapability(error);
369            return deny(core_err, None, None);
370        }
371    };
372    if input.capability.aggregate_invocation_budget.is_some() {
373        return deny(
374            KernelCoreError::UnsupportedCapabilityFeature {
375                feature: "aggregate invocation enforcement".to_string(),
376            },
377            None,
378            Some(verified),
379        );
380    }
381    if input.capability.scope.has_cumulative_approval() {
382        return deny(
383            KernelCoreError::UnsupportedCapabilityFeature {
384                feature: "cumulative approval enforcement".to_string(),
385            },
386            None,
387            Some(verified),
388        );
389    }
390
391    finish_verified_evaluation(input, verified, budgets)
392}
393
394fn finish_verified_evaluation(
395    input: EvaluateInput<'_>,
396    verified: VerifiedCapability,
397    budgets: &mut dyn BudgetRegistry,
398) -> EvaluationVerdict {
399    // Step 2: subject binding.
400    if verified.subject_hex != input.request.agent_id {
401        let core_err = KernelCoreError::SubjectMismatch {
402            expected: verified.subject_hex.clone(),
403            actual: input.request.agent_id.clone(),
404        };
405        return deny(core_err, None, Some(verified));
406    }
407
408    // Step 3: scope match.
409    let matched_grant_index = match resolve_matched_grant_index(&verified.scope, input.request) {
410        Ok(index) => index,
411        Err(error) => return deny(error, None, Some(verified)),
412    };
413
414    // Step 4: guard pipeline.
415    let ctx = GuardContext {
416        request: input.request,
417        scope: &verified.scope,
418        agent_id: &input.request.agent_id,
419        server_id: &input.request.server_id,
420        session_filesystem_roots: input.session_filesystem_roots,
421        matched_grant_index: Some(matched_grant_index),
422    };
423
424    for guard in input.guards {
425        match guard.evaluate(&ctx) {
426            Ok(Verdict::Allow) => {}
427            Ok(Verdict::Deny) | Ok(Verdict::PendingApproval) => {
428                // PendingApproval is reserved for the full kernel orchestration
429                // layer (chio-kernel::approval::ApprovalGuard); if a sync guard
430                // surfaces it here we fail closed.
431                let core_err = KernelCoreError::GuardDenied {
432                    guard: guard.name().to_string(),
433                };
434                return deny(core_err, Some(matched_grant_index), Some(verified));
435            }
436            Err(error) => {
437                let core_err = KernelCoreError::GuardError {
438                    guard: guard.name().to_string(),
439                    reason: error.deny_reason(),
440                };
441                return deny(core_err, Some(matched_grant_index), Some(verified));
442            }
443        }
444    }
445
446    // Step 5: mutate delegated sibling budget only after every deny-capable
447    // local check has passed.
448    if let Err(error) = admit_delegated_budget(input.capability, budgets) {
449        let core_err = KernelCoreError::InvalidCapability(error);
450        return deny(core_err, Some(matched_grant_index), Some(verified));
451    }
452
453    EvaluationVerdict {
454        verdict: Verdict::Allow,
455        reason: None,
456        matched_grant_index: Some(matched_grant_index),
457        verified: Some(verified),
458    }
459}
460
461fn deny(
462    error: KernelCoreError,
463    matched_grant_index: Option<usize>,
464    verified: Option<VerifiedCapability>,
465) -> EvaluationVerdict {
466    EvaluationVerdict {
467        verdict: Verdict::Deny,
468        reason: Some(error.deny_reason()),
469        matched_grant_index,
470        verified,
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use crate::{BudgetRegistry, InMemoryBudgetRegistry, MAX_BUDGET_SHARE_BPS};
478    use alloc::vec;
479    use chio_core_types::capability::{
480        aggregate_invocation::{AggregateInvocationBudget, AggregateInvocationScope},
481        attenuation::{DelegationLink, DelegationLinkBody},
482        features::AGGREGATE_INVOCATION_BUDGET,
483        scope::{ChioScope, Operation, ToolGrant},
484        token::{CapabilityToken, CapabilityTokenBody},
485    };
486    use chio_core_types::crypto::Keypair;
487
488    fn grant(server_id: &str, tool_name: &str) -> ToolGrant {
489        ToolGrant {
490            server_id: server_id.to_string(),
491            tool_name: tool_name.to_string(),
492            operations: vec![Operation::Invoke],
493            constraints: vec![],
494            max_invocations: None,
495            max_cost_per_invocation: None,
496            max_total_cost: None,
497            dpop_required: None,
498        }
499    }
500
501    fn request() -> PortableToolCallRequest {
502        PortableToolCallRequest {
503            request_id: "req-1".to_string(),
504            tool_name: "echo".to_string(),
505            server_id: "srv-a".to_string(),
506            agent_id: "agent-1".to_string(),
507            arguments: serde_json::json!({"msg":"hello"}),
508        }
509    }
510
511    fn delegated_capability(
512        issuer: &Keypair,
513        subject: &Keypair,
514        parent_capability_id: &str,
515    ) -> CapabilityToken {
516        let parent_link = match DelegationLink::sign(
517            DelegationLinkBody {
518                capability_id: parent_capability_id.to_string(),
519                delegator: issuer.public_key(),
520                delegatee: issuer.public_key(),
521                attenuations: vec![],
522                timestamp: 100,
523                scope_hash: None,
524                aggregate_budget: None,
525                cumulative_approval: None,
526            },
527            issuer,
528        ) {
529            Ok(link) => link,
530            Err(error) => panic!("failed to sign parent delegation link: {error:?}"),
531        };
532
533        match CapabilityToken::sign(
534            CapabilityTokenBody {
535                id: "child-capability".to_string(),
536                issuer: issuer.public_key(),
537                subject: subject.public_key(),
538                scope: ChioScope {
539                    grants: vec![grant("srv-a", "echo")],
540                    resource_grants: vec![],
541                    prompt_grants: vec![],
542                },
543                issued_at: 100,
544                expires_at: 200,
545                delegation_chain: vec![parent_link],
546                aggregate_invocation_budget: None,
547            },
548            issuer,
549        ) {
550            Ok(token) => token,
551            Err(error) => panic!("failed to sign delegated capability: {error:?}"),
552        }
553    }
554
555    #[test]
556    fn resolve_matched_grant_index_uses_scope_specificity_order() {
557        let scope = ChioScope {
558            grants: vec![grant("*", "*"), grant("srv-a", "echo")],
559            resource_grants: vec![],
560            prompt_grants: vec![],
561        };
562
563        let matched_index = resolve_matched_grant_index(&scope, &request());
564
565        assert_eq!(matched_index, Ok(1));
566    }
567
568    #[test]
569    fn resolve_matched_grant_index_maps_missing_grant_to_request_identity() {
570        let scope = ChioScope {
571            grants: vec![grant("srv-b", "echo")],
572            resource_grants: vec![],
573            prompt_grants: vec![],
574        };
575
576        let error = resolve_matched_grant_index(&scope, &request());
577
578        assert_eq!(
579            error,
580            Err(KernelCoreError::OutOfScope {
581                tool: "echo".to_string(),
582                server: "srv-a".to_string(),
583            })
584        );
585    }
586
587    #[test]
588    fn budget_admission_waits_until_subject_scope_and_guards_allow() {
589        let issuer = Keypair::generate();
590        let subject = Keypair::generate();
591        let wrong_agent = Keypair::generate();
592        let parent_capability_id = "parent-capability";
593        let capability = delegated_capability(&issuer, &subject, parent_capability_id);
594        let mut request = request();
595        request.agent_id = wrong_agent.public_key().to_hex();
596        let trusted = [issuer.public_key()];
597        let clock = crate::FixedClock::new(150);
598        let guards: [&dyn Guard; 0] = [];
599        let mut budgets = InMemoryBudgetRegistry::new();
600        if let Err(error) =
601            budgets.register_parent(parent_capability_id.to_string(), MAX_BUDGET_SHARE_BPS)
602        {
603            panic!("failed to register parent budget split: {error:?}");
604        }
605
606        let verdict = evaluate_with_crypto_floor_and_budgets(
607            EvaluateInput {
608                request: &request,
609                capability: &capability,
610                trusted_issuers: &trusted,
611                clock: &clock,
612                guards: &guards,
613                session_filesystem_roots: None,
614            },
615            CapabilityCryptoFloor::AllowClassical,
616            &mut budgets,
617        );
618
619        assert!(verdict.is_deny());
620        let split = match budgets.split(parent_capability_id) {
621            Some(split) => split,
622            None => panic!("registered parent budget split was missing"),
623        };
624        assert_eq!(split.current_total_child_bps(), 0);
625        assert!(split.children.is_empty());
626    }
627
628    #[test]
629    fn full_floor_budget_admission_waits_until_subject_scope_and_guards_allow() {
630        let issuer = Keypair::generate();
631        let subject = Keypair::generate();
632        let wrong_agent = Keypair::generate();
633        let parent_capability_id = "parent-capability-full";
634        let capability = delegated_capability(&issuer, &subject, parent_capability_id);
635        let mut request = request();
636        request.agent_id = wrong_agent.public_key().to_hex();
637        let trusted = [issuer.public_key()];
638        let clock = crate::FixedClock::new(150);
639        let guards: [&dyn Guard; 0] = [];
640        let peer = CapabilityNegotiation::t1_default();
641        let trust_roots = |_issuer: &chio_core_types::crypto::PublicKey| None;
642        let mut budgets = InMemoryBudgetRegistry::new();
643        if let Err(error) =
644            budgets.register_parent(parent_capability_id.to_string(), MAX_BUDGET_SHARE_BPS)
645        {
646            panic!("failed to register parent budget split: {error:?}");
647        }
648
649        let verdict = evaluate_with_full_floor(
650            EvaluateInput {
651                request: &request,
652                capability: &capability,
653                trusted_issuers: &trusted,
654                clock: &clock,
655                guards: &guards,
656                session_filesystem_roots: None,
657            },
658            CapabilityCryptoFloor::AllowClassical,
659            &peer,
660            &trust_roots,
661            &mut budgets,
662        );
663
664        assert!(verdict.is_deny());
665        let split = match budgets.split(parent_capability_id) {
666            Some(split) => split,
667            None => panic!("registered parent budget split was missing"),
668        };
669        assert_eq!(split.current_total_child_bps(), 0);
670        assert!(split.children.is_empty());
671    }
672
673    #[test]
674    fn full_floor_denies_aggregate_budget_until_enforcement_is_available() {
675        let issuer = Keypair::generate();
676        let subject = Keypair::generate();
677        let capability = match CapabilityToken::sign(
678            CapabilityTokenBody {
679                id: "aggregate-not-enforced".to_string(),
680                issuer: issuer.public_key(),
681                subject: subject.public_key(),
682                scope: ChioScope {
683                    grants: vec![grant("srv-a", "echo")],
684                    ..ChioScope::default()
685                },
686                issued_at: 100,
687                expires_at: 200,
688                delegation_chain: vec![],
689                aggregate_invocation_budget: Some(AggregateInvocationBudget {
690                    scope: AggregateInvocationScope::Capability,
691                    max_invocations: 1,
692                    root_binding: None,
693                }),
694            },
695            &issuer,
696        ) {
697            Ok(capability) => capability,
698            Err(error) => panic!("failed to sign aggregate capability: {error}"),
699        };
700        let mut request = request();
701        request.agent_id = subject.public_key().to_hex();
702        let trusted = [issuer.public_key()];
703        let clock = crate::FixedClock::new(150);
704        let guards: [&dyn Guard; 0] = [];
705        let mut peer = CapabilityNegotiation::v1_default();
706        peer.features
707            .insert(AGGREGATE_INVOCATION_BUDGET.to_string(), true);
708        let trust_roots = |_issuer: &chio_core_types::crypto::PublicKey| None;
709        let mut budgets = InMemoryBudgetRegistry::new();
710
711        let verdict = evaluate_with_full_floor(
712            EvaluateInput {
713                request: &request,
714                capability: &capability,
715                trusted_issuers: &trusted,
716                clock: &clock,
717                guards: &guards,
718                session_filesystem_roots: None,
719            },
720            CapabilityCryptoFloor::AllowClassical,
721            &peer,
722            &trust_roots,
723            &mut budgets,
724        );
725
726        assert!(verdict.is_deny());
727        assert!(verdict.reason.as_deref().is_some_and(|reason| reason
728            .contains("unsupported on this runtime: aggregate invocation enforcement")));
729    }
730}