stemedb/applications/aphoria/src/episteme/ephemeral.rs
jordan 8af9b48ac7 feat: Complete Aphoria Phase 14 - Governance Workflows
Implement structured approval workflows for pattern promotion with full
audit trails for SOC 2 compliance.

Core Components:
- governance/types.rs: ApprovalRequest, ApprovalStatus, ApprovalDecision
- governance/workflow.rs: ApprovalWorkflow, ApprovalStage with escalation
- governance/store.rs: JSONL persistence for requests and decisions
- governance/state_machine.rs: Approval state transitions with auto-advance
- governance/audit.rs: AuditTrail with JSON/CSV/Markdown export

CLI Commands:
- aphoria governance pending/approve/reject/escalate/status/create
- aphoria audit trail/export/summary

Integration:
- Pipeline gate blocks promotion until governance approval
- Auto-creates approval requests when governance enabled
- Evidence-based auto-approval for high-confidence patterns

Also includes:
- Phase 11-13: Evidence, Lifecycle, Scope modules
- 62+ governance-specific tests (946 total passing)
- Clippy clean with -D warnings
- Refactored cli.rs into submodules (governance, lifecycle, scope, etc.)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 05:16:26 -07:00

269 lines
9.6 KiB
Rust

//! Ephemeral conflict detector that works entirely in-memory.
//!
//! This is the fast path for `aphoria scan` when persistence is not needed.
//! It builds the authoritative corpus and concept index once, then can check
//! conflicts against any number of claims without disk I/O.
use std::collections::HashMap;
use ed25519_dalek::SigningKey;
use stemedb_core::types::Assertion;
use tracing::{info, instrument, warn};
use crate::config::{AphoriaConfig, CorpusConfig};
use crate::corpus::CorpusRegistry;
use crate::policy::TrustPack;
use crate::types::{ConflictResult, ExtractedClaim, PolicySourceInfo, PredicateAliasSet};
use super::concept_index::ConceptIndex;
use super::conflict::check_conflicts_with_predicate_aliases;
use super::corpus::current_timestamp;
/// Ephemeral conflict detector that works entirely in-memory.
///
/// This is the fast path for `aphoria scan` when persistence is not needed.
/// It builds the authoritative corpus and concept index once, then can check
/// conflicts against any number of claims without disk I/O.
///
/// # Example
///
/// ```ignore
/// let detector = EphemeralDetector::new(&signing_key, &corpus_config);
/// let conflicts = detector.check_conflicts(&claims, &config);
/// ```
pub struct EphemeralDetector {
/// Pre-built authoritative corpus (RFC, OWASP, vendor assertions).
#[allow(dead_code)]
corpus: Vec<Assertion>,
/// In-memory index for tail-path matching.
index: ConceptIndex,
/// In-memory aliases from policies.
aliases: HashMap<String, String>,
/// Mapping from assertion subject to policy source info.
/// Used to track which Trust Pack an assertion came from.
pack_sources: HashMap<String, PolicySourceInfo>,
/// Predicate aliases for semantic matching.
predicate_aliases: Vec<PredicateAliasSet>,
}
impl EphemeralDetector {
/// Create a new ephemeral detector with the full authoritative corpus.
///
/// This builds the corpus from all configured sources (hardcoded, RFC, OWASP, vendor)
/// using the CorpusRegistry. The corpus and index are built entirely in-memory.
///
/// # Arguments
///
/// * `signing_key` - Ed25519 key for signing assertions
/// * `corpus_config` - Configuration for corpus sources
#[instrument(skip(signing_key, corpus_config))]
pub fn new(signing_key: &SigningKey, corpus_config: &CorpusConfig) -> Self {
let registry = CorpusRegistry::with_defaults(corpus_config);
let timestamp = current_timestamp();
// Build the full corpus from registry (offline mode to avoid network I/O)
let result = registry.build_all(signing_key, timestamp, corpus_config, true);
let corpus = match result {
Ok(build_result) => {
info!(
total = build_result.total_assertions(),
successful = build_result.successful_builders(),
skipped = build_result.skipped_builders(),
"Corpus built from registry"
);
build_result.assertions
}
Err(e) => {
warn!(error = %e, "Corpus build failed, using empty corpus");
Vec::new()
}
};
let index = ConceptIndex::build(&corpus);
info!(
corpus_size = corpus.len(),
index_entries = index.entries.len(),
"EphemeralDetector initialized"
);
Self {
corpus,
index,
aliases: HashMap::new(),
pack_sources: HashMap::new(),
predicate_aliases: Vec::new(),
}
}
/// Create a new ephemeral detector with just the hardcoded corpus.
///
/// This is a faster initialization path that only uses the built-in assertions.
/// Useful for testing or when minimal corpus is sufficient.
#[allow(dead_code)]
#[instrument(skip(signing_key))]
pub fn new_minimal(signing_key: &SigningKey) -> Self {
let corpus = super::create_authoritative_corpus(signing_key);
let index = ConceptIndex::build(&corpus);
info!(
corpus_size = corpus.len(),
index_entries = index.entries.len(),
"EphemeralDetector initialized (minimal corpus)"
);
Self {
corpus,
index,
aliases: HashMap::new(),
pack_sources: HashMap::new(),
predicate_aliases: Vec::new(),
}
}
/// Ingest policies into the detector.
///
/// Adds assertions from trust packs to the corpus/index and aliases to the alias map.
/// Also tracks which pack each assertion came from for provenance reporting,
/// and imports predicate aliases for semantic matching.
pub fn ingest_policies(&mut self, policies: &[TrustPack]) {
let mut new_assertions = 0;
let mut new_aliases = 0;
let mut new_predicate_aliases = 0;
for pack in policies {
// Create policy source info for this pack
let policy_info = PolicySourceInfo {
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(),
};
// Add assertions to corpus and index
// Use predicate alias normalization when building keys
for assertion in &pack.assertions {
self.corpus.push(assertion.clone());
// Normalize predicate using current predicate aliases
let normalized_predicate = ConceptIndex::normalize_predicate(
&assertion.predicate,
&self.predicate_aliases,
);
// Add to index with normalized predicate
if let Some(key) = ConceptIndex::make_key(&assertion.subject, normalized_predicate)
{
self.index.entries.entry(key).or_default().push(assertion.clone());
}
// Track pack source for this assertion (keyed by subject)
self.pack_sources.insert(assertion.subject.clone(), policy_info.clone());
new_assertions += 1;
}
// Add concept aliases
for alias in &pack.aliases {
self.aliases.insert(alias.alias.to_string(), alias.canonical.to_string());
new_aliases += 1;
}
// Add predicate aliases from pack
for pack_alias in &pack.predicate_aliases {
self.predicate_aliases.push(PredicateAliasSet::from(pack_alias));
new_predicate_aliases += 1;
}
}
info!(new_assertions, new_aliases, new_predicate_aliases, "Ingested policies");
}
/// Set predicate aliases from config.
///
/// This allows predicate aliases to be configured in aphoria.toml
/// in addition to or instead of importing them from Trust Packs.
#[allow(dead_code)]
pub fn set_predicate_aliases(&mut self, aliases: Vec<PredicateAliasSet>) {
self.predicate_aliases = aliases;
}
/// Get the current predicate aliases.
#[allow(dead_code)]
pub fn predicate_aliases(&self) -> &[PredicateAliasSet] {
&self.predicate_aliases
}
/// Get the policy source info for a given assertion subject.
#[allow(dead_code)] // Used in tests
pub fn get_pack_source(&self, subject: &str) -> Option<&PolicySourceInfo> {
self.pack_sources.get(subject)
}
/// Check for conflicts between extracted claims and authoritative sources.
///
/// This is a pure in-memory operation. No persistence, no aliases created.
/// Uses both predicate aliases from config and those imported from Trust Packs.
///
/// # Arguments
///
/// * `claims` - Extracted claims from source code
/// * `config` - Configuration with thresholds
///
/// # Returns
///
/// Vector of conflict results, with debug traces populated based on config.
pub fn check_conflicts(
&self,
claims: &[ExtractedClaim],
config: &AphoriaConfig,
) -> Vec<ConflictResult> {
// Merge predicate aliases from config and from imported packs
let mut all_aliases = config.predicate_aliases.to_alias_sets();
all_aliases.extend(self.predicate_aliases.clone());
check_conflicts_with_predicate_aliases(
claims,
&self.index,
&self.aliases,
&self.pack_sources,
&all_aliases,
config,
false,
)
}
/// Check for conflicts with debug traces enabled.
///
/// Like `check_conflicts`, but populates `ConflictTrace` for each result.
pub fn check_conflicts_debug(
&self,
claims: &[ExtractedClaim],
config: &AphoriaConfig,
) -> Vec<ConflictResult> {
// Merge predicate aliases from config and from imported packs
let mut all_aliases = config.predicate_aliases.to_alias_sets();
all_aliases.extend(self.predicate_aliases.clone());
check_conflicts_with_predicate_aliases(
claims,
&self.index,
&self.aliases,
&self.pack_sources,
&all_aliases,
config,
true,
)
}
/// Get the number of authoritative assertions in the corpus.
#[allow(dead_code)]
pub fn corpus_size(&self) -> usize {
self.corpus.len()
}
/// Get the number of indexed concept keys.
#[allow(dead_code)]
pub fn index_size(&self) -> usize {
self.index.entries.len()
}
}