//! Policy export and import operations. use crate::bridge; use crate::config::AphoriaConfig; use crate::episteme::LocalEpisteme; use crate::error::AphoriaError; use crate::policy::{PackPredicateAliasSet, SignatureRecord, 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)?; // Include predicate aliases from config let predicate_aliases: Vec = config.predicate_aliases.to_alias_sets().iter().map(PackPredicateAliasSet::from).collect(); info!( predicate_alias_sets = predicate_aliases.len(), "Including predicate aliases from config" ); let pack = TrustPack::new_with_predicate_aliases( name, "0.1.0".to_string(), assertions, aliases, predicate_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, /// Number of predicate alias sets imported. pub predicate_aliases_imported: usize, } /// Statistics returned from policy re-signing. #[derive(Debug, Clone, Default)] pub struct ResignStats { /// Number of assertions in the pack. pub assertions_count: usize, /// Number of aliases in the pack. pub aliases_count: usize, /// Length of the signature chain (audit trail). pub signature_chain_length: 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]), }; // Update predicate indexes and store pack source for all assertions // This is needed because ingest_authoritative goes through the WAL, // which doesn't update these indexes directly let predicate_store = stemedb_storage::GenericPredicateIndexStore::new(episteme.store().clone()); for assertion in &pack.assertions { // 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(); // 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" ); } // Add ALL imported assertions to "authoritative" index for conflict detection if let Err(e) = predicate_store.add_to_predicate_index(predicates::AUTHORITATIVE, &hash).await { warn!( subject = %assertion.subject, error = %e, "Failed to add to authoritative index" ); } // Also add to "acknowledged" index if applicable if assertion.predicate == predicates::ACKNOWLEDGED { if let Err(e) = predicate_store.add_to_predicate_index(predicates::ACKNOWLEDGED, &hash).await { warn!( subject = %assertion.subject, error = %e, "Failed to add to acknowledged index" ); } } } } // 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; } // Log predicate aliases (they're stored with the pack, not separately) if !pack.predicate_aliases.is_empty() { info!(count = pack.predicate_aliases.len(), "Pack includes predicate alias sets"); stats.predicate_aliases_imported = pack.predicate_aliases.len(); } 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(()) } /// Re-sign a Trust Pack with a new key. /// /// Loads an existing pack (without verifying the old signature), re-signs with /// a new key, and optionally preserves the signature chain for audit trail. /// /// # Arguments /// /// * `file` - Path to the existing .pack file /// * `output` - Path for the re-signed pack /// * `key_path` - Optional path to the new signing key (defaults to .aphoria/agent.key) /// * `reason` - Optional reason for re-signing (e.g., "Key rotation", "Security incident") /// * `chain_signatures` - Whether to preserve the signature chain for audit trail /// /// # Example /// /// ```ignore /// // Re-sign with new key, preserving audit trail /// resign_policy( /// "old.pack".into(), /// "new.pack".into(), /// None, // Use default key /// Some("Annual key rotation".to_string()), /// true, // Preserve chain /// ).await?; /// ``` #[instrument(skip_all, fields(file = %file.display(), output = %output.display()))] pub async fn resign_policy( file: PathBuf, output: PathBuf, key_path: Option, reason: Option, chain_signatures: bool, ) -> Result { // 1. Load existing pack (skip verification - key may have changed) let pack = TrustPack::load_unverified(&file)?; info!( name = %pack.header.name, version = %pack.header.version, assertions = pack.assertions.len(), "Loaded pack for re-signing" ); // 2. Load new signing key let project_root = std::env::current_dir()?; let key_file = key_path.unwrap_or_else(|| project_root.join(".aphoria/agent.key")); let signing_key = bridge::load_key_from_file(&key_file)?; // 3. Build signature chain (audit trail) let mut chain = pack.signature_chain.clone(); if chain_signatures { chain.push(SignatureRecord { issuer_id: pack.header.issuer_id, signature: pack.signature, signed_at: pack.header.timestamp, reason, }); info!(chain_length = chain.len(), "Preserving signature chain for audit"); } // 4. Create new pack with updated signature let new_pack = TrustPack::resign( pack.header.name.clone(), pack.header.version.clone(), pack.assertions.clone(), pack.aliases.clone(), pack.predicate_aliases.clone(), &signing_key, chain.clone(), )?; // 5. Save to output new_pack.save(&output)?; info!( output = %output.display(), "Re-signed pack saved" ); Ok(ResignStats { assertions_count: new_pack.assertions.len(), aliases_count: new_pack.aliases.len(), signature_chain_length: new_pack.signature_chain.len(), }) } /// 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()) } } } }