Skip to main content

agent_registry_contract/
lib.rs

1//! Stable contracts for the Agent Registry and cross-domain resource relations.
2
3use std::{collections::BTreeSet, fmt};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
7
8pub const SERVICE_NAME: &str = "agent-registry";
9pub const AUDIENCE: &str = "agent-registry";
10pub const DEFAULT_PORT: u16 = 5100;
11pub const HEALTH_PATH: &str = "/health";
12pub const AGENTS_PATH: &str = "/internal/v1/agents";
13pub const AGENT_PATH: &str = "/internal/v1/agents/{agent_id}";
14pub const PUBLIC_AGENTS_PATH: &str = "/v1/agents";
15pub const PUBLIC_AGENT_PATH: &str = "/v1/agents/{agent_id}";
16
17const MAX_IDENTIFIER_BYTES: usize = 255;
18const MAX_RELATIONS_PER_KIND: usize = 256;
19
20#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
21#[serde(transparent)]
22pub struct AgentId(String);
23
24impl AgentId {
25    pub fn new(value: impl Into<String>) -> Result<Self, String> {
26        let value = value.into();
27        if !value.starts_with("agt_") {
28            return Err("agent_id must start with 'agt_'".into());
29        }
30        validate_identifier("agent_id", &value)?;
31        Ok(Self(value))
32    }
33
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37}
38
39impl<'de> Deserialize<'de> for AgentId {
40    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
41    where
42        D: Deserializer<'de>,
43    {
44        let value = String::deserialize(deserializer)?;
45        Self::new(value).map_err(D::Error::custom)
46    }
47}
48
49impl fmt::Display for AgentId {
50    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51        self.0.fmt(formatter)
52    }
53}
54
55/// Domain-neutral many-to-many associations for an Infra-owned resource.
56///
57/// The containing domain decides what each relation means. This type records
58/// associations only; it deliberately does not imply ownership or permission.
59#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase", deny_unknown_fields)]
61pub struct ResourceRelations {
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub agent_ids: Vec<AgentId>,
64    #[serde(default, skip_serializing_if = "Vec::is_empty")]
65    pub runtime_ids: Vec<String>,
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub user_ids: Vec<String>,
68}
69
70impl ResourceRelations {
71    pub fn normalize(mut self) -> Result<Self, String> {
72        normalize_values("agent_ids", &mut self.agent_ids)?;
73        normalize_strings("runtime_ids", &mut self.runtime_ids)?;
74        normalize_strings("user_ids", &mut self.user_ids)?;
75        Ok(self)
76    }
77
78    pub fn contains_user(&self, user_id: &str) -> bool {
79        self.user_ids.iter().any(|candidate| candidate == user_id)
80    }
81}
82
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum AgentStatus {
86    #[default]
87    Active,
88    Archived,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase", deny_unknown_fields)]
93pub struct Agent {
94    pub id: AgentId,
95    pub owner_user_id: String,
96    pub name: String,
97    pub description: String,
98    pub status: AgentStatus,
99    pub revision: u64,
100    #[serde(default)]
101    pub relations: ResourceRelations,
102    pub created_at: DateTime<Utc>,
103    pub updated_at: DateTime<Utc>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase", deny_unknown_fields)]
108pub struct CreateAgentRequest {
109    pub name: String,
110    #[serde(default)]
111    pub description: String,
112    #[serde(default)]
113    pub relations: ResourceRelations,
114    /// Empty or omitted: Registry derives a stable key from the normalized name.
115    #[serde(default)]
116    pub idempotency_key: String,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "camelCase", deny_unknown_fields)]
121pub struct UpdateAgentRequest {
122    pub expected_revision: u64,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub name: Option<String>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub description: Option<String>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub status: Option<AgentStatus>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub relations: Option<ResourceRelations>,
131}
132
133#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase", deny_unknown_fields)]
135pub struct ListAgentsRequest {
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub status: Option<AgentStatus>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub related_agent_id: Option<AgentId>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub related_runtime_id: Option<String>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub related_user_id: Option<String>,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub cursor: Option<String>,
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub limit: Option<u32>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase", deny_unknown_fields)]
152pub struct AgentPage {
153    pub items: Vec<Agent>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub next_cursor: Option<String>,
156}
157
158pub fn validate_name(value: &str) -> Result<String, String> {
159    let value = value.trim();
160    if value.is_empty() || value.chars().count() > 128 {
161        return Err("agent name must contain 1..=128 characters".into());
162    }
163    if value.chars().any(char::is_control) {
164        return Err("agent name must not contain control characters".into());
165    }
166    Ok(value.to_string())
167}
168
169pub fn validate_description(value: &str) -> Result<String, String> {
170    let value = value.trim();
171    if value.chars().count() > 4096 {
172        return Err("agent description must contain at most 4096 characters".into());
173    }
174    if value.chars().any(|character| character == '\0') {
175        return Err("agent description must not contain NUL".into());
176    }
177    Ok(value.to_string())
178}
179
180pub fn validate_identifier(field: &str, value: &str) -> Result<(), String> {
181    if value.is_empty() || value.len() > MAX_IDENTIFIER_BYTES {
182        return Err(format!(
183            "{field} must contain 1..={MAX_IDENTIFIER_BYTES} bytes"
184        ));
185    }
186    if !value.bytes().all(|byte| {
187        byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'@')
188    }) {
189        return Err(format!("{field} contains unsupported characters"));
190    }
191    Ok(())
192}
193
194fn normalize_strings(field: &str, values: &mut Vec<String>) -> Result<(), String> {
195    if values.len() > MAX_RELATIONS_PER_KIND {
196        return Err(format!("{field} contains too many relations"));
197    }
198    for value in values.iter_mut() {
199        *value = value.trim().to_string();
200        validate_identifier(field, value)?;
201    }
202    values.sort();
203    values.dedup();
204    Ok(())
205}
206
207fn normalize_values(field: &str, values: &mut Vec<AgentId>) -> Result<(), String> {
208    if values.len() > MAX_RELATIONS_PER_KIND {
209        return Err(format!("{field} contains too many relations"));
210    }
211    let unique = values.iter().cloned().collect::<BTreeSet<_>>();
212    *values = unique.into_iter().collect();
213    Ok(())
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn relations_are_sets_not_singular_scope_fields() {
222        let relations = ResourceRelations {
223            agent_ids: vec![
224                AgentId::new("agt_b").unwrap(),
225                AgentId::new("agt_a").unwrap(),
226            ],
227            runtime_ids: vec!["runtime-1".into(), "runtime-1".into()],
228            user_ids: vec!["user-2".into(), "user-1".into()],
229        }
230        .normalize()
231        .unwrap();
232        assert_eq!(relations.agent_ids[0].as_str(), "agt_a");
233        assert_eq!(relations.runtime_ids, ["runtime-1"]);
234        assert_eq!(relations.user_ids, ["user-1", "user-2"]);
235    }
236
237    #[test]
238    fn agent_id_validation_cannot_be_bypassed_by_json() {
239        let error = serde_json::from_str::<AgentId>(r#""agent-legacy""#).unwrap_err();
240        assert!(error.to_string().contains("must start with 'agt_'"));
241    }
242
243    #[test]
244    fn create_request_defaults_missing_idempotency_key() {
245        let request: CreateAgentRequest = serde_json::from_value(serde_json::json!({
246            "name": "Weather",
247            "relations": { "runtimeIds": ["rti_1"] }
248        }))
249        .unwrap();
250        assert_eq!(request.name, "Weather");
251        assert_eq!(request.idempotency_key, "");
252        assert_eq!(request.relations.runtime_ids, ["rti_1"]);
253    }
254}