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    /// The agent's standing orders, handed to the model as its system prompt.
97    ///
98    /// This is untrusted model text by definition. It shapes how an agent answers and nothing
99    /// else: it can never assert identity or authority, name a principal, widen a capability, or
100    /// influence an authorization decision. Everything an agent may actually do comes from broker
101    /// policy, which never reads this field.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub instructions: Option<String>,
104    /// Capabilities the agent may propose. This list itself grants no provider authority.
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub capabilities: Vec<CapabilityId>,
107    /// Providers the agent is expected to use through its capabilities.
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub providers: Vec<ProviderId>,
110    /// Optional model class selected by future orchestration.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub model_class: Option<String>,
113    /// Optional declarative policy profile name.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub policy_profile: Option<String>,
116}
117
118const fn default_enabled() -> bool {
119    true
120}
121
122/// A declarative agent resource.
123#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
124#[serde(deny_unknown_fields, rename_all = "camelCase")]
125pub struct Agent {
126    /// Resource schema version.
127    pub api_version: ApiVersion,
128    /// Must be [`Kind::Agent`].
129    pub kind: Kind,
130    /// Resource identity and labels.
131    pub metadata: ObjectMeta,
132    /// Desired agent state.
133    pub spec: AgentSpec,
134    /// Optional observed state. Local configuration may provide it for operator workflows.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub status: Option<AgentStatus>,
137}
138
139/// Desired state of a capability.
140#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
141#[serde(deny_unknown_fields, rename_all = "camelCase")]
142pub struct CapabilitySpec {
143    /// Concise operator-facing purpose.
144    pub description: String,
145    /// Provider expected to implement this capability.
146    pub provider: ProviderId,
147    /// External-effect classification.
148    pub effect: EffectKind,
149    /// Coarse risk classification available to policy.
150    pub risk: RiskLevel,
151    /// Declared retry behavior.
152    pub idempotency: Idempotency,
153    /// Least-privilege provider permissions.
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub permissions: Vec<Permission>,
156}
157
158/// Availability reported for a capability.
159#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
160#[serde(rename_all = "PascalCase")]
161pub enum CapabilityStatus {
162    /// The declared provider is available.
163    Available,
164    /// The declared provider is unavailable.
165    Unavailable,
166    /// Availability has not been observed.
167    Unknown,
168}
169
170impl fmt::Display for CapabilityStatus {
171    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
172        write!(formatter, "{self:?}")
173    }
174}
175
176/// A declarative capability resource.
177#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
178#[serde(deny_unknown_fields, rename_all = "camelCase")]
179pub struct Capability {
180    /// Resource schema version.
181    pub api_version: ApiVersion,
182    /// Must be [`Kind::Capability`].
183    pub kind: Kind,
184    /// Resource identity and labels.
185    pub metadata: ObjectMeta,
186    /// Desired capability state.
187    pub spec: CapabilitySpec,
188    /// Optional observed state.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub status: Option<CapabilityStatus>,
191}
192
193/// Desired state of a provider connection.
194#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
195#[serde(deny_unknown_fields, rename_all = "camelCase")]
196pub struct ProviderSpec {
197    /// Concise operator-facing purpose.
198    pub description: String,
199    /// Provider implementation family, such as `github`.
200    #[serde(rename = "type")]
201    pub provider_type: String,
202    /// Symbolic credential reference resolved only by a future broker.
203    pub credential_ref: String,
204}
205
206/// Availability reported for a provider.
207#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
208#[serde(rename_all = "PascalCase")]
209pub enum ProviderStatus {
210    /// The provider declaration is ready for use.
211    Ready,
212    /// The provider is not available.
213    Unavailable,
214    /// Availability has not been observed.
215    Unknown,
216}
217
218impl fmt::Display for ProviderStatus {
219    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
220        write!(formatter, "{self:?}")
221    }
222}
223
224/// A declarative provider resource.
225#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
226#[serde(deny_unknown_fields, rename_all = "camelCase")]
227pub struct Provider {
228    /// Resource schema version.
229    pub api_version: ApiVersion,
230    /// Must be [`Kind::Provider`].
231    pub kind: Kind,
232    /// Resource identity and labels.
233    pub metadata: ObjectMeta,
234    /// Desired provider state.
235    pub spec: ProviderSpec,
236    /// Optional observed state.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub status: Option<ProviderStatus>,
239}
240
241/// Versioned agent-list response.
242#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
243#[serde(deny_unknown_fields, rename_all = "camelCase")]
244pub struct AgentList {
245    /// Resource schema version.
246    pub api_version: ApiVersion,
247    /// Must be [`Kind::AgentList`].
248    pub kind: Kind,
249    /// Agents in deterministic name order.
250    pub items: Vec<Agent>,
251}
252
253impl AgentList {
254    /// Creates a `v1alpha1` agent list.
255    #[must_use]
256    pub const fn new(items: Vec<Agent>) -> Self {
257        Self {
258            api_version: ApiVersion::V1Alpha1,
259            kind: Kind::AgentList,
260            items,
261        }
262    }
263}
264
265/// Versioned capability-list response.
266#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
267#[serde(deny_unknown_fields, rename_all = "camelCase")]
268pub struct CapabilityList {
269    /// Resource schema version.
270    pub api_version: ApiVersion,
271    /// Must be [`Kind::CapabilityList`].
272    pub kind: Kind,
273    /// Capabilities in deterministic name order.
274    pub items: Vec<Capability>,
275}
276
277impl CapabilityList {
278    /// Creates a `v1alpha1` capability list.
279    #[must_use]
280    pub const fn new(items: Vec<Capability>) -> Self {
281        Self {
282            api_version: ApiVersion::V1Alpha1,
283            kind: Kind::CapabilityList,
284            items,
285        }
286    }
287}
288
289/// Versioned provider-list response.
290#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
291#[serde(deny_unknown_fields, rename_all = "camelCase")]
292pub struct ProviderList {
293    /// Resource schema version.
294    pub api_version: ApiVersion,
295    /// Must be [`Kind::ProviderList`].
296    pub kind: Kind,
297    /// Providers in deterministic name order.
298    pub items: Vec<Provider>,
299}
300
301impl ProviderList {
302    /// Creates a `v1alpha1` provider list.
303    #[must_use]
304    pub const fn new(items: Vec<Provider>) -> Self {
305        Self {
306            api_version: ApiVersion::V1Alpha1,
307            kind: Kind::ProviderList,
308            items,
309        }
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use std::collections::BTreeMap;
316
317    use dekopon_capability::{EffectKind, Idempotency};
318    use dekopon_core::{AgentStatus, ProviderId, RiskLevel};
319    use schemars::schema_for;
320
321    use super::{Agent, AgentSpec, ApiVersion, Capability, CapabilitySpec, Kind, ObjectMeta};
322
323    fn agent() -> Agent {
324        Agent {
325            api_version: ApiVersion::V1Alpha1,
326            kind: Kind::Agent,
327            metadata: ObjectMeta {
328                name: "reviewer".to_owned(),
329                labels: BTreeMap::from([("team".to_owned(), "platform".to_owned())]),
330            },
331            spec: AgentSpec {
332                description: "Reviews pull requests".to_owned(),
333                enabled: true,
334                instructions: Some("Review the diff and comment; never approve.".to_owned()),
335                capabilities: vec![
336                    "github.pull-request.read"
337                        .parse()
338                        .expect("valid capability fixture"),
339                ],
340                providers: vec!["github".parse().expect("valid provider fixture")],
341                model_class: Some("reasoning".to_owned()),
342                policy_profile: Some("review-read-only".to_owned()),
343            },
344            status: Some(AgentStatus::Ready),
345        }
346    }
347
348    #[test]
349    fn agent_round_trips_through_json_and_yaml() {
350        let original = agent();
351
352        let json = serde_json::to_string(&original).expect("agent serializes as JSON");
353        let from_json = serde_json::from_str::<Agent>(&json).expect("agent parses as JSON");
354        assert_eq!(from_json, original);
355
356        let yaml = serde_yaml::to_string(&original).expect("agent serializes as YAML");
357        let from_yaml = serde_yaml::from_str::<Agent>(&yaml).expect("agent parses as YAML");
358        assert_eq!(from_yaml, original);
359        assert!(yaml.contains("apiVersion: dekopon.dev/v1alpha1"));
360        assert!(yaml.contains("instructions:"));
361    }
362
363    /// Standing orders are optional and absent rather than empty when unauthored.
364    ///
365    /// An agent with no `instructions` must serialize without the key at all, so a round trip
366    /// through the catalog cannot turn "the operator wrote none" into an empty system prompt.
367    #[test]
368    fn absent_instructions_stay_absent_through_a_round_trip() {
369        let mut original = agent();
370        original.spec.instructions = None;
371
372        let value = serde_json::to_value(&original).expect("agent serializes");
373        assert!(value["spec"].get("instructions").is_none(), "{value}");
374
375        let yaml = serde_yaml::to_string(&original).expect("agent serializes as YAML");
376        assert!(!yaml.contains("instructions"), "{yaml}");
377        let decoded = serde_yaml::from_str::<Agent>(&yaml).expect("agent parses as YAML");
378        assert_eq!(decoded, original);
379        assert!(decoded.spec.instructions.is_none());
380    }
381
382    #[test]
383    fn rejects_unknown_authored_fields() {
384        let input = r#"
385apiVersion: dekopon.dev/v1alpha1
386kind: Capability
387metadata:
388  name: github.pull-request.read
389spec:
390  description: Reads pull requests
391  provider: github
392  effect: read-only
393  risk: Low
394  idempotency: idempotent
395  permisssions: []
396"#;
397        let error = serde_yaml::from_str::<Capability>(input)
398            .expect_err("misspelled permissions must not be ignored");
399        assert!(error.to_string().contains("unknown field `permisssions`"));
400    }
401
402    #[test]
403    fn generates_json_schema() {
404        let schema = schema_for!(Agent);
405        let encoded = serde_json::to_value(schema).expect("schema serializes");
406        assert_eq!(encoded["title"], "Agent");
407    }
408
409    #[test]
410    fn capability_wire_values_are_explicit() {
411        let capability = Capability {
412            api_version: ApiVersion::V1Alpha1,
413            kind: Kind::Capability,
414            metadata: ObjectMeta::named("github.pull-request.read"),
415            spec: CapabilitySpec {
416                description: "Reads pull requests".to_owned(),
417                provider: "github".parse::<ProviderId>().expect("valid fixture"),
418                effect: EffectKind::ReadOnly,
419                risk: RiskLevel::Low,
420                idempotency: Idempotency::Idempotent,
421                permissions: Vec::new(),
422            },
423            status: None,
424        };
425        let value = serde_json::to_value(capability).expect("capability serializes");
426
427        assert_eq!(value["spec"]["effect"], "read-only");
428        assert_eq!(value["spec"]["risk"], "Low");
429    }
430}