Major additions: - Staged scanning modes (working tree, staged, committed) with git integration - Drift detection for baseline vs current state comparisons - Hosted API handlers for policy CRUD operations via StemeDB API - stemedb-ontology crate with domain definitions and medical extractors - Consumer health vertical UAT scenarios (GLP-1, gastroparesis, etc.) - Aphoria development skill documentation Code organization: - Split large files into focused modules to stay under 500-line limit - Extracted config tests, episteme helpers/drift/aliases, API helpers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
316 lines
11 KiB
Rust
316 lines
11 KiB
Rust
//! 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<ImportStats, AphoriaError> {
|
|
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::<f64>() {
|
|
if n.is_finite() {
|
|
ObjectValue::Number(n)
|
|
} else {
|
|
ObjectValue::Text(s.to_string())
|
|
}
|
|
} else {
|
|
ObjectValue::Text(s.to_string())
|
|
}
|
|
}
|
|
}
|
|
}
|