Skip to main content

chio_workflow/
grant.rs

1//! SkillGrant -- extends the capability model for multi-step skill composition.
2//!
3//! A `SkillGrant` authorizes an agent to execute a named skill, which is a
4//! declared sequence of tool invocations. Unlike individual `ToolGrant`s, a
5//! skill grant binds the entire sequence under a single authorization with
6//! a shared budget envelope.
7
8use chio_core::capability::scope::MonetaryAmount;
9use serde::{Deserialize, Serialize};
10
11/// Schema identifier for skill grants.
12pub const SKILL_GRANT_SCHEMA: &str = "chio.skill-grant.v1";
13
14/// A capability grant for a multi-step skill.
15///
16/// This extends the standard Chio capability model by authorizing an ordered
17/// sequence of tool invocations rather than individual tools.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SkillGrant {
20    /// Schema version.
21    pub schema: String,
22
23    /// Unique skill identifier (e.g. "search-and-summarize").
24    pub skill_id: String,
25
26    /// Version of the skill manifest this grant authorizes.
27    pub skill_version: String,
28
29    /// Tool steps authorized by this grant, in declared order.
30    /// Each entry is "server_id:tool_name".
31    pub authorized_steps: Vec<String>,
32
33    /// Maximum number of complete skill executions.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub max_executions: Option<u32>,
36
37    /// Budget envelope for the entire skill execution.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub budget_envelope: Option<MonetaryAmount>,
40
41    /// Maximum wall-clock duration for a single skill execution (seconds).
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub max_duration_secs: Option<u64>,
44
45    /// Whether steps must execute in declared order.
46    /// Defaults to true (strict ordering).
47    #[serde(default = "default_strict_ordering")]
48    pub strict_ordering: bool,
49}
50
51fn default_strict_ordering() -> bool {
52    true
53}
54
55impl SkillGrant {
56    /// Create a new skill grant.
57    pub fn new(skill_id: String, skill_version: String, authorized_steps: Vec<String>) -> Self {
58        Self {
59            schema: SKILL_GRANT_SCHEMA.to_string(),
60            skill_id,
61            skill_version,
62            authorized_steps,
63            max_executions: None,
64            budget_envelope: None,
65            max_duration_secs: None,
66            strict_ordering: true,
67        }
68    }
69
70    /// Check whether a tool step is authorized by this grant.
71    #[must_use]
72    pub fn authorizes_step(&self, server_id: &str, tool_name: &str) -> bool {
73        let key = format!("{server_id}:{tool_name}");
74        self.authorized_steps.contains(&key)
75    }
76
77    /// Check whether a step index is valid for strict ordering.
78    ///
79    /// `completed_steps` is the number of steps already completed.
80    /// Returns true if the proposed step at the given index follows
81    /// the expected order.
82    #[must_use]
83    pub fn is_step_in_order(&self, step_index: usize, completed_steps: usize) -> bool {
84        if !self.strict_ordering {
85            return true;
86        }
87        step_index == completed_steps
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn skill_grant_roundtrip() {
97        let grant = SkillGrant::new(
98            "search-and-summarize".to_string(),
99            "1.0.0".to_string(),
100            vec![
101                "search-server:search".to_string(),
102                "llm-server:summarize".to_string(),
103            ],
104        );
105
106        let json = serde_json::to_string(&grant).unwrap();
107        let deserialized: SkillGrant = serde_json::from_str(&json).unwrap();
108        assert_eq!(deserialized.skill_id, "search-and-summarize");
109        assert_eq!(deserialized.authorized_steps.len(), 2);
110        assert!(deserialized.strict_ordering);
111    }
112
113    #[test]
114    fn authorizes_step() {
115        let grant = SkillGrant::new(
116            "s1".to_string(),
117            "1.0".to_string(),
118            vec!["srv:tool_a".to_string(), "srv:tool_b".to_string()],
119        );
120        assert!(grant.authorizes_step("srv", "tool_a"));
121        assert!(grant.authorizes_step("srv", "tool_b"));
122        assert!(!grant.authorizes_step("srv", "tool_c"));
123    }
124
125    #[test]
126    fn strict_ordering() {
127        let grant = SkillGrant::new(
128            "s1".to_string(),
129            "1.0".to_string(),
130            vec!["a:t1".to_string(), "a:t2".to_string()],
131        );
132        assert!(grant.is_step_in_order(0, 0)); // first step ok
133        assert!(grant.is_step_in_order(1, 1)); // second step ok
134        assert!(!grant.is_step_in_order(1, 0)); // skipping step 0
135    }
136
137    #[test]
138    fn relaxed_ordering() {
139        let mut grant = SkillGrant::new(
140            "s1".to_string(),
141            "1.0".to_string(),
142            vec!["a:t1".to_string(), "a:t2".to_string()],
143        );
144        grant.strict_ordering = false;
145        assert!(grant.is_step_in_order(1, 0)); // out of order ok
146    }
147}