Skip to main content

aivcs_core/domain/
agent_spec.rs

1//! Agent specification and digest computation.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use super::digest;
8use super::error::{AivcsError, Result};
9
10/// Canonical specification for an agent, including all digest components.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct AgentSpec {
13    /// Unique identifier for this spec version.
14    pub spec_id: Uuid,
15
16    /// SHA256 hex digest of canonical JSON representation.
17    pub spec_digest: String,
18
19    /// Git commit SHA where this spec was defined.
20    pub git_sha: String,
21
22    /// SHA256 hex of graph definition bytes.
23    pub graph_digest: String,
24
25    /// SHA256 hex of prompts definition.
26    pub prompts_digest: String,
27
28    /// SHA256 hex of tools definition.
29    pub tools_digest: String,
30
31    /// SHA256 hex of configuration.
32    pub config_digest: String,
33
34    /// When this spec was created.
35    pub created_at: DateTime<Utc>,
36
37    /// Additional metadata.
38    pub metadata: serde_json::Value,
39}
40
41/// Input fields for computing agent spec digest.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct AgentSpecFields {
44    pub git_sha: String,
45    pub graph_digest: String,
46    pub prompts_digest: String,
47    pub tools_digest: String,
48    pub config_digest: String,
49}
50
51impl AgentSpec {
52    /// Create a new agent spec with computed digest.
53    pub fn new(
54        git_sha: String,
55        graph_digest: String,
56        prompts_digest: String,
57        tools_digest: String,
58        config_digest: String,
59    ) -> Result<Self> {
60        if git_sha.is_empty() {
61            return Err(AivcsError::InvalidAgentSpec(
62                "git_sha cannot be empty".to_string(),
63            ));
64        }
65
66        let fields = AgentSpecFields {
67            git_sha: git_sha.clone(),
68            graph_digest: graph_digest.clone(),
69            prompts_digest: prompts_digest.clone(),
70            tools_digest: tools_digest.clone(),
71            config_digest: config_digest.clone(),
72        };
73
74        let spec_digest = Self::compute_digest(&fields)?;
75
76        Ok(Self {
77            spec_id: Uuid::new_v4(),
78            spec_digest,
79            git_sha,
80            graph_digest,
81            prompts_digest,
82            tools_digest,
83            config_digest,
84            created_at: Utc::now(),
85            metadata: serde_json::json!({}),
86        })
87    }
88
89    /// Compute stable SHA256 digest from canonical JSON (RFC 8785-compliant).
90    pub fn compute_digest(fields: &AgentSpecFields) -> Result<String> {
91        let json = serde_json::to_value(fields)?;
92        digest::compute_digest(&json)
93    }
94
95    /// Verify that spec_digest matches computed digest.
96    pub fn verify_digest(&self) -> Result<()> {
97        let fields = AgentSpecFields {
98            git_sha: self.git_sha.clone(),
99            graph_digest: self.graph_digest.clone(),
100            prompts_digest: self.prompts_digest.clone(),
101            tools_digest: self.tools_digest.clone(),
102            config_digest: self.config_digest.clone(),
103        };
104
105        let computed = Self::compute_digest(&fields)?;
106        if computed != self.spec_digest {
107            return Err(AivcsError::DigestMismatch {
108                expected: self.spec_digest.clone(),
109                actual: computed,
110            });
111        }
112        Ok(())
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_agent_spec_serde_roundtrip() {
122        let spec = AgentSpec::new(
123            "abc123def456".to_string(),
124            "graph111".to_string(),
125            "prompts222".to_string(),
126            "tools333".to_string(),
127            "config444".to_string(),
128        )
129        .expect("create spec");
130
131        let json = serde_json::to_string(&spec).expect("serialize");
132        let deserialized: AgentSpec = serde_json::from_str(&json).expect("deserialize");
133
134        assert_eq!(spec, deserialized);
135    }
136
137    #[test]
138    fn test_agent_spec_digest_stable() {
139        let fields1 = AgentSpecFields {
140            git_sha: "abc123".to_string(),
141            graph_digest: "graph111".to_string(),
142            prompts_digest: "prompts222".to_string(),
143            tools_digest: "tools333".to_string(),
144            config_digest: "config444".to_string(),
145        };
146
147        let fields2 = AgentSpecFields {
148            git_sha: "abc123".to_string(),
149            graph_digest: "graph111".to_string(),
150            prompts_digest: "prompts222".to_string(),
151            tools_digest: "tools333".to_string(),
152            config_digest: "config444".to_string(),
153        };
154
155        let digest1 = AgentSpec::compute_digest(&fields1).expect("compute digest 1");
156        let digest2 = AgentSpec::compute_digest(&fields2).expect("compute digest 2");
157
158        assert_eq!(digest1, digest2, "same inputs should produce same digest");
159    }
160
161    #[test]
162    fn test_agent_spec_digest_changes_on_mutation() {
163        let fields1 = AgentSpecFields {
164            git_sha: "abc123".to_string(),
165            graph_digest: "graph111".to_string(),
166            prompts_digest: "prompts222".to_string(),
167            tools_digest: "tools333".to_string(),
168            config_digest: "config444".to_string(),
169        };
170
171        let fields2 = AgentSpecFields {
172            git_sha: "abc123".to_string(),
173            graph_digest: "graph111_MODIFIED".to_string(),
174            prompts_digest: "prompts222".to_string(),
175            tools_digest: "tools333".to_string(),
176            config_digest: "config444".to_string(),
177        };
178
179        let digest1 = AgentSpec::compute_digest(&fields1).expect("compute digest 1");
180        let digest2 = AgentSpec::compute_digest(&fields2).expect("compute digest 2");
181
182        assert_ne!(
183            digest1, digest2,
184            "changed field should produce different digest"
185        );
186    }
187
188    #[test]
189    fn test_agent_spec_verify_digest() {
190        let spec = AgentSpec::new(
191            "abc123".to_string(),
192            "graph111".to_string(),
193            "prompts222".to_string(),
194            "tools333".to_string(),
195            "config444".to_string(),
196        )
197        .expect("create spec");
198
199        assert!(spec.verify_digest().is_ok(), "spec digest should be valid");
200    }
201
202    #[test]
203    fn test_agent_spec_new_rejects_empty_git_sha() {
204        let result = AgentSpec::new(
205            "".to_string(),
206            "graph111".to_string(),
207            "prompts222".to_string(),
208            "tools333".to_string(),
209            "config444".to_string(),
210        );
211
212        assert!(
213            result.is_err(),
214            "creating spec with empty git_sha should fail"
215        );
216    }
217
218    #[test]
219    fn test_agent_spec_digest_golden_value() {
220        // Golden value test: verify exact digest for known input
221        let fields = AgentSpecFields {
222            git_sha: "abc123def456".to_string(),
223            graph_digest: "graph111".to_string(),
224            prompts_digest: "prompts222".to_string(),
225            tools_digest: "tools333".to_string(),
226            config_digest: "config444".to_string(),
227        };
228
229        let digest = AgentSpec::compute_digest(&fields).expect("compute digest");
230
231        // Verify it's a valid 64-char hex string (SHA256)
232        assert_eq!(digest.len(), 64);
233        assert!(digest.chars().all(|c: char| c.is_ascii_hexdigit()));
234
235        // Verify determinism: same fields produce same digest
236        let digest2 = AgentSpec::compute_digest(&fields).expect("compute digest again");
237        assert_eq!(digest, digest2);
238    }
239
240    #[test]
241    fn test_agent_spec_field_order_invariant() {
242        // Verify that constructing the same spec via different field order produces same digest
243        let fields1 = AgentSpecFields {
244            git_sha: "abc123".to_string(),
245            graph_digest: "graph111".to_string(),
246            prompts_digest: "prompts222".to_string(),
247            tools_digest: "tools333".to_string(),
248            config_digest: "config444".to_string(),
249        };
250
251        let digest1 = AgentSpec::compute_digest(&fields1).expect("compute digest 1");
252
253        // Construct via different serialization path (JSON → serde → back)
254        let json_str = serde_json::to_string(&fields1).expect("serialize");
255        let fields2: AgentSpecFields = serde_json::from_str(&json_str).expect("deserialize");
256        let digest2 = AgentSpec::compute_digest(&fields2).expect("compute digest 2");
257
258        assert_eq!(
259            digest1, digest2,
260            "digests should match regardless of construction path"
261        );
262    }
263}