stemedb/applications/aphoria/src/handlers/scope.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

394 lines
12 KiB
Rust

//! Scope command handlers for knowledge hierarchy management.
use std::process::ExitCode;
use chrono::{DateTime, Duration, NaiveDate, Utc};
use aphoria::scope::{override_store_dir, OverrideStore, OverrideValue, ScopeId, ScopeOverride};
use aphoria::AphoriaConfig;
use crate::cli::ScopeCommands;
pub async fn handle_scope_command(command: ScopeCommands, config: &AphoriaConfig) -> ExitCode {
match command {
ScopeCommands::Status => handle_scope_status(config),
ScopeCommands::Override { concept_path, value, reason, evidence, expires } => {
handle_scope_override(config, concept_path, value, reason, evidence, expires)
}
ScopeCommands::List { include_inherited, show_expired } => {
handle_scope_list(config, include_inherited, show_expired)
}
ScopeCommands::Remove { concept_path, force } => {
handle_scope_remove(config, concept_path, force)
}
}
}
fn handle_scope_status(config: &AphoriaConfig) -> ExitCode {
println!("Scope Configuration");
println!("===================");
println!();
// Show configured scope hierarchy
println!("Configured Hierarchy:");
if let Some(ref org) = config.scope.organization {
println!(" Organization: {}", org);
} else {
println!(" Organization: (not set)");
}
if let Some(ref team) = config.scope.team {
println!(" Team: {}", team);
} else {
println!(" Team: (not set)");
}
if let Some(ref project) = config.scope.project {
println!(" Project: {}", project);
} else {
// Try to infer from project config
let project_name = config.project.name.as_deref().unwrap_or("(auto-detected)");
println!(" Project: {}", project_name);
}
println!();
// Show inheritance chain
let ctx = config.scope.to_context();
let chain = ctx.inheritance_chain();
if chain.is_empty() {
println!("Inheritance Chain: (empty - no scopes configured)");
} else {
println!("Inheritance Chain (most specific first):");
for (i, scope) in chain.iter().enumerate() {
let arrow = if i == 0 { " *" } else { " " };
println!("{} {} ({})", arrow, scope.name, scope.level);
}
}
// Show override store status
println!();
let store_dir = override_store_dir();
match OverrideStore::new(&store_dir) {
Ok(store) => {
let active = store.active_count();
let expired = store.expired_count();
println!("Override Store:");
println!(" Location: {}", store.path().display());
println!(" Active: {}", active);
println!(" Expired: {}", expired);
}
Err(e) => {
println!("Override Store: Error - {}", e);
}
}
println!();
println!("Configure scopes in aphoria.toml:");
println!();
println!(" [scope]");
println!(" project = \"my-project\"");
println!(" team = \"my-team\"");
println!(" organization = \"my-org\"");
ExitCode::SUCCESS
}
fn handle_scope_override(
config: &AphoriaConfig,
concept_path: String,
value: String,
reason: String,
evidence: Option<String>,
expires: Option<String>,
) -> ExitCode {
// Get current scope
let ctx = config.scope.to_context();
let current_scope = match ctx.current_scope() {
Some(s) => s,
None => {
eprintln!("No scope configured. Cannot create override.");
eprintln!();
eprintln!("Configure a scope in aphoria.toml first:");
eprintln!(" [scope]");
eprintln!(" project = \"my-project\"");
return ExitCode::from(1);
}
};
// Validate scope name
if let Err(e) = ScopeId::validate_name(&current_scope.name) {
eprintln!("Invalid scope name: {}", e);
return ExitCode::from(1);
}
// Parse expiry if provided
let expires_at = match expires.as_ref() {
Some(exp_str) => match parse_expiry(exp_str) {
Ok(dt) => Some(dt),
Err(e) => {
eprintln!("Invalid expiry format: {}", e);
eprintln!();
eprintln!("Valid formats:");
eprintln!(" Duration: 90d, 30d, 7d (days from now)");
eprintln!(" Date: 2026-12-31 (ISO 8601 date)");
return ExitCode::from(1);
}
},
None => None,
};
// Parse the value (infer type from string)
let parsed_value = OverrideValue::parse(&value);
// Extract predicate from concept_path (last segment after /)
let predicate = concept_path.rsplit('/').next().unwrap_or("value").to_string();
// Create override
let mut override_record =
ScopeOverride::new(current_scope.clone(), &concept_path, predicate, parsed_value, &reason);
if let Some(ref ev) = evidence {
override_record = override_record.with_evidence(ev);
}
if let Some(exp) = expires_at {
override_record = override_record.with_expires_at(exp);
}
// Persist to store
let store_dir = override_store_dir();
let mut store = match OverrideStore::new(&store_dir) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open override store: {}", e);
return ExitCode::from(3);
}
};
if let Err(e) = store.add(override_record) {
eprintln!("Failed to save override: {}", e);
return ExitCode::from(3);
}
println!("Override created successfully.");
println!();
println!(" Scope: {}", current_scope);
println!(" Concept: {}", concept_path);
println!(" Value: {}", value);
println!(" Reason: {}", reason);
if let Some(ref ev) = evidence {
println!(" Evidence: {}", ev);
}
if let Some(ref exp) = expires {
println!(" Expires: {}", exp);
}
println!();
println!("Stored in: {}", store.path().display());
ExitCode::SUCCESS
}
fn handle_scope_list(
config: &AphoriaConfig,
include_inherited: bool,
show_expired: bool,
) -> ExitCode {
let ctx = config.scope.to_context();
let current_scope = ctx.current_scope();
let chain = ctx.inheritance_chain();
println!("Scope Overrides");
println!("===============");
println!();
if let Some(ref scope) = current_scope {
println!("Current scope: {}", scope);
} else {
println!("Current scope: (none configured)");
}
if include_inherited {
println!("Showing: local + inherited overrides");
} else {
println!("Showing: local overrides only");
}
if show_expired {
println!("Including: expired overrides");
}
println!();
// Open store
let store_dir = override_store_dir();
let store = match OverrideStore::new(&store_dir) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open override store: {}", e);
return ExitCode::from(3);
}
};
// Get overrides
let overrides = if include_inherited {
store.list_with_inheritance(&chain, show_expired)
} else {
store.list(current_scope.as_ref(), show_expired)
};
if overrides.is_empty() {
println!("No overrides found.");
println!();
println!("Create an override with:");
println!(" aphoria scope override <concept_path> -V <value> -r <reason>");
return ExitCode::SUCCESS;
}
// Print table header
println!("{:<30} {:<12} {:<20} Reason", "Concept", "Scope", "Value");
println!("{}", "-".repeat(80));
for o in &overrides {
let status = if o.is_expired() { " (expired)" } else { "" };
let scope_short = format!("{}:{}", o.scope.level, truncate(&o.scope.name, 8));
println!(
"{:<30} {:<12} {:<20} {}{}",
truncate(&o.concept_path, 30),
scope_short,
truncate(&o.value.to_string(), 20),
truncate(&o.reason, 20),
status
);
if let Some(ref ev) = o.evidence {
println!(" Evidence: {}", ev);
}
if let Some(days) = o.days_until_expiration() {
if days > 0 {
println!(" Expires in: {} days", days);
}
}
}
println!();
println!("Total: {} override(s)", overrides.len());
ExitCode::SUCCESS
}
fn handle_scope_remove(config: &AphoriaConfig, concept_path: String, force: bool) -> ExitCode {
let ctx = config.scope.to_context();
let current_scope = match ctx.current_scope() {
Some(s) => s,
None => {
eprintln!("No scope configured.");
return ExitCode::from(1);
}
};
if !force {
println!("Would remove override for '{}' at scope '{}'", concept_path, current_scope);
println!();
println!("Use --force to confirm removal.");
return ExitCode::SUCCESS;
}
// Open store
let store_dir = override_store_dir();
let mut store = match OverrideStore::new(&store_dir) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open override store: {}", e);
return ExitCode::from(3);
}
};
match store.remove(&current_scope, &concept_path) {
Ok(true) => {
println!("Removed override for '{}' at scope '{}'", concept_path, current_scope);
}
Ok(false) => {
println!("No override found for '{}' at scope '{}'", concept_path, current_scope);
}
Err(e) => {
eprintln!("Failed to remove override: {}", e);
return ExitCode::from(3);
}
}
ExitCode::SUCCESS
}
/// Parse an expiry string into a DateTime.
///
/// Supports:
/// - Duration format: "90d", "30d", "7d" (days from now)
/// - ISO date format: "2026-12-31"
fn parse_expiry(s: &str) -> Result<DateTime<Utc>, String> {
let s = s.trim();
// Try duration format (e.g., "90d")
if let Some(days_str) = s.strip_suffix('d') {
let days: i64 =
days_str.parse().map_err(|_| format!("Invalid day count: '{}'", days_str))?;
if days <= 0 {
return Err("Days must be positive".to_string());
}
return Ok(Utc::now() + Duration::days(days));
}
// Try ISO date format (e.g., "2026-12-31")
if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let datetime = date.and_hms_opt(23, 59, 59).ok_or_else(|| "Invalid date".to_string())?;
return Ok(DateTime::from_naive_utc_and_offset(datetime, Utc));
}
Err(format!("Could not parse '{}'. Use '90d' for duration or '2026-12-31' for date.", s))
}
/// Truncate a string for display.
fn truncate(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len.saturating_sub(3)])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_expiry_duration() {
let result = parse_expiry("90d").expect("parse 90d");
let days_from_now = (result - Utc::now()).num_days();
assert!((89..=90).contains(&days_from_now));
let result = parse_expiry("7d").expect("parse 7d");
let days_from_now = (result - Utc::now()).num_days();
assert!((6..=7).contains(&days_from_now));
}
#[test]
fn test_parse_expiry_date() {
let result = parse_expiry("2030-12-31").expect("parse date");
assert_eq!(result.date_naive().to_string(), "2030-12-31");
}
#[test]
fn test_parse_expiry_invalid() {
assert!(parse_expiry("").is_err());
assert!(parse_expiry("invalid").is_err());
assert!(parse_expiry("0d").is_err());
assert!(parse_expiry("-5d").is_err());
}
}