Skip to main content

dekopon_protocol/
lib.rs

1//! Versioned, transport-independent Dekopon resources.
2//!
3//! The `v1alpha1` shape is inspired by Kubernetes resource documents: each authored
4//! resource carries an API version, kind, metadata, spec, and an optional observed status
5//! where useful. It is intentionally smaller than the Kubernetes API machinery.
6//!
7//! Authored structures reject unknown fields. This catches misspelled security-relevant
8//! settings today; a future API version can introduce an explicit compatibility strategy
9//! if network negotiation requires one.
10
11#![forbid(unsafe_code)]
12
13use std::{collections::BTreeMap, fmt};
14
15use dekopon_capability::{EffectKind, Idempotency, Permission};
16pub use dekopon_core::AgentStatus;
17use dekopon_core::{CapabilityId, ProviderId, RiskLevel};
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21/// API version supported by this crate.
22#[derive(
23    Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
24)]
25pub enum ApiVersion {
26    /// Initial alpha resource format.
27    #[serde(rename = "dekopon.dev/v1alpha1")]
28    V1Alpha1,
29}
30
31impl fmt::Display for ApiVersion {
32    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            Self::V1Alpha1 => formatter.write_str("dekopon.dev/v1alpha1"),
35        }
36    }
37}
38
39/// Resource kind discriminator.
40#[derive(
41    Clone, Copy, Debug, Deserialize, Eq, Hash, JsonSchema, Ord, PartialEq, PartialOrd, Serialize,
42)]
43#[serde(rename_all = "PascalCase")]
44pub enum Kind {
45    /// An agent resource.
46    Agent,
47    /// A capability resource.
48    Capability,
49    /// A provider resource.
50    Provider,
51    /// A list of agents.
52    AgentList,
53    /// A list of capabilities.
54    CapabilityList,
55    /// A list of providers.
56    ProviderList,
57}
58
59impl fmt::Display for Kind {
60    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        write!(formatter, "{self:?}")
62    }
63}
64
65/// Common authored metadata.
66#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
67#[serde(deny_unknown_fields, rename_all = "camelCase")]
68pub struct ObjectMeta {
69    /// Resource name. The configuration loader validates it as the kind-specific ID type.
70    pub name: String,
71    /// Operator-defined labels with stable ordering.
72    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
73    pub labels: BTreeMap<String, String>,
74}
75
76impl ObjectMeta {
77    /// Creates metadata without labels.
78    #[must_use]
79    pub fn named(name: impl Into<String>) -> Self {
80        Self {
81            name: name.into(),
82            labels: BTreeMap::new(),
83        }
84    }
85}
86
87/// Desired state of an agent.
88#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
89#[serde(deny_unknown_fields, rename_all = "camelCase")]
90pub struct AgentSpec {
91    /// Concise operator-facing purpose.
92    pub description: String,
93    /// Whether orchestration may schedule the agent.
94    #[serde(default = "default_enabled")]
95    pub enabled: bool,
96    /// Capabilities the agent may propose. This list itself grants no provider authority.
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub capabilities: Vec<CapabilityId>,
99    /// Providers the agent is expected to use through its capabilities.
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub providers: Vec<ProviderId>,
102    /// Optional model class selected by future orchestration.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub model_class: Option<String>,
105    /// Optional declarative policy profile name.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub policy_profile: Option<String>,
108}
109
110const fn default_enabled() -> bool {
111    true
112}
113
114/// A declarative agent resource.
115#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
116#[serde(deny_unknown_fields, rename_all = "camelCase")]
117pub struct Agent {
118    /// Resource schema version.
119    pub api_version: ApiVersion,
120    /// Must be [`Kind::Agent`].
121    pub kind: Kind,
122    /// Resource identity and labels.
123    pub metadata: ObjectMeta,
124    /// Desired agent state.
125    pub spec: AgentSpec,
126    /// Optional observed state. Local configuration may provide it for operator workflows.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub status: Option<AgentStatus>,
129}
130
131/// Desired state of a capability.
132#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
133#[serde(deny_unknown_fields, rename_all = "camelCase")]
134pub struct CapabilitySpec {
135    /// Concise operator-facing purpose.
136    pub description: String,
137    /// Provider expected to implement this capability.
138    pub provider: ProviderId,
139    /// External-effect classification.
140    pub effect: EffectKind,
141    /// Coarse risk classification available to policy.
142    pub risk: RiskLevel,
143    /// Declared retry behavior.
144    pub idempotency: Idempotency,
145    /// Least-privilege provider permissions.
146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
147    pub permissions: Vec<Permission>,
148}
149
150/// Availability reported for a capability.
151#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
152#[serde(rename_all = "PascalCase")]
153pub enum CapabilityStatus {
154    /// The declared provider is available.
155    Available,
156    /// The declared provider is unavailable.
157    Unavailable,
158    /// Availability has not been observed.
159    Unknown,
160}
161
162impl fmt::Display for CapabilityStatus {
163    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
164        write!(formatter, "{self:?}")
165    }
166}
167
168/// A declarative capability resource.
169#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
170#[serde(deny_unknown_fields, rename_all = "camelCase")]
171pub struct Capability {
172    /// Resource schema version.
173    pub api_version: ApiVersion,
174    /// Must be [`Kind::Capability`].
175    pub kind: Kind,
176    /// Resource identity and labels.
177    pub metadata: ObjectMeta,
178    /// Desired capability state.
179    pub spec: CapabilitySpec,
180    /// Optional observed state.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub status: Option<CapabilityStatus>,
183}
184
185/// Desired state of a provider connection.
186#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
187#[serde(deny_unknown_fields, rename_all = "camelCase")]
188pub struct ProviderSpec {
189    /// Concise operator-facing purpose.
190    pub description: String,
191    /// Provider implementation family, such as `github`.
192    #[serde(rename = "type")]
193    pub provider_type: String,
194    /// Symbolic credential reference resolved only by a future broker.
195    pub credential_ref: String,
196}
197
198/// Availability reported for a provider.
199#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
200#[serde(rename_all = "PascalCase")]
201pub enum ProviderStatus {
202    /// The provider declaration is ready for use.
203    Ready,
204    /// The provider is not available.
205    Unavailable,
206    /// Availability has not been observed.
207    Unknown,
208}
209
210impl fmt::Display for ProviderStatus {
211    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
212        write!(formatter, "{self:?}")
213    }
214}
215
216/// A declarative provider resource.
217#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
218#[serde(deny_unknown_fields, rename_all = "camelCase")]
219pub struct Provider {
220    /// Resource schema version.
221    pub api_version: ApiVersion,
222    /// Must be [`Kind::Provider`].
223    pub kind: Kind,
224    /// Resource identity and labels.
225    pub metadata: ObjectMeta,
226    /// Desired provider state.
227    pub spec: ProviderSpec,
228    /// Optional observed state.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub status: Option<ProviderStatus>,
231}
232
233/// Versioned agent-list response.
234#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
235#[serde(deny_unknown_fields, rename_all = "camelCase")]
236pub struct AgentList {
237    /// Resource schema version.
238    pub api_version: ApiVersion,
239    /// Must be [`Kind::AgentList`].
240    pub kind: Kind,
241    /// Agents in deterministic name order.
242    pub items: Vec<Agent>,
243}
244
245impl AgentList {
246    /// Creates a `v1alpha1` agent list.
247    #[must_use]
248    pub const fn new(items: Vec<Agent>) -> Self {
249        Self {
250            api_version: ApiVersion::V1Alpha1,
251            kind: Kind::AgentList,
252            items,
253        }
254    }
255}
256
257/// Versioned capability-list response.
258#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
259#[serde(deny_unknown_fields, rename_all = "camelCase")]
260pub struct CapabilityList {
261    /// Resource schema version.
262    pub api_version: ApiVersion,
263    /// Must be [`Kind::CapabilityList`].
264    pub kind: Kind,
265    /// Capabilities in deterministic name order.
266    pub items: Vec<Capability>,
267}
268
269impl CapabilityList {
270    /// Creates a `v1alpha1` capability list.
271    #[must_use]
272    pub const fn new(items: Vec<Capability>) -> Self {
273        Self {
274            api_version: ApiVersion::V1Alpha1,
275            kind: Kind::CapabilityList,
276            items,
277        }
278    }
279}
280
281/// Versioned provider-list response.
282#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
283#[serde(deny_unknown_fields, rename_all = "camelCase")]
284pub struct ProviderList {
285    /// Resource schema version.
286    pub api_version: ApiVersion,
287    /// Must be [`Kind::ProviderList`].
288    pub kind: Kind,
289    /// Providers in deterministic name order.
290    pub items: Vec<Provider>,
291}
292
293impl ProviderList {
294    /// Creates a `v1alpha1` provider list.
295    #[must_use]
296    pub const fn new(items: Vec<Provider>) -> Self {
297        Self {
298            api_version: ApiVersion::V1Alpha1,
299            kind: Kind::ProviderList,
300            items,
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use std::collections::BTreeMap;
308
309    use dekopon_capability::{EffectKind, Idempotency};
310    use dekopon_core::{AgentStatus, ProviderId, RiskLevel};
311    use schemars::schema_for;
312
313    use super::{Agent, AgentSpec, ApiVersion, Capability, CapabilitySpec, Kind, ObjectMeta};
314
315    fn agent() -> Agent {
316        Agent {
317            api_version: ApiVersion::V1Alpha1,
318            kind: Kind::Agent,
319            metadata: ObjectMeta {
320                name: "reviewer".to_owned(),
321                labels: BTreeMap::from([("team".to_owned(), "platform".to_owned())]),
322            },
323            spec: AgentSpec {
324                description: "Reviews pull requests".to_owned(),
325                enabled: true,
326                capabilities: vec![
327                    "github.pull-request.read"
328                        .parse()
329                        .expect("valid capability fixture"),
330                ],
331                providers: vec!["github".parse().expect("valid provider fixture")],
332                model_class: Some("reasoning".to_owned()),
333                policy_profile: Some("review-read-only".to_owned()),
334            },
335            status: Some(AgentStatus::Ready),
336        }
337    }
338
339    #[test]
340    fn agent_round_trips_through_json_and_yaml() {
341        let original = agent();
342
343        let json = serde_json::to_string(&original).expect("agent serializes as JSON");
344        let from_json = serde_json::from_str::<Agent>(&json).expect("agent parses as JSON");
345        assert_eq!(from_json, original);
346
347        let yaml = serde_yaml::to_string(&original).expect("agent serializes as YAML");
348        let from_yaml = serde_yaml::from_str::<Agent>(&yaml).expect("agent parses as YAML");
349        assert_eq!(from_yaml, original);
350        assert!(yaml.contains("apiVersion: dekopon.dev/v1alpha1"));
351    }
352
353    #[test]
354    fn rejects_unknown_authored_fields() {
355        let input = r#"
356apiVersion: dekopon.dev/v1alpha1
357kind: Capability
358metadata:
359  name: github.pull-request.read
360spec:
361  description: Reads pull requests
362  provider: github
363  effect: read-only
364  risk: Low
365  idempotency: idempotent
366  permisssions: []
367"#;
368        let error = serde_yaml::from_str::<Capability>(input)
369            .expect_err("misspelled permissions must not be ignored");
370        assert!(error.to_string().contains("unknown field `permisssions`"));
371    }
372
373    #[test]
374    fn generates_json_schema() {
375        let schema = schema_for!(Agent);
376        let encoded = serde_json::to_value(schema).expect("schema serializes");
377        assert_eq!(encoded["title"], "Agent");
378    }
379
380    #[test]
381    fn capability_wire_values_are_explicit() {
382        let capability = Capability {
383            api_version: ApiVersion::V1Alpha1,
384            kind: Kind::Capability,
385            metadata: ObjectMeta::named("github.pull-request.read"),
386            spec: CapabilitySpec {
387                description: "Reads pull requests".to_owned(),
388                provider: "github".parse::<ProviderId>().expect("valid fixture"),
389                effect: EffectKind::ReadOnly,
390                risk: RiskLevel::Low,
391                idempotency: Idempotency::Idempotent,
392                permissions: Vec::new(),
393            },
394            status: None,
395        };
396        let value = serde_json::to_value(capability).expect("capability serializes");
397
398        assert_eq!(value["spec"]["effect"], "read-only");
399        assert_eq!(value["spec"]["risk"], "Low");
400    }
401}