//! Handler for the `aphoria claims promote` command. //! //! Promotion raises a claim to a higher authority tier (lower tier number) //! by creating a new claim that supersedes the original. The original claim //! is marked Deprecated in the TOML file. //! //! The invariant protected here: claim data is never destroyed. The original //! remains in the file with a Deprecated status; the new claim links back via //! `supersedes` and carries a provenance trail. use aphoria::claims_file::ClaimsFile; use aphoria::{validate_promotion, ClaimStatus, PromotionRequest, PromotionResult}; /// Return the current UNIX timestamp in whole seconds. /// /// Used to generate unique new claim IDs without external dependencies. /// Returns 0 if the system clock is not available (e.g. before UNIX epoch). fn timestamp_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } /// Format the current date as an ISO 8601 string (UTC). fn date_str_now() -> String { chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() } /// Execute a promotion: validate, load, supersede with higher tier, save. /// /// Validates the request, loads the claim from the TOML file, creates a new /// claim at the target tier that supersedes the original, marks the original /// as Deprecated, and saves both changes. /// /// Returns `PromotionResult` on success or soft validation failure. /// Returns `Err(AphoriaError)` only for I/O or unexpected errors that /// prevent the operation from completing (cannot read/write TOML file, /// current directory unavailable, etc.). pub fn execute_promotion( request: PromotionRequest, project_root: &std::path::Path, ) -> Result { let claims_path = ClaimsFile::default_path(project_root); let mut claims_file = ClaimsFile::load(&claims_path)?; // Find the claim by ID. let existing = claims_file.claims.iter().find(|c| c.id == request.claim_id).cloned(); let existing = match existing { Some(c) => c, None => { return Ok(PromotionResult { original_claim_id: request.claim_id.clone(), new_claim_id: String::new(), previous_tier: String::new(), new_tier: String::new(), success: false, error: Some(format!("claim '{}' not found", request.claim_id)), }); } }; // Validate before touching any state. let is_deprecated = existing.status == ClaimStatus::Deprecated; if let Err(e) = validate_promotion(&request, &existing.authority_tier, is_deprecated) { return Ok(PromotionResult { original_claim_id: request.claim_id.clone(), new_claim_id: String::new(), previous_tier: existing.authority_tier.clone(), new_tier: request.target_tier.clone(), success: false, error: Some(e.to_string()), }); } let previous_tier = existing.authority_tier.clone(); let now = date_str_now(); // Build the promoted provenance trail. let new_provenance = format!( "{} | Promoted to {} by {} on {}: {}", existing.provenance, request.target_tier, request.promoted_by, now, request.reason ); // Merge evidence: existing + new, deduplicated, preserving order. let mut merged_evidence = existing.evidence.clone(); for item in &request.evidence { if !merged_evidence.contains(item) { merged_evidence.push(item.clone()); } } let new_id = format!("{}-promoted-{}", request.claim_id, timestamp_secs()); let new_claim = aphoria::AuthoredClaim { id: new_id.clone(), concept_path: existing.concept_path.clone(), predicate: existing.predicate.clone(), value: existing.value.clone(), comparison: existing.comparison.clone(), provenance: new_provenance, invariant: existing.invariant.clone(), consequence: existing.consequence.clone(), authority_tier: request.target_tier.clone(), evidence: merged_evidence, category: existing.category.clone(), status: ClaimStatus::Active, supersedes: Some(existing.id.clone()), created_by: request.promoted_by.clone(), created_at: now.clone(), updated_at: None, }; // Mark the original as Deprecated (in-place — ClaimsFile is a flat mutable TOML file). for c in claims_file.claims.iter_mut() { if c.id == request.claim_id { c.status = ClaimStatus::Deprecated; c.updated_at = Some(now); break; } } // Append the new claim and persist. claims_file.claims.push(new_claim); claims_file.save(&claims_path)?; Ok(PromotionResult { original_claim_id: request.claim_id, new_claim_id: new_id, previous_tier, new_tier: request.target_tier, success: true, error: None, }) } #[cfg(test)] mod tests { use super::*; use aphoria::{AuthoredClaim, AuthoredValue, ClaimStatus}; use tempfile::TempDir; /// Build a minimal active claim for test fixtures. fn sample_claim(id: &str, tier: &str) -> AuthoredClaim { AuthoredClaim { id: id.to_string(), concept_path: format!("test/{id}"), predicate: "behavior".to_string(), value: AuthoredValue::Bool(true), comparison: Default::default(), provenance: "Test analysis by engineer".to_string(), invariant: "System MUST behave correctly".to_string(), consequence: "Incorrect behavior causes failures".to_string(), authority_tier: tier.to_string(), evidence: vec!["ADR-001".to_string()], category: "architecture".to_string(), status: ClaimStatus::Active, supersedes: None, created_by: "tester".to_string(), created_at: "2026-02-01T00:00:00Z".to_string(), updated_at: None, } } fn setup_claims_file(dir: &TempDir, claims: Vec) -> ClaimsFile { let path = ClaimsFile::default_path(dir.path()); let mut file = ClaimsFile::new(); for c in claims { file.claims.push(c); } file.save(&path).expect("save claims file"); file } fn valid_request(claim_id: &str, target_tier: &str) -> PromotionRequest { PromotionRequest { claim_id: claim_id.to_string(), target_tier: target_tier.to_string(), evidence: vec!["new-study-reference".to_string()], reason: "Adopted as org-wide standard after review".to_string(), promoted_by: "jml".to_string(), } } #[test] fn test_happy_path_promotes_claim() { let dir = TempDir::new().expect("temp dir"); setup_claims_file(&dir, vec![sample_claim("claim-001", "community")]); let req = valid_request("claim-001", "expert"); let result = execute_promotion(req, dir.path()).expect("execute"); assert!(result.success, "expected success, got error: {:?}", result.error); assert_eq!(result.original_claim_id, "claim-001"); assert!(!result.new_claim_id.is_empty(), "new_claim_id must be set"); assert_eq!(result.previous_tier, "community"); assert_eq!(result.new_tier, "expert"); assert!(result.error.is_none()); } #[test] fn test_original_marked_deprecated_after_promotion() { let dir = TempDir::new().expect("temp dir"); setup_claims_file(&dir, vec![sample_claim("claim-001", "community")]); let req = valid_request("claim-001", "expert"); let result = execute_promotion(req, dir.path()).expect("execute"); assert!(result.success); // Reload the file and verify the original is Deprecated. let path = ClaimsFile::default_path(dir.path()); let loaded = ClaimsFile::load(&path).expect("reload"); let original = loaded.find_by_id("claim-001").expect("find original"); assert_eq!(original.status, ClaimStatus::Deprecated); assert!(original.updated_at.is_some(), "updated_at must be set on deprecation"); } #[test] fn test_new_claim_persisted_with_correct_fields() { let dir = TempDir::new().expect("temp dir"); setup_claims_file(&dir, vec![sample_claim("claim-001", "community")]); let req = valid_request("claim-001", "expert"); let result = execute_promotion(req, dir.path()).expect("execute"); assert!(result.success); let path = ClaimsFile::default_path(dir.path()); let loaded = ClaimsFile::load(&path).expect("reload"); let new_claim = loaded.find_by_id(&result.new_claim_id).expect("find new claim"); assert_eq!(new_claim.authority_tier, "expert"); assert_eq!(new_claim.status, ClaimStatus::Active); assert_eq!(new_claim.supersedes.as_deref(), Some("claim-001")); assert_eq!(new_claim.created_by, "jml"); // New claim must have inherited the concept path. assert_eq!(new_claim.concept_path, "test/claim-001"); } #[test] fn test_evidence_is_merged_and_deduplicated() { let dir = TempDir::new().expect("temp dir"); let mut claim = sample_claim("claim-001", "community"); // Original has "ADR-001"; request adds "ADR-001" (dup) and "new-study". claim.evidence = vec!["ADR-001".to_string()]; setup_claims_file(&dir, vec![claim]); let mut req = valid_request("claim-001", "expert"); req.evidence = vec!["ADR-001".to_string(), "new-study".to_string()]; let result = execute_promotion(req, dir.path()).expect("execute"); assert!(result.success); let path = ClaimsFile::default_path(dir.path()); let loaded = ClaimsFile::load(&path).expect("reload"); let new_claim = loaded.find_by_id(&result.new_claim_id).expect("find"); // "ADR-001" must appear exactly once; "new-study" must be present. assert_eq!(new_claim.evidence.iter().filter(|e| e.as_str() == "ADR-001").count(), 1); assert!(new_claim.evidence.contains(&"new-study".to_string())); } #[test] fn test_provenance_contains_promotion_trail() { let dir = TempDir::new().expect("temp dir"); setup_claims_file(&dir, vec![sample_claim("claim-001", "community")]); let req = valid_request("claim-001", "expert"); let result = execute_promotion(req, dir.path()).expect("execute"); assert!(result.success); let path = ClaimsFile::default_path(dir.path()); let loaded = ClaimsFile::load(&path).expect("reload"); let new_claim = loaded.find_by_id(&result.new_claim_id).expect("find"); assert!( new_claim.provenance.contains("Promoted to expert"), "provenance must record target tier: {}", new_claim.provenance ); assert!( new_claim.provenance.contains("jml"), "provenance must record promoter: {}", new_claim.provenance ); assert!( new_claim.provenance.contains("Adopted as org-wide standard after review"), "provenance must contain reason: {}", new_claim.provenance ); } #[test] fn test_claim_not_found_returns_soft_failure() { let dir = TempDir::new().expect("temp dir"); setup_claims_file(&dir, vec![]); let req = valid_request("nonexistent-claim", "expert"); let result = execute_promotion(req, dir.path()).expect("execute (I/O must not fail)"); assert!(!result.success); assert!(result.error.is_some()); let msg = result.error.unwrap(); assert!(msg.contains("nonexistent-claim"), "error must name the missing claim: {msg}"); } #[test] fn test_deprecated_claim_returns_validation_failure() { let dir = TempDir::new().expect("temp dir"); let mut claim = sample_claim("claim-001", "community"); claim.status = ClaimStatus::Deprecated; setup_claims_file(&dir, vec![claim]); let req = valid_request("claim-001", "expert"); let result = execute_promotion(req, dir.path()).expect("execute"); assert!(!result.success); let msg = result.error.unwrap(); assert!(msg.contains("deprecated"), "error must mention deprecated status: {msg}"); } #[test] fn test_same_tier_returns_validation_failure() { let dir = TempDir::new().expect("temp dir"); setup_claims_file(&dir, vec![sample_claim("claim-001", "expert")]); let req = valid_request("claim-001", "expert"); let result = execute_promotion(req, dir.path()).expect("execute"); assert!(!result.success); let msg = result.error.unwrap(); assert!( msg.contains("lower authority") || msg.contains("equal"), "error must describe tier constraint: {msg}" ); } #[test] fn test_lower_authority_tier_returns_validation_failure() { let dir = TempDir::new().expect("temp dir"); // Claim is already at "expert" (tier 3); requesting "community" (tier 4) is a demotion. setup_claims_file(&dir, vec![sample_claim("claim-001", "expert")]); let req = valid_request("claim-001", "community"); let result = execute_promotion(req, dir.path()).expect("execute"); assert!(!result.success); assert!(result.error.is_some()); } #[test] fn test_missing_evidence_returns_validation_failure() { let dir = TempDir::new().expect("temp dir"); setup_claims_file(&dir, vec![sample_claim("claim-001", "community")]); let mut req = valid_request("claim-001", "expert"); req.evidence = vec![]; let result = execute_promotion(req, dir.path()).expect("execute"); assert!(!result.success); let msg = result.error.unwrap(); assert!(msg.contains("evidence"), "error must mention evidence: {msg}"); } #[test] fn test_missing_reason_returns_validation_failure() { let dir = TempDir::new().expect("temp dir"); setup_claims_file(&dir, vec![sample_claim("claim-001", "community")]); let mut req = valid_request("claim-001", "expert"); req.reason = " ".to_string(); let result = execute_promotion(req, dir.path()).expect("execute"); assert!(!result.success); let msg = result.error.unwrap(); assert!(msg.contains("reason"), "error must mention reason: {msg}"); } #[test] fn test_anecdotal_to_regulatory_is_valid() { let dir = TempDir::new().expect("temp dir"); setup_claims_file(&dir, vec![sample_claim("claim-001", "anecdotal")]); let req = valid_request("claim-001", "regulatory"); let result = execute_promotion(req, dir.path()).expect("execute"); assert!(result.success); assert_eq!(result.previous_tier, "anecdotal"); assert_eq!(result.new_tier, "regulatory"); } #[test] fn test_file_has_exactly_two_entries_after_promotion() { let dir = TempDir::new().expect("temp dir"); setup_claims_file(&dir, vec![sample_claim("claim-001", "community")]); let req = valid_request("claim-001", "expert"); let result = execute_promotion(req, dir.path()).expect("execute"); assert!(result.success); let path = ClaimsFile::default_path(dir.path()); let loaded = ClaimsFile::load(&path).expect("reload"); // Original (now Deprecated) + new promoted claim. assert_eq!( loaded.len(), 2, "file must contain exactly 2 entries: original (deprecated) + promoted" ); } }