diff --git a/Cargo.lock b/Cargo.lock index 44c20e1..86f3ed7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,7 @@ dependencies = [ "serde_json", "sha2", "shellexpand", + "uuid", "winapi", ] @@ -53,6 +54,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "cassowary" version = "0.3.0" @@ -180,6 +187,30 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -201,6 +232,19 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -212,6 +256,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -224,6 +274,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + [[package]] name = "itertools" version = "0.13.0" @@ -239,6 +307,24 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.178" @@ -276,7 +362,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown", + "hashbrown 0.15.5", ] [[package]] @@ -297,6 +383,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "option-ext" version = "0.2.0" @@ -332,6 +424,22 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -350,6 +458,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "ratatui" version = "0.27.0" @@ -386,7 +500,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom", + "getrandom 0.2.16", "libredox", "thiserror", ] @@ -438,6 +552,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -531,6 +651,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -641,6 +767,23 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -653,6 +796,103 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "winapi" version = "0.3.9" @@ -755,3 +995,97 @@ name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] diff --git a/Cargo.toml b/Cargo.toml index 1cf19bb..9bfb436 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ shellexpand = "3.1.0" serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" sha2 = "0.10.8" +uuid = { version = "1", features = ["v7"] } [target.'cfg(windows)'.dependencies] winapi = { version = "0.3", features = ["winuser", "processthreadsapi"] } diff --git a/src/domain.rs b/src/domain.rs index e3991b2..df9d36f 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -1,10 +1,12 @@ use serde::{Deserialize, Serialize}; use std::fmt; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use uuid::Uuid; pub const POLICY_SCHEMA_VERSION: u16 = 1; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum CommitmentSource { Manual, Planner, @@ -14,6 +16,7 @@ pub enum CommitmentSource { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum CommitmentState { Draft, Active, @@ -24,6 +27,7 @@ pub enum CommitmentState { } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum RuntimeState { Locked, Planning, @@ -34,6 +38,7 @@ pub enum RuntimeState { } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum EnforcementLevel { Observe, Warn, @@ -42,6 +47,7 @@ pub enum EnforcementLevel { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum EvidenceHealth { Available, Degraded(String), @@ -105,6 +111,31 @@ impl fmt::Display for CommitmentValidationError { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PolicySnapshotValidationError { + WrongSchemaVersion, + MissingId, + MissingCommitmentId, + MissingGeneratedByAgentVersion, +} + +impl fmt::Display for PolicySnapshotValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PolicySnapshotValidationError::WrongSchemaVersion => { + write!(f, "policy schema version is unsupported") + } + PolicySnapshotValidationError::MissingId => write!(f, "policy id is required"), + PolicySnapshotValidationError::MissingCommitmentId => { + write!(f, "commitment id is required") + } + PolicySnapshotValidationError::MissingGeneratedByAgentVersion => { + write!(f, "generated agent version is required") + } + } + } +} + impl Commitment { pub fn new_manual( next_action: impl Into, @@ -114,19 +145,13 @@ impl Commitment { let next_action = next_action.into().trim().to_string(); let success_condition = success_condition.into().trim().to_string(); - if next_action.is_empty() { - return Err(CommitmentValidationError::MissingNextAction); - } - if success_condition.is_empty() { - return Err(CommitmentValidationError::MissingSuccessCondition); - } - if timebox.is_zero() { + if timebox.as_secs() == 0 { return Err(CommitmentValidationError::MissingTimebox); } let created_at_unix_secs = unix_secs_now(); - Ok(Self { - id: format!("commitment-{created_at_unix_secs}"), + let commitment = Self { + id: unique_id("commitment"), created_at_unix_secs, source: CommitmentSource::Manual, project_id: None, @@ -136,7 +161,22 @@ impl Commitment { timebox_secs: timebox.as_secs(), transition_policy_id: None, state: CommitmentState::Draft, - }) + }; + commitment.validate()?; + Ok(commitment) + } + + pub fn validate(&self) -> Result<(), CommitmentValidationError> { + if self.next_action.trim().is_empty() { + return Err(CommitmentValidationError::MissingNextAction); + } + if self.success_condition.trim().is_empty() { + return Err(CommitmentValidationError::MissingSuccessCondition); + } + if self.timebox_secs == 0 { + return Err(CommitmentValidationError::MissingTimebox); + } + Ok(()) } } @@ -149,7 +189,7 @@ impl PolicySnapshot { ) -> Self { let created_at_unix_secs = unix_secs_now(); Self { - id: format!("policy-{created_at_unix_secs}"), + id: unique_id("policy"), commitment_id, schema_version: POLICY_SCHEMA_VERSION, created_at_unix_secs, @@ -162,6 +202,22 @@ impl PolicySnapshot { generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(), } } + + pub fn validate(&self) -> Result<(), PolicySnapshotValidationError> { + if self.schema_version != POLICY_SCHEMA_VERSION { + return Err(PolicySnapshotValidationError::WrongSchemaVersion); + } + if self.id.trim().is_empty() { + return Err(PolicySnapshotValidationError::MissingId); + } + if self.commitment_id.trim().is_empty() { + return Err(PolicySnapshotValidationError::MissingCommitmentId); + } + if self.generated_by_agent_version.trim().is_empty() { + return Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion); + } + Ok(()) + } } pub fn unix_secs_now() -> u64 { @@ -171,6 +227,10 @@ pub fn unix_secs_now() -> u64 { .as_secs() } +fn unique_id(prefix: &str) -> String { + format!("{prefix}-{}", Uuid::now_v7()) +} + #[cfg(test)] mod tests { use super::*; @@ -214,4 +274,183 @@ mod tests { assert_eq!(policy.enforcement_level, EnforcementLevel::Warn); assert_eq!(policy.schema_version, 1); } + + #[test] + fn commitment_ids_are_unique_for_rapid_consecutive_construction() { + let first = Commitment::new_manual( + "Implement first action", + "first tests pass", + Duration::from_secs(1500), + ) + .unwrap(); + let second = Commitment::new_manual( + "Implement second action", + "second tests pass", + Duration::from_secs(1500), + ) + .unwrap(); + + assert_ne!(first.id, second.id); + } + + #[test] + fn policy_snapshot_ids_are_unique_for_rapid_consecutive_construction() { + let first = PolicySnapshot::for_runtime( + "commitment-1".to_string(), + RuntimeState::Active, + EnforcementLevel::Warn, + AllowedContext::default(), + ); + let second = PolicySnapshot::for_runtime( + "commitment-1".to_string(), + RuntimeState::Active, + EnforcementLevel::Warn, + AllowedContext::default(), + ); + + assert_ne!(first.id, second.id); + } + + #[test] + fn commitment_serializes_enum_contract_as_snake_case() { + let commitment = Commitment::new_manual( + "Implement domain contracts", + "domain tests pass", + Duration::from_secs(1500), + ) + .unwrap(); + + let json = serde_json::to_string(&commitment).unwrap(); + + assert!(json.contains(r#""source":"manual""#)); + assert!(json.contains(r#""state":"draft""#)); + } + + #[test] + fn policy_snapshot_serializes_enum_contract_as_snake_case() { + let policy = PolicySnapshot::for_runtime( + "commitment-1".to_string(), + RuntimeState::Active, + EnforcementLevel::Warn, + AllowedContext::default(), + ); + + let json = serde_json::to_string(&policy).unwrap(); + + assert!(json.contains(r#""runtime_state":"active""#)); + assert!(json.contains(r#""enforcement_level":"warn""#)); + } + + #[test] + fn commitment_validate_rejects_invalid_public_field_values() { + let mut commitment = + Commitment::new_manual("Refactor state", "tests pass", Duration::from_secs(1500)) + .unwrap(); + commitment.next_action = " ".to_string(); + assert_eq!( + commitment.validate(), + Err(CommitmentValidationError::MissingNextAction) + ); + + commitment.next_action = "Refactor state".to_string(); + commitment.success_condition = " ".to_string(); + assert_eq!( + commitment.validate(), + Err(CommitmentValidationError::MissingSuccessCondition) + ); + + commitment.success_condition = "tests pass".to_string(); + commitment.timebox_secs = 0; + assert_eq!( + commitment.validate(), + Err(CommitmentValidationError::MissingTimebox) + ); + } + + #[test] + fn commitment_validate_rejects_invalid_deserialized_values() { + let json = r#"{ + "id": "commitment-1", + "created_at_unix_secs": 1, + "source": "manual", + "project_id": null, + "template_id": null, + "next_action": "", + "success_condition": "tests pass", + "timebox_secs": 1500, + "transition_policy_id": null, + "state": "draft" + }"#; + let commitment: Commitment = serde_json::from_str(json).unwrap(); + + assert_eq!( + commitment.validate(), + Err(CommitmentValidationError::MissingNextAction) + ); + } + + #[test] + fn policy_snapshot_validate_rejects_invalid_public_field_values() { + let mut policy = PolicySnapshot::for_runtime( + "commitment-1".to_string(), + RuntimeState::Active, + EnforcementLevel::Warn, + AllowedContext::default(), + ); + policy.schema_version = POLICY_SCHEMA_VERSION + 1; + assert_eq!( + policy.validate(), + Err(PolicySnapshotValidationError::WrongSchemaVersion) + ); + + policy.schema_version = POLICY_SCHEMA_VERSION; + policy.id = " ".to_string(); + assert_eq!( + policy.validate(), + Err(PolicySnapshotValidationError::MissingId) + ); + + policy.id = "policy-1".to_string(); + policy.commitment_id = " ".to_string(); + assert_eq!( + policy.validate(), + Err(PolicySnapshotValidationError::MissingCommitmentId) + ); + + policy.commitment_id = "commitment-1".to_string(); + policy.generated_by_agent_version = " ".to_string(); + assert_eq!( + policy.validate(), + Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion) + ); + } + + #[test] + fn policy_snapshot_validate_rejects_invalid_deserialized_values() { + let json = r#"{ + "id": "", + "commitment_id": "commitment-1", + "schema_version": 1, + "created_at_unix_secs": 1, + "runtime_state": "active", + "enforcement_level": "warn", + "allowed_context": { + "window_classes": [], + "window_title_substrings": [], + "domains": [], + "repos": [], + "commands": [] + }, + "required_monitors": [], + "violation_actions": [], + "expires_at_unix_secs": null, + "generated_by_agent_version": "0.1.0" + }"#; + let policy: PolicySnapshot = serde_json::from_str(json).unwrap(); + + assert_eq!( + policy.validate(), + Err(PolicySnapshotValidationError::MissingId) + ); + } }