Skip to main content

chio_kernel_core/
normalized.rs

1//! Proof-facing normalized types for the bounded verified core.
2//!
3//! These types deliberately carve out the pure authorization subset the
4//! executable spec and future Lean refinement work can talk about without
5//! depending on the full runtime object graph.
6
7use alloc::string::{String, ToString};
8use alloc::vec::Vec;
9use core::convert::TryFrom;
10
11use chio_core_types::capability::{
12    runtime_attestation::RuntimeAssuranceTier,
13    scope::{
14        ChioScope, Constraint, MonetaryAmount, Operation, PromptGrant, ResourceGrant, ToolGrant,
15    },
16    token::CapabilityToken,
17};
18use serde::{Deserialize, Serialize};
19
20use crate::capability_verify::VerifiedCapability;
21use crate::evaluate::EvaluationVerdict;
22#[cfg(not(kani))]
23use crate::formal_core::monetary_cap_is_subset_by_parts;
24use crate::formal_core::{
25    exact_or_wildcard_covers, optional_u32_cap_is_subset, prefix_wildcard_or_exact_covers,
26    required_true_is_preserved,
27};
28use crate::guard::PortableToolCallRequest;
29use crate::Verdict;
30
31/// Errors raised while projecting runtime objects into the normalized
32/// proof-facing surface.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum NormalizationError {
35    /// The current bounded proof lane does not model this constraint yet.
36    UnsupportedConstraint { kind: String },
37}
38
39/// Proof-facing monetary cap.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct NormalizedMonetaryAmount {
42    pub units: u64,
43    pub currency: String,
44}
45
46/// Proof-facing runtime assurance tier.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum NormalizedRuntimeAssuranceTier {
50    None,
51    Basic,
52    Attested,
53    Verified,
54}
55
56/// Proof-facing operation enum.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum NormalizedOperation {
60    Invoke,
61    ReadResult,
62    Read,
63    Subscribe,
64    Get,
65    Delegate,
66}
67
68/// Constraint subset currently admitted into the normalized proof-facing AST.
69///
70/// Unsupported runtime-only constraints remain outside this boundary and cause
71/// normalization to fail closed.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(tag = "type", content = "value", rename_all = "snake_case")]
74pub enum NormalizedConstraint {
75    PathPrefix(String),
76    DomainExact(String),
77    DomainGlob(String),
78    RegexMatch(String),
79    MaxLength(usize),
80    MaxArgsSize(usize),
81    GovernedIntentRequired,
82    RequireApprovalAbove { threshold_units: u64 },
83    SellerExact(String),
84    MinimumRuntimeAssurance(NormalizedRuntimeAssuranceTier),
85    Custom(String, String),
86}
87
88/// Proof-facing tool grant.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct NormalizedToolGrant {
91    pub server_id: String,
92    pub tool_name: String,
93    pub operations: Vec<NormalizedOperation>,
94    pub constraints: Vec<NormalizedConstraint>,
95    pub max_invocations: Option<u32>,
96    pub max_cost_per_invocation: Option<NormalizedMonetaryAmount>,
97    pub max_total_cost: Option<NormalizedMonetaryAmount>,
98    pub dpop_required: Option<bool>,
99}
100
101impl NormalizedToolGrant {
102    /// Mirrors `ToolGrant::is_subset_of` over the normalized proof-facing AST.
103    #[must_use]
104    pub fn is_subset_of(&self, parent: &Self) -> bool {
105        #[cfg(kani)]
106        {
107            return self.is_subset_of_bounded_kani(parent);
108        }
109
110        #[cfg(not(kani))]
111        {
112            if !exact_or_wildcard_covers(&parent.server_id, &self.server_id) {
113                return false;
114            }
115            if !exact_or_wildcard_covers(&parent.tool_name, &self.tool_name) {
116                return false;
117            }
118
119            if !self
120                .operations
121                .iter()
122                .all(|operation| parent.operations.contains(operation))
123            {
124                return false;
125            }
126
127            if !optional_u32_cap_is_subset(
128                self.max_invocations.is_some(),
129                self.max_invocations.unwrap_or(0),
130                parent.max_invocations.is_some(),
131                parent.max_invocations.unwrap_or(0),
132            ) {
133                return false;
134            }
135
136            if !parent
137                .constraints
138                .iter()
139                .all(|constraint| self.constraints.contains(constraint))
140            {
141                return false;
142            }
143
144            if !monetary_cap_is_subset(
145                self.max_cost_per_invocation.as_ref(),
146                parent.max_cost_per_invocation.as_ref(),
147            ) {
148                return false;
149            }
150
151            if !monetary_cap_is_subset(self.max_total_cost.as_ref(), parent.max_total_cost.as_ref())
152            {
153                return false;
154            }
155
156            if !required_true_is_preserved(
157                parent.dpop_required == Some(true),
158                self.dpop_required == Some(true),
159            ) {
160                return false;
161            }
162
163            true
164        }
165    }
166
167    #[cfg(kani)]
168    fn is_subset_of_bounded_kani(&self, parent: &Self) -> bool {
169        if !exact_or_wildcard_covers(&parent.server_id, &self.server_id) {
170            return false;
171        }
172        if !exact_or_wildcard_covers(&parent.tool_name, &self.tool_name) {
173            return false;
174        }
175        if !normalized_operations_subset_bounded_kani(&self.operations, &parent.operations) {
176            return false;
177        }
178        if !optional_u32_cap_is_subset(
179            self.max_invocations.is_some(),
180            self.max_invocations.unwrap_or(0),
181            parent.max_invocations.is_some(),
182            parent.max_invocations.unwrap_or(0),
183        ) {
184            return false;
185        }
186        if !normalized_constraints_subset_bounded_kani(&self.constraints, &parent.constraints) {
187            return false;
188        }
189        if !monetary_cap_is_subset_bounded_kani(
190            self.max_cost_per_invocation.as_ref(),
191            parent.max_cost_per_invocation.as_ref(),
192        ) {
193            return false;
194        }
195        if !monetary_cap_is_subset_bounded_kani(
196            self.max_total_cost.as_ref(),
197            parent.max_total_cost.as_ref(),
198        ) {
199            return false;
200        }
201        if !required_true_is_preserved(
202            parent.dpop_required == Some(true),
203            self.dpop_required == Some(true),
204        ) {
205            return false;
206        }
207
208        true
209    }
210}
211
212/// Proof-facing resource grant.
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct NormalizedResourceGrant {
215    pub uri_pattern: String,
216    pub operations: Vec<NormalizedOperation>,
217}
218
219impl NormalizedResourceGrant {
220    #[must_use]
221    pub fn is_subset_of(&self, parent: &Self) -> bool {
222        pattern_covers(&parent.uri_pattern, &self.uri_pattern)
223            && self
224                .operations
225                .iter()
226                .all(|operation| parent.operations.contains(operation))
227    }
228}
229
230/// Proof-facing prompt grant.
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct NormalizedPromptGrant {
233    pub prompt_name: String,
234    pub operations: Vec<NormalizedOperation>,
235}
236
237impl NormalizedPromptGrant {
238    #[must_use]
239    pub fn is_subset_of(&self, parent: &Self) -> bool {
240        pattern_covers(&parent.prompt_name, &self.prompt_name)
241            && self
242                .operations
243                .iter()
244                .all(|operation| parent.operations.contains(operation))
245    }
246}
247
248/// Proof-facing scope.
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct NormalizedScope {
251    pub grants: Vec<NormalizedToolGrant>,
252    pub resource_grants: Vec<NormalizedResourceGrant>,
253    pub prompt_grants: Vec<NormalizedPromptGrant>,
254}
255
256impl NormalizedScope {
257    /// Mirrors `ChioScope::is_subset_of` over the normalized proof-facing AST.
258    #[must_use]
259    pub fn is_subset_of(&self, parent: &Self) -> bool {
260        #[cfg(kani)]
261        {
262            if !self.resource_grants.is_empty()
263                || !parent.resource_grants.is_empty()
264                || !self.prompt_grants.is_empty()
265                || !parent.prompt_grants.is_empty()
266            {
267                return false;
268            }
269            if self.grants.is_empty() {
270                return true;
271            }
272            if self.grants.len() == 1 && parent.grants.len() == 1 {
273                return self.grants[0].is_subset_of(&parent.grants[0]);
274            }
275            return false;
276        }
277
278        #[cfg(not(kani))]
279        {
280            self.grants.iter().all(|grant| {
281                parent
282                    .grants
283                    .iter()
284                    .any(|candidate| grant.is_subset_of(candidate))
285            }) && self.resource_grants.iter().all(|grant| {
286                parent
287                    .resource_grants
288                    .iter()
289                    .any(|candidate| grant.is_subset_of(candidate))
290            }) && self.prompt_grants.iter().all(|grant| {
291                parent
292                    .prompt_grants
293                    .iter()
294                    .any(|candidate| grant.is_subset_of(candidate))
295            })
296        }
297    }
298}
299
300/// Proof-facing capability token projection.
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
302pub struct NormalizedCapability {
303    pub id: String,
304    pub issuer_hex: String,
305    pub subject_hex: String,
306    pub scope: NormalizedScope,
307    pub issued_at: u64,
308    pub expires_at: u64,
309}
310
311/// Proof-facing verified-capability output.
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313pub struct NormalizedVerifiedCapability {
314    pub capability: NormalizedCapability,
315    pub evaluated_at: u64,
316}
317
318/// Proof-facing request shape for the current evaluated tool call.
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct NormalizedRequest {
321    pub request_id: String,
322    pub tool_name: String,
323    pub server_id: String,
324    pub agent_id: String,
325    pub arguments: serde_json::Value,
326}
327
328/// Proof-facing verdict enum.
329#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
330#[serde(rename_all = "snake_case")]
331pub enum NormalizedVerdict {
332    Allow,
333    Deny,
334    PendingApproval,
335}
336
337/// Proof-facing evaluation output.
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
339pub struct NormalizedEvaluationVerdict {
340    pub request: NormalizedRequest,
341    pub verdict: NormalizedVerdict,
342    pub reason: Option<String>,
343    pub matched_grant_index: Option<usize>,
344    pub verified: Option<NormalizedVerifiedCapability>,
345}
346
347impl TryFrom<&CapabilityToken> for NormalizedCapability {
348    type Error = NormalizationError;
349
350    fn try_from(capability: &CapabilityToken) -> Result<Self, Self::Error> {
351        Ok(Self {
352            id: capability.id.clone(),
353            issuer_hex: capability.issuer.to_hex(),
354            subject_hex: capability.subject.to_hex(),
355            scope: NormalizedScope::try_from(&capability.scope)?,
356            issued_at: capability.issued_at,
357            expires_at: capability.expires_at,
358        })
359    }
360}
361
362impl TryFrom<&VerifiedCapability> for NormalizedVerifiedCapability {
363    type Error = NormalizationError;
364
365    fn try_from(verified: &VerifiedCapability) -> Result<Self, Self::Error> {
366        Ok(Self {
367            capability: NormalizedCapability {
368                id: verified.id.clone(),
369                issuer_hex: verified.issuer_hex.clone(),
370                subject_hex: verified.subject_hex.clone(),
371                scope: NormalizedScope::try_from(&verified.scope)?,
372                issued_at: verified.issued_at,
373                expires_at: verified.expires_at,
374            },
375            evaluated_at: verified.evaluated_at,
376        })
377    }
378}
379
380impl From<&PortableToolCallRequest> for NormalizedRequest {
381    fn from(request: &PortableToolCallRequest) -> Self {
382        Self {
383            request_id: request.request_id.clone(),
384            tool_name: request.tool_name.clone(),
385            server_id: request.server_id.clone(),
386            agent_id: request.agent_id.clone(),
387            arguments: request.arguments.clone(),
388        }
389    }
390}
391
392impl NormalizedEvaluationVerdict {
393    pub fn try_from_evaluation(
394        request: &PortableToolCallRequest,
395        verdict: &EvaluationVerdict,
396    ) -> Result<Self, NormalizationError> {
397        Ok(Self {
398            request: NormalizedRequest::from(request),
399            verdict: NormalizedVerdict::from(verdict.verdict),
400            reason: verdict.reason.clone(),
401            matched_grant_index: verdict.matched_grant_index,
402            verified: verdict
403                .verified
404                .as_ref()
405                .map(NormalizedVerifiedCapability::try_from)
406                .transpose()?,
407        })
408    }
409}
410
411impl From<Verdict> for NormalizedVerdict {
412    fn from(verdict: Verdict) -> Self {
413        match verdict {
414            Verdict::Allow => Self::Allow,
415            Verdict::Deny => Self::Deny,
416            Verdict::PendingApproval => Self::PendingApproval,
417        }
418    }
419}
420
421impl TryFrom<&ChioScope> for NormalizedScope {
422    type Error = NormalizationError;
423
424    fn try_from(scope: &ChioScope) -> Result<Self, Self::Error> {
425        Ok(Self {
426            grants: scope
427                .grants
428                .iter()
429                .map(NormalizedToolGrant::try_from)
430                .collect::<Result<Vec<_>, _>>()?,
431            resource_grants: scope
432                .resource_grants
433                .iter()
434                .map(NormalizedResourceGrant::from)
435                .collect(),
436            prompt_grants: scope
437                .prompt_grants
438                .iter()
439                .map(NormalizedPromptGrant::from)
440                .collect(),
441        })
442    }
443}
444
445impl TryFrom<&ToolGrant> for NormalizedToolGrant {
446    type Error = NormalizationError;
447
448    fn try_from(grant: &ToolGrant) -> Result<Self, Self::Error> {
449        Ok(Self {
450            server_id: grant.server_id.clone(),
451            tool_name: grant.tool_name.clone(),
452            operations: grant
453                .operations
454                .iter()
455                .cloned()
456                .map(NormalizedOperation::from)
457                .collect(),
458            constraints: grant
459                .constraints
460                .iter()
461                .map(NormalizedConstraint::try_from)
462                .collect::<Result<Vec<_>, _>>()?,
463            max_invocations: grant.max_invocations,
464            max_cost_per_invocation: grant
465                .max_cost_per_invocation
466                .as_ref()
467                .map(NormalizedMonetaryAmount::from),
468            max_total_cost: grant
469                .max_total_cost
470                .as_ref()
471                .map(NormalizedMonetaryAmount::from),
472            dpop_required: grant.dpop_required,
473        })
474    }
475}
476
477impl From<&ResourceGrant> for NormalizedResourceGrant {
478    fn from(grant: &ResourceGrant) -> Self {
479        Self {
480            uri_pattern: grant.uri_pattern.clone(),
481            operations: grant
482                .operations
483                .iter()
484                .cloned()
485                .map(NormalizedOperation::from)
486                .collect(),
487        }
488    }
489}
490
491impl From<&PromptGrant> for NormalizedPromptGrant {
492    fn from(grant: &PromptGrant) -> Self {
493        Self {
494            prompt_name: grant.prompt_name.clone(),
495            operations: grant
496                .operations
497                .iter()
498                .cloned()
499                .map(NormalizedOperation::from)
500                .collect(),
501        }
502    }
503}
504
505impl From<&MonetaryAmount> for NormalizedMonetaryAmount {
506    fn from(amount: &MonetaryAmount) -> Self {
507        Self {
508            units: amount.units,
509            currency: amount.currency.clone(),
510        }
511    }
512}
513
514impl From<Operation> for NormalizedOperation {
515    fn from(operation: Operation) -> Self {
516        match operation {
517            Operation::Invoke => Self::Invoke,
518            Operation::ReadResult => Self::ReadResult,
519            Operation::Read => Self::Read,
520            Operation::Subscribe => Self::Subscribe,
521            Operation::Get => Self::Get,
522            Operation::Delegate => Self::Delegate,
523        }
524    }
525}
526
527impl From<RuntimeAssuranceTier> for NormalizedRuntimeAssuranceTier {
528    fn from(tier: RuntimeAssuranceTier) -> Self {
529        match tier {
530            RuntimeAssuranceTier::None => Self::None,
531            RuntimeAssuranceTier::Basic => Self::Basic,
532            RuntimeAssuranceTier::Attested => Self::Attested,
533            RuntimeAssuranceTier::Verified => Self::Verified,
534        }
535    }
536}
537
538impl TryFrom<&Constraint> for NormalizedConstraint {
539    type Error = NormalizationError;
540
541    fn try_from(constraint: &Constraint) -> Result<Self, Self::Error> {
542        match constraint {
543            Constraint::PathPrefix(value) => Ok(Self::PathPrefix(value.clone())),
544            Constraint::DomainExact(value) => Ok(Self::DomainExact(value.clone())),
545            Constraint::DomainGlob(value) => Ok(Self::DomainGlob(value.clone())),
546            Constraint::RegexMatch(value) => Ok(Self::RegexMatch(value.clone())),
547            Constraint::MaxLength(value) => Ok(Self::MaxLength(*value)),
548            Constraint::MaxArgsSize(value) => Ok(Self::MaxArgsSize(*value)),
549            Constraint::GovernedIntentRequired => Ok(Self::GovernedIntentRequired),
550            Constraint::RequireApprovalAbove { threshold_units } => {
551                Ok(Self::RequireApprovalAbove {
552                    threshold_units: *threshold_units,
553                })
554            }
555            Constraint::SellerExact(value) => Ok(Self::SellerExact(value.clone())),
556            Constraint::MinimumRuntimeAssurance(tier) => {
557                Ok(Self::MinimumRuntimeAssurance((*tier).into()))
558            }
559            Constraint::Custom(key, value) => Ok(Self::Custom(key.clone(), value.clone())),
560            unsupported => Err(NormalizationError::UnsupportedConstraint {
561                kind: unsupported_constraint_name(unsupported).to_string(),
562            }),
563        }
564    }
565}
566
567#[cfg(not(kani))]
568fn monetary_cap_is_subset(
569    child: Option<&NormalizedMonetaryAmount>,
570    parent: Option<&NormalizedMonetaryAmount>,
571) -> bool {
572    let child_units = child.map(|cap| cap.units).unwrap_or(0);
573    let parent_units = parent.map(|cap| cap.units).unwrap_or(0);
574    let currency_matches = match (child, parent) {
575        (Some(child_cap), Some(parent_cap)) => child_cap.currency == parent_cap.currency,
576        _ => false,
577    };
578    monetary_cap_is_subset_by_parts(
579        child.is_some(),
580        child_units,
581        parent.is_some(),
582        parent_units,
583        currency_matches,
584    )
585}
586
587#[cfg(kani)]
588fn normalized_operations_subset_bounded_kani(
589    child: &[NormalizedOperation],
590    parent: &[NormalizedOperation],
591) -> bool {
592    if child.is_empty() {
593        return true;
594    }
595    if child.len() == 1 && parent.len() == 1 {
596        return child[0] == parent[0];
597    }
598    false
599}
600
601#[cfg(kani)]
602fn normalized_constraints_subset_bounded_kani(
603    _child: &[NormalizedConstraint],
604    parent: &[NormalizedConstraint],
605) -> bool {
606    parent.is_empty()
607}
608
609#[cfg(kani)]
610fn monetary_cap_is_subset_bounded_kani(
611    child: Option<&NormalizedMonetaryAmount>,
612    parent: Option<&NormalizedMonetaryAmount>,
613) -> bool {
614    match (child, parent) {
615        (None, None) | (Some(_), None) => true,
616        (None, Some(_)) => false,
617        (Some(child_cap), Some(parent_cap)) => {
618            child_cap.units <= parent_cap.units && child_cap.currency == parent_cap.currency
619        }
620    }
621}
622
623fn pattern_covers(parent: &str, child: &str) -> bool {
624    prefix_wildcard_or_exact_covers(parent, child)
625}
626
627fn unsupported_constraint_name(constraint: &Constraint) -> &'static str {
628    match constraint {
629        Constraint::PathPrefix(_) => "path_prefix",
630        Constraint::DomainExact(_) => "domain_exact",
631        Constraint::DomainGlob(_) => "domain_glob",
632        Constraint::RegexMatch(_) => "regex_match",
633        Constraint::MaxLength(_) => "max_length",
634        Constraint::MaxArgsSize(_) => "max_args_size",
635        Constraint::GovernedIntentRequired => "governed_intent_required",
636        Constraint::RequireApprovalAbove { .. } => "require_approval_above",
637        Constraint::RequireCumulativeApprovalAbove { .. } => "require_cumulative_approval_above",
638        Constraint::SellerExact(_) => "seller_exact",
639        Constraint::MinimumRuntimeAssurance(_) => "minimum_runtime_assurance",
640        Constraint::MinimumAutonomyTier(_) => "minimum_autonomy_tier",
641        Constraint::Custom(_, _) => "custom",
642        Constraint::TableAllowlist(_) => "table_allowlist",
643        Constraint::ColumnDenylist(_) => "column_denylist",
644        Constraint::MaxRowsReturned(_) => "max_rows_returned",
645        Constraint::OperationClass(_) => "operation_class",
646        Constraint::AudienceAllowlist(_) => "audience_allowlist",
647        Constraint::ContentReviewTier(_) => "content_review_tier",
648        Constraint::MaxTransactionAmountUsd(_) => "max_transaction_amount_usd",
649        Constraint::RequireDualApproval(_) => "require_dual_approval",
650        Constraint::ModelConstraint { .. } => "model_constraint",
651        Constraint::MemoryStoreAllowlist(_) => "memory_store_allowlist",
652        Constraint::MemoryWriteDenyPatterns(_) => "memory_write_deny_patterns",
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use alloc::vec;
660
661    fn grant(constraints: Vec<Constraint>) -> ToolGrant {
662        ToolGrant {
663            server_id: "srv-a".to_string(),
664            tool_name: "tool-a".to_string(),
665            operations: vec![Operation::Invoke],
666            constraints,
667            max_invocations: Some(4),
668            max_cost_per_invocation: Some(MonetaryAmount {
669                units: 100,
670                currency: "USD".to_string(),
671            }),
672            max_total_cost: None,
673            dpop_required: Some(true),
674        }
675    }
676
677    #[test]
678    fn normalized_scope_preserves_subset_logic_for_supported_surface() {
679        let parent = ChioScope {
680            grants: vec![grant(vec![Constraint::PathPrefix("/tmp".to_string())])],
681            resource_grants: vec![ResourceGrant {
682                uri_pattern: "chio://receipts/*".to_string(),
683                operations: vec![Operation::Read],
684            }],
685            prompt_grants: vec![PromptGrant {
686                prompt_name: "*".to_string(),
687                operations: vec![Operation::Get],
688            }],
689        };
690        let child = ChioScope {
691            grants: vec![grant(vec![
692                Constraint::PathPrefix("/tmp".to_string()),
693                Constraint::MaxLength(32),
694            ])],
695            resource_grants: vec![ResourceGrant {
696                uri_pattern: "chio://receipts/session/*".to_string(),
697                operations: vec![Operation::Read],
698            }],
699            prompt_grants: vec![PromptGrant {
700                prompt_name: "risk_*".to_string(),
701                operations: vec![Operation::Get],
702            }],
703        };
704
705        let normalized_parent = NormalizedScope::try_from(&parent).expect("parent normalizes");
706        let normalized_child = NormalizedScope::try_from(&child).expect("child normalizes");
707
708        assert!(normalized_child.is_subset_of(&normalized_parent));
709    }
710
711    #[test]
712    fn normalized_scope_rejects_unsupported_constraint() {
713        let scope = ChioScope {
714            grants: vec![grant(vec![Constraint::TableAllowlist(vec![
715                "users".to_string()
716            ])])],
717            resource_grants: vec![],
718            prompt_grants: vec![],
719        };
720
721        let error = NormalizedScope::try_from(&scope).expect_err("unsupported constraint fails");
722        assert_eq!(
723            error,
724            NormalizationError::UnsupportedConstraint {
725                kind: "table_allowlist".to_string(),
726            }
727        );
728    }
729
730    #[test]
731    fn normalized_scope_rejects_cumulative_approval_until_enforced() {
732        let scope = ChioScope {
733            grants: vec![grant(vec![Constraint::RequireCumulativeApprovalAbove {
734                threshold: MonetaryAmount {
735                    units: 10,
736                    currency: "USD".to_string(),
737                },
738                approval_budget_id: "budget-1".to_string(),
739                approval_budget_epoch: 1,
740                cumulative_approval_root_binding: None,
741            }])],
742            resource_grants: vec![],
743            prompt_grants: vec![],
744        };
745
746        let error = NormalizedScope::try_from(&scope)
747            .expect_err("cumulative approval requires atomic enforcement");
748        assert_eq!(
749            error,
750            NormalizationError::UnsupportedConstraint {
751                kind: "require_cumulative_approval_above".to_string(),
752            }
753        );
754    }
755
756    #[test]
757    fn normalized_evaluation_captures_verified_projection() {
758        let request = PortableToolCallRequest {
759            request_id: "req-1".to_string(),
760            tool_name: "tool-a".to_string(),
761            server_id: "srv-a".to_string(),
762            agent_id: "agent-1".to_string(),
763            arguments: serde_json::json!({"path":"/tmp/demo.txt"}),
764        };
765        let verified = VerifiedCapability {
766            id: "cap-1".to_string(),
767            subject_hex: "agent-1".to_string(),
768            issuer_hex: "issuer-1".to_string(),
769            scope: ChioScope {
770                grants: vec![grant(vec![Constraint::PathPrefix("/tmp".to_string())])],
771                resource_grants: vec![],
772                prompt_grants: vec![],
773            },
774            issued_at: 10,
775            expires_at: 20,
776            evaluated_at: 15,
777        };
778        let verdict = EvaluationVerdict {
779            verdict: Verdict::Allow,
780            reason: None,
781            matched_grant_index: Some(0),
782            verified: Some(verified),
783        };
784
785        let normalized = NormalizedEvaluationVerdict::try_from_evaluation(&request, &verdict)
786            .expect("evaluation normalizes");
787
788        assert_eq!(normalized.request.request_id, "req-1");
789        assert_eq!(normalized.verdict, NormalizedVerdict::Allow);
790        assert_eq!(
791            normalized
792                .verified
793                .as_ref()
794                .expect("verified projection present")
795                .capability
796                .id,
797            "cap-1"
798        );
799    }
800}