Skip to main content

smith_protocol/
policy_abi.rs

1//! Policy ABI Version Management for Smith Platform
2//!
3//! This module provides versioning and compatibility checking for capability bundles,
4//! ensuring deterministic startup failures when ABI mismatches occur.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use thiserror::Error;
9
10/// Current supported Policy ABI version
11/// This must be incremented when capability bundle schema changes in breaking ways
12pub const CURRENT_POLICY_ABI_VERSION: u32 = 1;
13
14/// Policy ABI version information
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct PolicyAbiVersion {
17    /// Major version - incompatible changes
18    pub major: u32,
19    /// Minor version - backward compatible additions
20    pub minor: u32,
21    /// Patch version - bug fixes and clarifications
22    pub patch: u32,
23}
24
25impl PolicyAbiVersion {
26    /// Create a new PolicyAbiVersion
27    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
28        Self {
29            major,
30            minor,
31            patch,
32        }
33    }
34
35    /// Get the current supported ABI version
36    pub fn current() -> Self {
37        Self::new(CURRENT_POLICY_ABI_VERSION, 0, 0)
38    }
39
40    /// Check if this version is compatible with the current ABI
41    pub fn is_compatible(&self) -> bool {
42        self.major == CURRENT_POLICY_ABI_VERSION
43    }
44
45    /// Check if this version is exactly the current version
46    pub fn is_current(&self) -> bool {
47        *self == Self::current()
48    }
49
50    /// Convert to version string for display
51    pub fn to_version_string(&self) -> String {
52        format!("{}.{}.{}", self.major, self.minor, self.patch)
53    }
54
55    /// Parse from version string (e.g., "1.0.0")
56    pub fn from_version_string(version: &str) -> Result<Self, PolicyAbiError> {
57        let parts: Vec<&str> = version.split('.').collect();
58        if parts.len() != 3 {
59            return Err(PolicyAbiError::InvalidVersionFormat(version.to_string()));
60        }
61
62        let major = parts[0]
63            .parse::<u32>()
64            .map_err(|_| PolicyAbiError::InvalidVersionFormat(version.to_string()))?;
65        let minor = parts[1]
66            .parse::<u32>()
67            .map_err(|_| PolicyAbiError::InvalidVersionFormat(version.to_string()))?;
68        let patch = parts[2]
69            .parse::<u32>()
70            .map_err(|_| PolicyAbiError::InvalidVersionFormat(version.to_string()))?;
71
72        Ok(Self::new(major, minor, patch))
73    }
74}
75
76impl fmt::Display for PolicyAbiVersion {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        write!(f, "{}", self.to_version_string())
79    }
80}
81
82/// Capability bundle header with ABI version information
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct CapabilityBundleHeader {
85    /// ABI version of this bundle
86    pub abi_version: PolicyAbiVersion,
87    /// Bundle format version (separate from ABI)
88    pub bundle_version: String,
89    /// Bundle creation timestamp
90    pub created_at: chrono::DateTime<chrono::Utc>,
91    /// SHA256 digest of bundle content (excluding header)
92    pub content_digest: String,
93    /// Bundle metadata
94    pub metadata: CapabilityBundleMetadata,
95}
96
97/// Metadata about the capability bundle
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct CapabilityBundleMetadata {
100    /// Human-readable bundle name
101    pub name: String,
102    /// Bundle description
103    pub description: Option<String>,
104    /// Organization that created this bundle
105    pub organization: Option<String>,
106    /// Git commit hash when bundle was created
107    pub git_commit: Option<String>,
108    /// Build environment information
109    pub build_info: Option<String>,
110}
111
112/// Policy ABI validation errors
113#[derive(Debug, Error)]
114pub enum PolicyAbiError {
115    #[error(
116        "Incompatible Policy ABI version: bundle={bundle_version}, supported={supported_version}"
117    )]
118    IncompatibleVersion {
119        bundle_version: PolicyAbiVersion,
120        supported_version: PolicyAbiVersion,
121    },
122
123    #[error("Invalid version format: {0}")]
124    InvalidVersionFormat(String),
125
126    #[error("Missing ABI version in capability bundle")]
127    MissingAbiVersion,
128
129    #[error("Capability bundle validation failed: {0}")]
130    ValidationFailed(String),
131
132    #[error("Capability bundle deserialization failed: {0}")]
133    DeserializationFailed(String),
134}
135
136/// Policy ABI validator for startup checks
137pub struct PolicyAbiValidator;
138
139impl PolicyAbiValidator {
140    /// Validate capability bundle ABI version on startup
141    ///
142    /// This function MUST be called during admission controller startup.
143    /// It will return an error if the bundle ABI version is incompatible,
144    /// causing deterministic startup failure.
145    pub fn validate_startup_compatibility(
146        bundle_json: &str,
147    ) -> Result<CapabilityBundleHeader, PolicyAbiError> {
148        // Parse just the header to check ABI version
149        let bundle_value: serde_json::Value = serde_json::from_str(bundle_json)
150            .map_err(|e| PolicyAbiError::DeserializationFailed(e.to_string()))?;
151
152        // Extract ABI version from bundle
153        let abi_version = Self::extract_abi_version(&bundle_value)?;
154
155        // Check compatibility
156        if !abi_version.is_compatible() {
157            return Err(PolicyAbiError::IncompatibleVersion {
158                bundle_version: abi_version.clone(),
159                supported_version: PolicyAbiVersion::current(),
160            });
161        }
162
163        // Parse full header if ABI is compatible
164        let header: CapabilityBundleHeader = serde_json::from_value(
165            bundle_value
166                .get("header")
167                .ok_or(PolicyAbiError::MissingAbiVersion)?
168                .clone(),
169        )
170        .map_err(|e| PolicyAbiError::DeserializationFailed(e.to_string()))?;
171
172        Ok(header)
173    }
174
175    /// Extract ABI version from bundle JSON
176    fn extract_abi_version(
177        bundle_value: &serde_json::Value,
178    ) -> Result<PolicyAbiVersion, PolicyAbiError> {
179        let header = bundle_value
180            .get("header")
181            .ok_or(PolicyAbiError::MissingAbiVersion)?;
182
183        let abi_version: PolicyAbiVersion = serde_json::from_value(
184            header
185                .get("abi_version")
186                .ok_or(PolicyAbiError::MissingAbiVersion)?
187                .clone(),
188        )
189        .map_err(|e| PolicyAbiError::DeserializationFailed(e.to_string()))?;
190
191        Ok(abi_version)
192    }
193
194    /// Generate ABI change detection hash for CI validation
195    ///
196    /// This hash should be stored in CI and compared against new builds
197    /// to detect breaking ABI changes.
198    pub fn generate_abi_hash() -> String {
199        use sha2::{Digest, Sha256};
200
201        // Create deterministic representation of current ABI
202        let abi_repr = format!(
203            "POLICY_ABI_V{}_CURRENT_VERSION_{}_FIELDS_{}",
204            CURRENT_POLICY_ABI_VERSION,
205            PolicyAbiVersion::current().to_version_string(),
206            "header,abi_version,bundle_version,created_at,content_digest,metadata"
207        );
208
209        let mut hasher = Sha256::new();
210        hasher.update(abi_repr.as_bytes());
211        format!("{:x}", hasher.finalize())
212    }
213
214    /// Validate that a capability bundle schema hasn't changed in breaking ways
215    pub fn validate_abi_stability(old_hash: &str, new_hash: &str) -> Result<(), PolicyAbiError> {
216        if old_hash != new_hash {
217            return Err(PolicyAbiError::ValidationFailed(format!(
218                "ABI hash mismatch: expected {} but got {}. This indicates a breaking change to the Policy ABI.",
219                old_hash, new_hash
220            )));
221        }
222        Ok(())
223    }
224}
225
226/// Helper trait for capability bundle validation
227pub trait CapabilityBundleValidation {
228    /// Validate ABI compatibility during bundle loading
229    fn validate_abi_compatibility(&self) -> Result<(), PolicyAbiError>;
230}
231
232// Implementation for any type that can provide capability bundle JSON
233impl CapabilityBundleValidation for String {
234    fn validate_abi_compatibility(&self) -> Result<(), PolicyAbiError> {
235        PolicyAbiValidator::validate_startup_compatibility(self)?;
236        Ok(())
237    }
238}
239
240impl CapabilityBundleValidation for &str {
241    fn validate_abi_compatibility(&self) -> Result<(), PolicyAbiError> {
242        PolicyAbiValidator::validate_startup_compatibility(self)?;
243        Ok(())
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use serde_json::json;
251
252    #[test]
253    fn test_policy_abi_version_creation() {
254        let version = PolicyAbiVersion::new(1, 2, 3);
255        assert_eq!(version.major, 1);
256        assert_eq!(version.minor, 2);
257        assert_eq!(version.patch, 3);
258        assert_eq!(version.to_version_string(), "1.2.3");
259    }
260
261    #[test]
262    fn test_current_version() {
263        let current = PolicyAbiVersion::current();
264        assert_eq!(current.major, CURRENT_POLICY_ABI_VERSION);
265        assert_eq!(current.minor, 0);
266        assert_eq!(current.patch, 0);
267    }
268
269    #[test]
270    fn test_version_compatibility() {
271        let current = PolicyAbiVersion::current();
272        assert!(current.is_compatible());
273        assert!(current.is_current());
274
275        let incompatible = PolicyAbiVersion::new(CURRENT_POLICY_ABI_VERSION + 1, 0, 0);
276        assert!(!incompatible.is_compatible());
277        assert!(!incompatible.is_current());
278
279        let older_compatible = PolicyAbiVersion::new(CURRENT_POLICY_ABI_VERSION, 1, 5);
280        assert!(older_compatible.is_compatible());
281        assert!(!older_compatible.is_current());
282    }
283
284    #[test]
285    fn test_version_string_parsing() {
286        let version = PolicyAbiVersion::from_version_string("2.1.5").unwrap();
287        assert_eq!(version.major, 2);
288        assert_eq!(version.minor, 1);
289        assert_eq!(version.patch, 5);
290
291        // Test invalid formats
292        assert!(PolicyAbiVersion::from_version_string("1.2").is_err());
293        assert!(PolicyAbiVersion::from_version_string("invalid").is_err());
294        assert!(PolicyAbiVersion::from_version_string("1.x.3").is_err());
295    }
296
297    #[test]
298    fn test_compatible_bundle_validation() {
299        let current_version = PolicyAbiVersion::current();
300        let bundle_json = json!({
301            "header": {
302                "abi_version": current_version,
303                "bundle_version": "1.0.0",
304                "created_at": "2024-01-01T00:00:00Z",
305                "content_digest": "abc123",
306                "metadata": {
307                    "name": "test-bundle",
308                    "description": "Test capability bundle",
309                    "organization": "Smith Team",
310                    "git_commit": "abc123",
311                    "build_info": "test-build"
312                }
313            },
314            "atoms": {},
315            "macros": {},
316            "playbooks": {}
317        })
318        .to_string();
319
320        let result = PolicyAbiValidator::validate_startup_compatibility(&bundle_json);
321        assert!(result.is_ok());
322
323        let header = result.unwrap();
324        assert_eq!(header.abi_version, current_version);
325        assert_eq!(header.metadata.name, "test-bundle");
326    }
327
328    #[test]
329    fn test_incompatible_bundle_validation() {
330        let incompatible_version = PolicyAbiVersion::new(CURRENT_POLICY_ABI_VERSION + 1, 0, 0);
331        let bundle_json = json!({
332            "header": {
333                "abi_version": incompatible_version,
334                "bundle_version": "2.0.0",
335                "created_at": "2024-01-01T00:00:00Z",
336                "content_digest": "def456",
337                "metadata": {
338                    "name": "future-bundle"
339                }
340            }
341        })
342        .to_string();
343
344        let result = PolicyAbiValidator::validate_startup_compatibility(&bundle_json);
345        assert!(result.is_err());
346
347        match result.unwrap_err() {
348            PolicyAbiError::IncompatibleVersion {
349                bundle_version,
350                supported_version,
351            } => {
352                assert_eq!(bundle_version, incompatible_version);
353                assert_eq!(supported_version, PolicyAbiVersion::current());
354            }
355            _ => panic!("Expected IncompatibleVersion error"),
356        }
357    }
358
359    #[test]
360    fn test_missing_abi_version() {
361        let bundle_json = json!({
362            "header": {
363                "bundle_version": "1.0.0"
364                // Missing abi_version
365            }
366        })
367        .to_string();
368
369        let result = PolicyAbiValidator::validate_startup_compatibility(&bundle_json);
370        assert!(result.is_err());
371        assert!(matches!(
372            result.unwrap_err(),
373            PolicyAbiError::MissingAbiVersion
374        ));
375    }
376
377    #[test]
378    fn test_abi_hash_generation() {
379        let hash1 = PolicyAbiValidator::generate_abi_hash();
380        let hash2 = PolicyAbiValidator::generate_abi_hash();
381
382        // Hash should be deterministic
383        assert_eq!(hash1, hash2);
384
385        // Hash should be valid SHA256 (64 hex chars)
386        assert_eq!(hash1.len(), 64);
387        assert!(hash1.chars().all(|c| c.is_ascii_hexdigit()));
388    }
389
390    #[test]
391    fn test_abi_stability_validation() {
392        let hash = "abc123def456";
393
394        // Same hash should validate
395        assert!(PolicyAbiValidator::validate_abi_stability(hash, hash).is_ok());
396
397        // Different hash should fail
398        let result = PolicyAbiValidator::validate_abi_stability(hash, "different");
399        assert!(result.is_err());
400        assert!(matches!(
401            result.unwrap_err(),
402            PolicyAbiError::ValidationFailed(_)
403        ));
404    }
405
406    #[test]
407    fn test_bundle_validation_trait() {
408        let current_version = PolicyAbiVersion::current();
409        let bundle_json = json!({
410            "header": {
411                "abi_version": current_version,
412                "bundle_version": "1.0.0",
413                "created_at": "2024-01-01T00:00:00Z",
414                "content_digest": "test123",
415                "metadata": {
416                    "name": "trait-test-bundle"
417                }
418            }
419        })
420        .to_string();
421
422        // Test trait implementation
423        assert!(bundle_json.validate_abi_compatibility().is_ok());
424        assert!(bundle_json.as_str().validate_abi_compatibility().is_ok());
425    }
426
427    #[test]
428    fn test_capability_bundle_header_serialization() {
429        let header = CapabilityBundleHeader {
430            abi_version: PolicyAbiVersion::current(),
431            bundle_version: "1.0.0".to_string(),
432            created_at: chrono::Utc::now(),
433            content_digest: "test-digest".to_string(),
434            metadata: CapabilityBundleMetadata {
435                name: "test-bundle".to_string(),
436                description: Some("Test description".to_string()),
437                organization: Some("Smith Team".to_string()),
438                git_commit: Some("abc123".to_string()),
439                build_info: Some("test-build".to_string()),
440            },
441        };
442
443        // Test serialization roundtrip
444        let json = serde_json::to_string(&header).unwrap();
445        let deserialized: CapabilityBundleHeader = serde_json::from_str(&json).unwrap();
446
447        assert_eq!(deserialized.abi_version, header.abi_version);
448        assert_eq!(deserialized.bundle_version, header.bundle_version);
449        assert_eq!(deserialized.metadata.name, header.metadata.name);
450    }
451}