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>
269 lines
9.6 KiB
Rust
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()
|
|
}
|
|
}
|