Claims now flow through StemeDB's append-only knowledge graph instead of mutable TOML files. This resolves all 6 critical claim-bypass code paths: - Bridge: lossless AuthoredClaim ↔ Assertion round-trip (comparison, status, lifecycle mapping) - LocalEpisteme: ingest_authored_claim() and fetch_authored_claims() with AUTHORED_CLAIM predicate index - EpistemeClaimStore: ClaimStore trait backed by StemeDB (append-only delete via deprecation) - CLI handlers: all claim commands read/write through StemeDB - Scanner: loads claims from StemeDB with auto-migration fallback to TOML - Export: new `aphoria claims export` serializes StemeDB claims to TOML/JSON Also cleans up dead code (EpistemeConfig.url), renames ingest_claims→ingest_observations, fixes ClaimFilter.authority_tier type, adds Draft variant to ClaimStatus, and fixes pre-existing clippy warnings (too_many_arguments, filter_next→rfind). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
828 lines
28 KiB
Rust
828 lines
28 KiB
Rust
//! 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, Observation, 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<PackPredicateAliasSet> =
|
|
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<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]),
|
|
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<crate::types::PredicateAliasSet> =
|
|
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 = Observation {
|
|
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_observations(&[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 = Observation {
|
|
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_observations(&[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 = Observation {
|
|
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_observations(&[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<PathBuf>,
|
|
reason: Option<String>,
|
|
chain_signatures: bool,
|
|
) -> Result<ResignStats, AphoriaError> {
|
|
// 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::<f64>() {
|
|
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<std::path::PathBuf>,
|
|
config: &AphoriaConfig,
|
|
) -> Result<AckExportStats, AphoriaError> {
|
|
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<std::path::PathBuf>,
|
|
config: &AphoriaConfig,
|
|
) -> Result<AckImportStats, AphoriaError> {
|
|
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<i64> = 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 = Observation {
|
|
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_observations(&[claim]).await?;
|
|
stats.imported += 1;
|
|
}
|
|
|
|
episteme.shutdown().await;
|
|
|
|
info!(imported = stats.imported, skipped = stats.skipped, "Acknowledgments imported");
|
|
|
|
Ok(stats)
|
|
}
|
|
|
|
/// Export authored claims from `.aphoria/claims.toml` as a signed Trust Pack.
|
|
///
|
|
/// This bridges A2 claims authoring with Trust Pack distribution — authored claims
|
|
/// can be packaged and shared across projects.
|
|
#[instrument(skip(config), fields(name = %name, output = %output.display()))]
|
|
pub async fn export_claims_as_policy(
|
|
name: String,
|
|
output: PathBuf,
|
|
config: &AphoriaConfig,
|
|
) -> Result<usize, AphoriaError> {
|
|
use crate::claims_file::ClaimsFile;
|
|
use crate::types::authored_claim::ClaimStatus;
|
|
|
|
info!("Exporting authored claims as Trust Pack");
|
|
|
|
let project_root = std::env::current_dir()?;
|
|
let claims_path = ClaimsFile::default_path(&project_root);
|
|
let claims_file = ClaimsFile::load(&claims_path)?;
|
|
|
|
// Only export active claims
|
|
let active_claims: Vec<_> =
|
|
claims_file.find_by_status(&ClaimStatus::Active).into_iter().cloned().collect();
|
|
|
|
if active_claims.is_empty() {
|
|
return Err(AphoriaError::Claims(
|
|
"No active claims found in .aphoria/claims.toml".to_string(),
|
|
));
|
|
}
|
|
|
|
let signing_key = bridge::load_or_generate_key(&project_root)?;
|
|
let timestamp = crate::current_timestamp();
|
|
|
|
// Convert authored claims to assertions
|
|
let mut assertions = Vec::with_capacity(active_claims.len());
|
|
for claim in &active_claims {
|
|
let assertion = bridge::authored_claim_to_assertion(claim, &signing_key, timestamp, None)?;
|
|
assertions.push(assertion);
|
|
}
|
|
|
|
let assertion_count = assertions.len();
|
|
|
|
// Include predicate aliases from config
|
|
let predicate_aliases: Vec<PackPredicateAliasSet> =
|
|
config.predicate_aliases.to_alias_sets().iter().map(PackPredicateAliasSet::from).collect();
|
|
|
|
let pack = TrustPack::new_with_predicate_aliases(
|
|
name,
|
|
"0.1.0".to_string(),
|
|
assertions,
|
|
vec![], // No aliases for claim packs
|
|
predicate_aliases,
|
|
&signing_key,
|
|
config.trust_pack.signer_name.clone(),
|
|
config.trust_pack.contact.clone(),
|
|
)?;
|
|
|
|
pack.save(&output)?;
|
|
|
|
info!(
|
|
claims = assertion_count,
|
|
output = %output.display(),
|
|
"Authored claims exported as Trust Pack"
|
|
);
|
|
Ok(assertion_count)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_export_claims_as_policy_empty_claims_file() {
|
|
use crate::claims_file::ClaimsFile;
|
|
|
|
let tmp = tempfile::tempdir().expect("tmpdir");
|
|
let aphoria_dir = tmp.path().join(".aphoria");
|
|
std::fs::create_dir_all(&aphoria_dir).expect("mkdir");
|
|
|
|
// Write an empty claims file
|
|
let claims = ClaimsFile::new();
|
|
let claims_path = ClaimsFile::default_path(tmp.path());
|
|
claims.save(&claims_path).expect("save");
|
|
|
|
let config = AphoriaConfig::default();
|
|
let output = tmp.path().join("out.pack");
|
|
|
|
// Run from the temp directory so current_dir() resolves correctly
|
|
let original_dir = std::env::current_dir().expect("cwd");
|
|
std::env::set_current_dir(tmp.path()).expect("chdir");
|
|
|
|
let result = export_claims_as_policy("test".to_string(), output, &config).await;
|
|
|
|
// Restore original directory
|
|
std::env::set_current_dir(original_dir).expect("restore");
|
|
|
|
assert!(result.is_err());
|
|
let err_msg = format!("{}", result.unwrap_err());
|
|
assert!(
|
|
err_msg.contains("No active claims"),
|
|
"Expected 'No active claims' error, got: {err_msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_value_boolean() {
|
|
let val = parse_value("true");
|
|
assert!(matches!(val, stemedb_core::types::ObjectValue::Boolean(true)));
|
|
|
|
let val = parse_value("false");
|
|
assert!(matches!(val, stemedb_core::types::ObjectValue::Boolean(false)));
|
|
|
|
let val = parse_value("TRUE");
|
|
assert!(matches!(val, stemedb_core::types::ObjectValue::Boolean(true)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_value_number() {
|
|
let val = parse_value("42.5");
|
|
match val {
|
|
stemedb_core::types::ObjectValue::Number(n) => {
|
|
assert!((n - 42.5).abs() < f64::EPSILON);
|
|
}
|
|
_ => panic!("Expected Number"),
|
|
}
|
|
|
|
let val = parse_value("100");
|
|
match val {
|
|
stemedb_core::types::ObjectValue::Number(n) => {
|
|
assert!((n - 100.0).abs() < f64::EPSILON);
|
|
}
|
|
_ => panic!("Expected Number"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_value_rejects_nan_and_infinity() {
|
|
// NaN and Infinity should be parsed as Text to avoid downstream issues
|
|
let val = parse_value("NaN");
|
|
assert!(matches!(val, stemedb_core::types::ObjectValue::Text(_)));
|
|
|
|
let val = parse_value("Infinity");
|
|
assert!(matches!(val, stemedb_core::types::ObjectValue::Text(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_value_text() {
|
|
let val = parse_value("some text");
|
|
assert!(matches!(val, stemedb_core::types::ObjectValue::Text(_)));
|
|
|
|
let val = parse_value("environment_or_vault");
|
|
assert!(matches!(val, stemedb_core::types::ObjectValue::Text(_)));
|
|
}
|
|
}
|