Skip to main content

lean_ctx_protocol/
capability.rs

1//! Capability discovery and conformance contract.
2
3use crate::common::{
4    CapabilityId, ExtensionsV1, ValidationError, deserialize_schema_version,
5    validate_bounded_string, validate_schema_version, validate_unique_strings,
6};
7use crate::experiment::DataClassification;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10
11/// Capability category exposed by a provider or runtime.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum CapabilityKind {
15    Tool,
16    Model,
17    Provider,
18    ContextSource,
19    Validator,
20    AgentConnector,
21    ModelProvider,
22    KnowledgeProvider,
23    OutcomeEvaluator,
24    CostEstimator,
25    QualityEstimator,
26    PolicyGate,
27    Scheduler,
28    ReadCompressionStrategy,
29    SearchRetrieval,
30    AgentRuntime,
31    Addon,
32    RemoteCapability,
33    ShellOutputOptimization,
34    Other,
35}
36
37/// Whether a capability's effects can be undone.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum Reversibility {
41    Reversible,
42    Irreversible,
43    Conditional,
44}
45
46/// Determinism guarantee made by a capability.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum Determinism {
50    Deterministic,
51    Seeded,
52    NonDeterministic,
53}
54
55/// Boundary at which a capability moves data.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum DataMovement {
59    None,
60    LocalOnly,
61    Remote,
62    CrossRegion,
63}
64
65/// Measurement dimensions a capability can report.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct MeasurementSupportV1 {
69    pub latency: bool,
70    pub tokens: bool,
71    pub quality: bool,
72}
73
74/// Per-surface support details in a capability manifest.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct SurfaceSupportV1 {
78    pub supported: bool,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub input_schema_ref: Option<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub output_schema_ref: Option<String>,
83}
84
85/// Provider capability manifest. Unknown top-level fields are retained for additive evolution.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct CapabilityManifestV1 {
88    #[serde(deserialize_with = "deserialize_schema_version")]
89    pub schema_version: u32,
90    pub capability_id: CapabilityId,
91    pub provider: String,
92    pub kind: CapabilityKind,
93    pub version: String,
94    pub surfaces: Vec<String>,
95    pub support_matrix: BTreeMap<String, SurfaceSupportV1>,
96    pub local: bool,
97    pub remote: bool,
98    pub reversibility: Reversibility,
99    pub determinism: Determinism,
100    pub data_movement: DataMovement,
101    pub supported_classifications: Vec<DataClassification>,
102    pub measurement_support: MeasurementSupportV1,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub input_schema_ref: Option<String>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub output_schema_ref: Option<String>,
107    pub conformance_version: u32,
108    #[serde(default, flatten)]
109    pub extra: ExtensionsV1,
110}
111
112const CAPABILITY_RESERVED_FIELDS: &[&str] = &[
113    "schema_version",
114    "capability_id",
115    "provider",
116    "kind",
117    "version",
118    "surfaces",
119    "support_matrix",
120    "local",
121    "remote",
122    "reversibility",
123    "determinism",
124    "data_movement",
125    "supported_classifications",
126    "measurement_support",
127    "input_schema_ref",
128    "output_schema_ref",
129    "conformance_version",
130];
131
132impl CapabilityManifestV1 {
133    /// Validate location and schema invariants.
134    pub fn validate(&self) -> Result<(), ValidationError> {
135        self.extra.validate_reserved(CAPABILITY_RESERVED_FIELDS)?;
136        validate_schema_version(self.schema_version)?;
137        if !self.local && !self.remote {
138            return Err(ValidationError::new(
139                "capability must support local or remote execution",
140            ));
141        }
142        validate_bounded_string(&self.provider, "provider")?;
143        validate_bounded_string(&self.version, "capability version")?;
144        validate_unique_strings(&self.surfaces, "surfaces")?;
145        if self.surfaces.is_empty() {
146            return Err(ValidationError::new(
147                "capability must declare at least one surface",
148            ));
149        }
150        let supported = self
151            .support_matrix
152            .keys()
153            .map(String::as_str)
154            .collect::<Vec<_>>();
155        validate_unique_strings(&supported, "support_matrix")?;
156        if self
157            .support_matrix
158            .keys()
159            .any(|surface| !self.surfaces.contains(surface))
160        {
161            return Err(ValidationError::new(
162                "support_matrix contains a surface not declared in surfaces",
163            ));
164        }
165        if self.supported_classifications.len() > 4
166            || self
167                .supported_classifications
168                .iter()
169                .enumerate()
170                .any(|(index, value)| self.supported_classifications[index + 1..].contains(value))
171        {
172            return Err(ValidationError::new(
173                "supported_classifications exceeds the enum or contains duplicates",
174            ));
175        }
176        if self.remote
177            && matches!(
178                self.data_movement,
179                DataMovement::None | DataMovement::LocalOnly
180            )
181        {
182            return Err(ValidationError::new(
183                "remote capability must declare remote or cross-region data movement",
184            ));
185        }
186        if !self.remote
187            && matches!(
188                self.data_movement,
189                DataMovement::Remote | DataMovement::CrossRegion
190            )
191        {
192            return Err(ValidationError::new(
193                "remote or cross-region data movement requires remote=true",
194            ));
195        }
196        if self.remote
197            && self.supported_classifications.iter().any(|classification| {
198                matches!(
199                    classification,
200                    DataClassification::Confidential | DataClassification::Restricted
201                )
202            })
203        {
204            return Err(ValidationError::new(
205                "remote capability cannot accept confidential or restricted data",
206            ));
207        }
208        Ok(())
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn id<T>(value: &str) -> T
217    where
218        T: TryFrom<String>,
219        <T as TryFrom<String>>::Error: std::fmt::Debug,
220    {
221        T::try_from(value.to_owned()).expect("identifier should be valid")
222    }
223
224    #[test]
225    fn serialization_round_trip() {
226        let manifest = CapabilityManifestV1 {
227            schema_version: 1,
228            capability_id: id("capability:search"),
229            provider: "provider-1".to_owned(),
230            kind: CapabilityKind::Tool,
231            version: "1.0.0".to_owned(),
232            surfaces: vec!["mcp".to_owned(), "cli".to_owned()],
233            support_matrix: BTreeMap::from([(
234                "mcp".to_owned(),
235                SurfaceSupportV1 {
236                    supported: true,
237                    input_schema_ref: Some("schema:input".to_owned()),
238                    output_schema_ref: Some("schema:output".to_owned()),
239                },
240            )]),
241            local: true,
242            remote: true,
243            reversibility: Reversibility::Reversible,
244            determinism: Determinism::Deterministic,
245            data_movement: DataMovement::Remote,
246            supported_classifications: vec![DataClassification::Public],
247            measurement_support: MeasurementSupportV1 {
248                latency: true,
249                tokens: true,
250                quality: false,
251            },
252            input_schema_ref: Some("schema:input".to_owned()),
253            output_schema_ref: Some("schema:output".to_owned()),
254            conformance_version: 1,
255            extra: {
256                let mut extra = ExtensionsV1::default();
257                extra
258                    .insert("future_field", serde_json::Value::from(true))
259                    .expect("extension should be valid");
260                extra
261            },
262        };
263        let json = serde_json::to_string(&manifest).expect("manifest should serialize");
264        let decoded: CapabilityManifestV1 =
265            serde_json::from_str(&json).expect("manifest should deserialize");
266        assert_eq!(manifest, decoded);
267        manifest
268            .validate()
269            .expect("manifest should satisfy invariants");
270    }
271
272    #[test]
273    fn capability_kind_wire_names_cover_protocol_roles() {
274        let kinds = [
275            (CapabilityKind::Tool, "tool"),
276            (CapabilityKind::Model, "model"),
277            (CapabilityKind::Provider, "provider"),
278            (CapabilityKind::ContextSource, "context_source"),
279            (CapabilityKind::Validator, "validator"),
280            (CapabilityKind::AgentConnector, "agent_connector"),
281            (CapabilityKind::ModelProvider, "model_provider"),
282            (CapabilityKind::KnowledgeProvider, "knowledge_provider"),
283            (CapabilityKind::OutcomeEvaluator, "outcome_evaluator"),
284            (CapabilityKind::CostEstimator, "cost_estimator"),
285            (CapabilityKind::QualityEstimator, "quality_estimator"),
286            (CapabilityKind::PolicyGate, "policy_gate"),
287            (CapabilityKind::Scheduler, "scheduler"),
288            (
289                CapabilityKind::ReadCompressionStrategy,
290                "read_compression_strategy",
291            ),
292            (CapabilityKind::SearchRetrieval, "search_retrieval"),
293            (CapabilityKind::AgentRuntime, "agent_runtime"),
294            (CapabilityKind::Addon, "addon"),
295            (CapabilityKind::RemoteCapability, "remote_capability"),
296            (
297                CapabilityKind::ShellOutputOptimization,
298                "shell_output_optimization",
299            ),
300            (CapabilityKind::Other, "other"),
301        ];
302        for (kind, expected) in kinds {
303            assert_eq!(
304                serde_json::to_string(&kind).expect("serialize kind"),
305                serde_json::to_string(expected).expect("serialize expected"),
306            );
307        }
308    }
309
310    #[test]
311    fn remote_local_only_is_rejected_fail_closed() {
312        let mut manifest: CapabilityManifestV1 = serde_json::from_str(
313            r#"{
314                "schema_version":1,
315                "capability_id":"capability:test",
316                "provider":"provider",
317                "kind":"tool",
318                "version":"1.0.0",
319                "surfaces":["mcp"],
320                "support_matrix":{"mcp":{"supported":true}},
321                "local":true,
322                "remote":true,
323                "reversibility":"reversible",
324                "determinism":"deterministic",
325                "data_movement":"local_only",
326                "supported_classifications":["Public"],
327                "measurement_support":{"latency":true,"tokens":true,"quality":true},
328                "conformance_version":1
329            }"#,
330        )
331        .expect("manifest should deserialize");
332        assert!(manifest.validate().is_err());
333        manifest.remote = false;
334        manifest
335            .validate()
336            .expect("local-only capability should be valid");
337    }
338}