//! Policy export and import operations. use crate::bridge; use crate::config::AphoriaConfig; use crate::episteme::LocalEpisteme; use crate::error::AphoriaError; use crate::policy::TrustPack; use crate::types::{predicates, AcknowledgeArgs, ExtractedClaim, UpdateArgs}; use std::path::PathBuf; use tracing::{info, instrument, warn}; /// Export policy from the current project. /// /// Collects all acknowledged conflicts, blessed patterns, and manual aliases into a Trust Pack. #[instrument(skip(config))] pub async fn export_policy( name: String, output: PathBuf, config: &AphoriaConfig, ) -> Result<(), AphoriaError> { info!(name, output = %output.display(), "Exporting policy"); let project_root = std::env::current_dir()?; let episteme = LocalEpisteme::open(config, &project_root).await?; // Fetch acknowledgments (assertions with predicate="acknowledged") let mut assertions = episteme.fetch_acknowledgments().await?; let ack_count = assertions.len(); // Fetch blessed assertions (patterns blessed as authoritative standards) let blessed = episteme.fetch_blessed_assertions().await?; let blessed_count = blessed.len(); assertions.extend(blessed); info!( acknowledged = ack_count, blessed = blessed_count, total = assertions.len(), "Collected assertions for export" ); // Fetch manual aliases let aliases = episteme.fetch_manual_aliases().await?; // Sign with agent key let signing_key = bridge::load_or_generate_key(&project_root)?; let pack = TrustPack::new(name, "0.1.0".to_string(), assertions, aliases, &signing_key)?; pack.save(&output)?; info!("Policy exported to {}", output.display()); Ok(()) } /// Statistics returned from policy import. #[derive(Debug, Clone, Default)] pub struct ImportStats { /// Number of assertions imported. pub assertions_imported: usize, /// Number of aliases imported. pub aliases_imported: usize, } /// Import a Trust Pack into the local Episteme. /// /// Loads and verifies the pack, then imports assertions and aliases /// into the local storage. #[instrument(skip(config), fields(file = %file.display()))] pub async fn import_policy( file: PathBuf, config: &AphoriaConfig, ) -> Result { use stemedb_storage::{AliasStore, PackSourceInfo, PackSourceStore, PredicateIndexStore}; info!(file = %file.display(), "Importing policy"); // Load and verify the pack let pack = TrustPack::load(&file)?; info!( name = %pack.header.name, version = %pack.header.version, assertions = pack.assertions.len(), aliases = pack.aliases.len(), "Trust pack verified" ); // Open local Episteme let project_root = std::env::current_dir()?; let mut episteme = LocalEpisteme::open(config, &project_root).await?; let mut stats = ImportStats::default(); // Import assertions via ingest_authoritative if !pack.assertions.is_empty() { let ingested = episteme.ingest_authoritative(&pack.assertions).await?; stats.assertions_imported = ingested; // Build pack source info for attribution let pack_info = PackSourceInfo { pack_name: pack.header.name.clone(), pack_version: pack.header.version.clone(), issuer_hex: hex::encode(&pack.header.issuer_id[..4]), }; // Also update predicate index for "acknowledged" assertions // and store pack source for all assertions // This is needed because ingest_authoritative goes through the WAL, // which doesn't update these indexes directly for assertion in &pack.assertions { // Store pack source for policy attribution if let Err(e) = episteme.pack_source_store().set_pack_source(&assertion.subject, &pack_info).await { warn!( subject = %assertion.subject, error = %e, "Failed to store pack source" ); } if assertion.predicate == predicates::ACKNOWLEDGED { // Compute hash same way as ingestion let bytes = stemedb_core::serde::serialize(assertion) .map_err(|e| AphoriaError::Storage(e.to_string()))?; let hash = *blake3::hash(&bytes).as_bytes(); // Get predicate index store and add let predicate_store = stemedb_storage::GenericPredicateIndexStore::new(episteme.store().clone()); predicate_store .add_to_predicate_index(predicates::ACKNOWLEDGED, &hash) .await .map_err(|e| AphoriaError::Storage(e.to_string()))?; } } } // Import aliases for alias in &pack.aliases { let alias_store = stemedb_storage::GenericAliasStore::new(episteme.store().clone()); alias_store.set_alias(alias).await.map_err(|e| AphoriaError::Storage(e.to_string()))?; stats.aliases_imported += 1; } episteme.shutdown().await; info!( assertions = stats.assertions_imported, aliases = stats.aliases_imported, "Policy imported successfully" ); Ok(stats) } /// Acknowledge a conflict as intentional. /// /// Creates an assertion in Episteme recording that this conflict has been /// reviewed and accepted. The conflict still appears in reports but marked as ACK. #[instrument(skip(config), fields(concept_path = %args.concept_path))] pub async fn acknowledge( args: AcknowledgeArgs, config: &AphoriaConfig, ) -> Result<(), AphoriaError> { info!("Acknowledging conflict"); let project_root = std::env::current_dir()?; let mut episteme = LocalEpisteme::open(config, &project_root).await?; // Create acknowledgment assertion let claim = ExtractedClaim { concept_path: args.concept_path.clone(), predicate: predicates::ACKNOWLEDGED.to_string(), value: stemedb_core::types::ObjectValue::Text(args.reason.clone()), file: "aphoria_ack".to_string(), line: 0, matched_text: format!("Acknowledged: {}", args.reason), confidence: 1.0, description: format!("Conflict acknowledged: {}", args.reason), }; episteme.ingest_claims(&[claim]).await?; episteme.shutdown().await; Ok(()) } /// Arguments for the bless command. pub use crate::types::BlessArgs; /// Bless a code pattern as the authoritative standard. /// /// Unlike `acknowledge` (which creates a suppression with predicate="acknowledged"), /// `bless` creates an assertion with the actual predicate and value that becomes /// the authoritative standard. Blessed patterns can be exported as Trust Packs /// and imported into other projects. /// /// # Example /// /// ```ignore /// // Bless TLS as required /// bless(BlessArgs { /// concept_path: "code://rust/grpc/tls".to_string(), /// predicate: "enabled".to_string(), /// value: "true".to_string(), /// reason: "All services MUST use mTLS".to_string(), /// }, &config).await?; /// ``` #[instrument(skip(config), fields(concept_path = %args.concept_path, predicate = %args.predicate))] pub async fn bless(args: BlessArgs, config: &AphoriaConfig) -> Result<(), AphoriaError> { info!("Blessing code pattern as standard"); let project_root = std::env::current_dir()?; let mut episteme = LocalEpisteme::open(config, &project_root).await?; // Parse the value string into ObjectValue let value = parse_value(&args.value); // Create the blessed assertion with the actual predicate (not "acknowledged") let claim = ExtractedClaim { concept_path: args.concept_path.clone(), predicate: args.predicate.clone(), // The actual predicate, not "acknowledged" value, file: "aphoria_bless".to_string(), line: 0, matched_text: format!("Blessed: {} = {}", args.predicate, args.value), confidence: 1.0, description: args.reason.clone(), }; episteme.ingest_claims(&[claim]).await?; episteme.shutdown().await; info!(concept_path = %args.concept_path, predicate = %args.predicate, "Pattern blessed as standard"); Ok(()) } /// Record an intentional policy change. /// /// Unlike `acknowledge()` which marks a conflict as reviewed, /// `update()` records a new baseline value for a concept. Use this /// when you intentionally change a configuration and want future /// scans to recognize this as the expected value. /// /// # Example /// /// ```ignore /// // Record intentional change to pool size /// update(UpdateArgs { /// concept_path: "db/pool_size".to_string(), /// value: "100".to_string(), /// reason: "Scaling for Black Friday".to_string(), /// }, &config).await?; /// ``` #[instrument(skip(config), fields(concept_path = %args.concept_path))] pub async fn update(args: UpdateArgs, config: &AphoriaConfig) -> Result<(), AphoriaError> { info!("Recording policy update"); let project_root = std::env::current_dir()?; let mut episteme = LocalEpisteme::open(config, &project_root).await?; // Parse the value string into ObjectValue let value = parse_value(&args.value); // Create policy update assertion let claim = ExtractedClaim { concept_path: args.concept_path.clone(), predicate: predicates::POLICY_UPDATE.to_string(), value, file: "aphoria_update".to_string(), line: 0, matched_text: format!("Policy update: {} = {}", args.concept_path, args.value), confidence: 1.0, description: format!("Intentional change: {}", args.reason), }; episteme.ingest_claims(&[claim]).await?; episteme.shutdown().await; info!( concept_path = %args.concept_path, value = %args.value, "Policy update recorded" ); Ok(()) } /// Parse a string value into an ObjectValue. /// /// Supports: /// - "true"/"false" → Boolean /// - Finite numeric strings → Number (rejects NaN/Infinity) /// - Everything else → Text pub fn parse_value(s: &str) -> stemedb_core::types::ObjectValue { use stemedb_core::types::ObjectValue; match s.to_lowercase().as_str() { "true" => ObjectValue::Boolean(true), "false" => ObjectValue::Boolean(false), _ => { // Try to parse as number, but reject NaN and Infinity // as they're likely unintended and could cause issues downstream if let Ok(n) = s.parse::() { if n.is_finite() { ObjectValue::Number(n) } else { ObjectValue::Text(s.to_string()) } } else { ObjectValue::Text(s.to_string()) } } } }