//! Policy export and import operations. use std::path::PathBuf; use tracing::{info, instrument, warn}; 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}; /// 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, config.trust_pack.signer_name.clone(), config.trust_pack.contact.clone(), )?; 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]), signer_name: pack.header.signer_name.clone(), contact: pack.header.contact.clone(), }; // 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(format!( "Failed to serialize assertion for {}: {e}", assertion.subject )) })?; 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(format!( "Failed to import alias '{}' -> '{}': {e}", alias.alias, alias.canonical )) })?; stats.aliases_imported += 1; } // Persist predicate aliases to storage AND update in-memory cache // This ensures aliases survive restarts (Phase 6.5.3) if !pack.predicate_aliases.is_empty() { let alias_sets: Vec = pack.predicate_aliases.iter().map(crate::types::PredicateAliasSet::from).collect(); episteme.persist_predicate_aliases(alias_sets).await?; info!(count = pack.predicate_aliases.len(), "Imported and persisted 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. /// /// If `args.expires` is provided, the acknowledgment will expire at that time. /// Expired acknowledgments are preserved for audit trail (per patent claim 25) /// but the conflict will resurface as BLOCK/FLAG. #[instrument(skip(config), fields(concept_path = %args.concept_path))] pub async fn acknowledge( args: AcknowledgeArgs, config: &AphoriaConfig, ) -> Result<(), AphoriaError> { use crate::expiry; info!("Acknowledging conflict"); // Parse expiry if provided let expires_at = if let Some(ref spec) = args.expires { Some(expiry::parse_expiry(spec)?) } else { None }; let project_root = std::env::current_dir()?; let mut episteme = LocalEpisteme::open(config, &project_root).await?; // Build acknowledgment payload as JSON // This allows storing both reason and expiry while maintaining backwards compatibility // (legacy acks stored as plain text are still readable) let ack_payload = serde_json::json!({ "reason": args.reason, "expires_at": expires_at, }); // Create acknowledgment assertion with JSON payload let claim = ExtractedClaim { concept_path: args.concept_path.clone(), predicate: predicates::ACKNOWLEDGED.to_string(), value: stemedb_core::types::ObjectValue::Text(ack_payload.to_string()), 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; // Log expiry info if set if let Some(ts) = expires_at { info!( concept_path = %args.concept_path, expires = %expiry::format_expiry(ts), "Acknowledgment created with expiry" ); } 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 // Preserve signer info from original pack 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(), pack.header.signer_name.clone(), pack.header.contact.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()) } } } } // ============================================================================ // Ack Export/Import (Version Control) // ============================================================================ /// Statistics from ack export. #[derive(Debug, Clone, Default)] pub struct AckExportStats { /// Number of acks exported. pub exported: usize, /// Path to the export file. pub output_path: std::path::PathBuf, } /// Statistics from ack import. #[derive(Debug, Clone, Default)] pub struct AckImportStats { /// Number of acks imported (created new). pub imported: usize, /// Number of acks skipped (already exist). pub skipped: usize, } /// Export acknowledgments to a version-controllable TOML file. /// /// Reads all acks from the local Episteme and writes them to `.aphoria/acks.toml`. /// This file can be committed to version control for team collaboration. #[instrument(skip(config))] pub async fn export_acks( output: Option, config: &AphoriaConfig, ) -> Result { use crate::ack_file::{AckEntry, AckFile, AckPayload}; info!("Exporting acknowledgments to file"); let project_root = std::env::current_dir()?; let episteme = LocalEpisteme::open(config, &project_root).await?; // Fetch all acks from database let acks = episteme.fetch_acknowledgments().await?; // Convert to AckFile format let mut ack_file = AckFile::new(); for assertion in &acks { // Parse the JSON payload to extract reason and expiry let payload_text = match &assertion.object { stemedb_core::types::ObjectValue::Text(t) => t.clone(), other => format!("{other:?}"), }; let payload = AckPayload::parse(&payload_text); // Format created timestamp (timestamp is u64, need to convert for chrono) let created = i64::try_from(assertion.timestamp) .ok() .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)) .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) .unwrap_or_else(|| format!("{}", assertion.timestamp)); let expires = payload.expires_iso(); ack_file.add(AckEntry { path: assertion.subject.clone(), reason: payload.reason, expires, created, by: None, // Could add agent_id here if we want to track who created it }); } // Determine output path let output_path = output.unwrap_or_else(|| AckFile::default_path(&project_root)); // Save to file ack_file.save(&output_path)?; info!( count = ack_file.len(), path = %output_path.display(), "Acknowledgments exported" ); Ok(AckExportStats { exported: ack_file.len(), output_path }) } /// Import acknowledgments from a TOML file into the local Episteme. /// /// Reads `.aphoria/acks.toml` and creates acknowledgment assertions for each entry. /// Skips entries that already exist (based on concept path). #[instrument(skip(config))] pub async fn import_acks( input: Option, config: &AphoriaConfig, ) -> Result { use crate::ack_file::AckFile; use crate::expiry; info!("Importing acknowledgments from file"); let project_root = std::env::current_dir()?; let mut episteme = LocalEpisteme::open(config, &project_root).await?; // Determine input path let input_path = input.unwrap_or_else(|| AckFile::default_path(&project_root)); if !input_path.exists() { return Err(AphoriaError::Config(format!("Ack file not found: {}", input_path.display()))); } // Load ack file let ack_file = AckFile::load(&input_path)?; info!(count = ack_file.len(), path = %input_path.display(), "Loaded ack file"); // Fetch existing acks to avoid duplicates let existing_acks = episteme.fetch_acknowledgments().await?; let existing_paths: std::collections::HashSet<&str> = existing_acks.iter().map(|a| a.subject.as_str()).collect(); let mut stats = AckImportStats::default(); for entry in &ack_file.acks { // Check if ack already exists if existing_paths.contains(entry.path.as_str()) { stats.skipped += 1; continue; } // Parse expiry if present let expires_at: Option = if let Some(ref exp_str) = entry.expires { // Try to parse as ISO 8601 timestamp chrono::DateTime::parse_from_rfc3339(exp_str).map(|dt| dt.timestamp()).ok().or_else( || { // Fall back to expiry::parse_expiry for duration strings // parse_expiry returns u64, convert to i64 expiry::parse_expiry(exp_str).ok().and_then(|u| i64::try_from(u).ok()) }, ) } else { None }; // Build ack payload as JSON let ack_payload = serde_json::json!({ "reason": entry.reason, "expires_at": expires_at, }); // Create acknowledgment assertion let claim = ExtractedClaim { concept_path: entry.path.clone(), predicate: predicates::ACKNOWLEDGED.to_string(), value: stemedb_core::types::ObjectValue::Text(ack_payload.to_string()), file: "aphoria_ack_import".to_string(), line: 0, matched_text: format!("Acknowledged: {}", entry.reason), confidence: 1.0, description: format!("Imported: {}", entry.reason), }; episteme.ingest_claims(&[claim]).await?; stats.imported += 1; } episteme.shutdown().await; info!(imported = stats.imported, skipped = stats.skipped, "Acknowledgments imported"); Ok(stats) }