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

695 lines
21 KiB
Rust

//! Lifecycle command handlers for knowledge deprecation and migration tracking.
use std::process::ExitCode;
use chrono::{NaiveDate, Utc};
use uuid::Uuid;
use aphoria::{
learning_store_dir, AphoriaConfig, DeprecatedUsage, KnowledgeStatus, LifecycleStore,
LocalPatternStore, MigrationProgress, MigrationStore, PatternStore, StatusTransition,
};
use crate::cli::{LifecycleCommands, MigrationCommands};
/// Handle lifecycle subcommands.
pub async fn handle_lifecycle_command(
command: LifecycleCommands,
config: &AphoriaConfig,
) -> ExitCode {
match command {
LifecycleCommands::Deprecate {
pattern_id,
reason,
superseded_by,
sunset_date,
migration_guide,
} => {
handle_deprecate(
&pattern_id,
&reason,
superseded_by.as_deref(),
sunset_date.as_deref(),
migration_guide,
config,
)
.await
}
LifecycleCommands::Archive { pattern_id, reason } => {
handle_archive(&pattern_id, &reason, config).await
}
LifecycleCommands::Reactivate { pattern_id, reason } => {
handle_reactivate(&pattern_id, &reason, config).await
}
LifecycleCommands::History { pattern_id, format } => {
handle_history(&pattern_id, &format, config).await
}
LifecycleCommands::List { status, overdue, format } => {
handle_list(status.as_deref(), overdue, &format, config).await
}
}
}
/// Handle migrations subcommands.
pub async fn handle_migrations_command(
command: MigrationCommands,
config: &AphoriaConfig,
) -> ExitCode {
match command {
MigrationCommands::Status { pattern, scope, format } => {
handle_migration_status(pattern.as_deref(), scope.as_deref(), &format, config).await
}
MigrationCommands::Export { output, format, include_resolved } => {
handle_migration_export(&output, &format, include_resolved, config).await
}
MigrationCommands::Blockers { pattern_id, scope } => {
handle_migration_blockers(&pattern_id, scope.as_deref(), config).await
}
}
}
/// Deprecate a pattern.
async fn handle_deprecate(
pattern_id: &str,
reason: &str,
superseded_by: Option<&str>,
sunset_date: Option<&str>,
migration_guide: Option<String>,
_config: &AphoriaConfig,
) -> ExitCode {
// Parse pattern ID
let id = match Uuid::parse_str(pattern_id) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid pattern ID '{}': {}", pattern_id, e);
return ExitCode::from(1);
}
};
// Parse superseded_by if provided
let superseded_by_id = if let Some(s) = superseded_by {
match Uuid::parse_str(s) {
Ok(id) => Some(id),
Err(e) => {
eprintln!("Invalid superseded-by ID '{}': {}", s, e);
return ExitCode::from(1);
}
}
} else {
None
};
// Parse sunset date if provided
let sunset_datetime = if let Some(date_str) = sunset_date {
match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
Ok(date) => {
let datetime = date.and_hms_opt(23, 59, 59);
datetime.map(|dt| chrono::TimeZone::from_utc_datetime(&Utc, &dt))
}
Err(e) => {
eprintln!("Invalid sunset date '{}': {}. Use YYYY-MM-DD format.", date_str, e);
return ExitCode::from(1);
}
}
} else {
None
};
// Load pattern store to verify pattern exists
let pattern_store = match LocalPatternStore::new(&learning_store_dir()) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open pattern store: {}", e);
return ExitCode::from(1);
}
};
let pattern = match pattern_store.get_pattern_by_id(&id) {
Some(p) => p,
None => {
eprintln!("Pattern '{}' not found", pattern_id);
return ExitCode::from(1);
}
};
// Create lifecycle store
let lifecycle_store = match LifecycleStore::open_default() {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open lifecycle store: {}", e);
return ExitCode::from(1);
}
};
// Get current status
let current_status = lifecycle_store.get_current_status(&id).unwrap_or(KnowledgeStatus::Active);
// Create new deprecated status
let new_status = KnowledgeStatus::Deprecated {
reason: reason.to_string(),
superseded_by: superseded_by_id,
sunset_date: sunset_datetime,
migration_guide,
};
// Record transition
let transition = StatusTransition::new(
id,
current_status,
new_status.clone(),
whoami::username(),
Some(format!("Deprecated: {}", reason)),
);
if let Err(e) = lifecycle_store.record_transition(transition) {
eprintln!("Failed to record transition: {}", e);
return ExitCode::from(1);
}
// Display result
println!("Pattern deprecated successfully");
println!();
println!(" Pattern ID: {}", id);
println!(" Pattern Name: {}", pattern.claim_template.predicate);
println!(" Reason: {}", reason);
if let Some(s) = superseded_by_id {
println!(" Superseded By: {}", s);
}
if let Some(date) = sunset_datetime {
println!(" Sunset Date: {}", date.format("%Y-%m-%d"));
}
println!();
println!("Scans will now FLAG this pattern with migration guidance.");
ExitCode::SUCCESS
}
/// Archive a pattern.
async fn handle_archive(pattern_id: &str, reason: &str, _config: &AphoriaConfig) -> ExitCode {
let id = match Uuid::parse_str(pattern_id) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid pattern ID '{}': {}", pattern_id, e);
return ExitCode::from(1);
}
};
// Load pattern store to verify pattern exists
let pattern_store = match LocalPatternStore::new(&learning_store_dir()) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open pattern store: {}", e);
return ExitCode::from(1);
}
};
if pattern_store.get_pattern_by_id(&id).is_none() {
eprintln!("Pattern '{}' not found", pattern_id);
return ExitCode::from(1);
}
// Create lifecycle store
let lifecycle_store = match LifecycleStore::open_default() {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open lifecycle store: {}", e);
return ExitCode::from(1);
}
};
let current_status = lifecycle_store.get_current_status(&id).unwrap_or(KnowledgeStatus::Active);
let new_status =
KnowledgeStatus::Archived { archived_at: Utc::now(), reason: reason.to_string() };
let transition = StatusTransition::new(
id,
current_status,
new_status,
whoami::username(),
Some(format!("Archived: {}", reason)),
);
if let Err(e) = lifecycle_store.record_transition(transition) {
eprintln!("Failed to record transition: {}", e);
return ExitCode::from(1);
}
println!("Pattern archived successfully");
println!();
println!(" Pattern ID: {}", id);
println!(" Reason: {}", reason);
println!();
println!("Pattern will no longer match during scans.");
ExitCode::SUCCESS
}
/// Reactivate a deprecated pattern.
async fn handle_reactivate(pattern_id: &str, reason: &str, _config: &AphoriaConfig) -> ExitCode {
let id = match Uuid::parse_str(pattern_id) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid pattern ID '{}': {}", pattern_id, e);
return ExitCode::from(1);
}
};
// Load pattern store
let pattern_store = match LocalPatternStore::new(&learning_store_dir()) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open pattern store: {}", e);
return ExitCode::from(1);
}
};
if pattern_store.get_pattern_by_id(&id).is_none() {
eprintln!("Pattern '{}' not found", pattern_id);
return ExitCode::from(1);
}
// Create lifecycle store
let lifecycle_store = match LifecycleStore::open_default() {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open lifecycle store: {}", e);
return ExitCode::from(1);
}
};
let current_status = match lifecycle_store.get_current_status(&id) {
Some(s) => s,
None => {
eprintln!("Pattern has no lifecycle history (already active)");
return ExitCode::from(1);
}
};
if !current_status.is_deprecated() {
eprintln!("Pattern is not deprecated (status: {})", current_status.status_name());
return ExitCode::from(1);
}
let transition = StatusTransition::new(
id,
current_status,
KnowledgeStatus::Active,
whoami::username(),
Some(format!("Reactivated: {}", reason)),
);
if let Err(e) = lifecycle_store.record_transition(transition) {
eprintln!("Failed to record transition: {}", e);
return ExitCode::from(1);
}
println!("Pattern reactivated successfully");
println!();
println!(" Pattern ID: {}", id);
println!(" Reason: {}", reason);
println!();
println!("Pattern is now active and will match without deprecation warnings.");
ExitCode::SUCCESS
}
/// Show lifecycle history for a pattern.
async fn handle_history(pattern_id: &str, format: &str, _config: &AphoriaConfig) -> ExitCode {
let id = match Uuid::parse_str(pattern_id) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid pattern ID '{}': {}", pattern_id, e);
return ExitCode::from(1);
}
};
let lifecycle_store = match LifecycleStore::open_default() {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open lifecycle store: {}", e);
return ExitCode::from(1);
}
};
let history = lifecycle_store.get_history(&id);
if history.is_empty() {
println!("No lifecycle history for pattern {}", pattern_id);
println!();
println!("Pattern is in Active status (default).");
return ExitCode::SUCCESS;
}
match format {
"json" => {
let json = serde_json::to_string_pretty(&history).unwrap_or_else(|_| "[]".to_string());
println!("{}", json);
}
_ => {
println!("Lifecycle History for {}", pattern_id);
println!("{}", "=".repeat(60));
println!();
for transition in &history {
let arrow = "";
println!(
"{} {} {}{}",
transition.timestamp.format("%Y-%m-%d %H:%M"),
arrow,
transition.from_status.status_name(),
transition.to_status.status_name()
);
println!(" By: {}", transition.initiated_by);
if let Some(ref comment) = transition.comment {
println!(" Comment: {}", comment);
}
println!();
}
}
}
ExitCode::SUCCESS
}
/// List patterns by lifecycle status.
async fn handle_list(
status_filter: Option<&str>,
overdue: bool,
format: &str,
_config: &AphoriaConfig,
) -> ExitCode {
let lifecycle_store = match LifecycleStore::open_default() {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open lifecycle store: {}", e);
return ExitCode::from(1);
}
};
let pattern_store = match LocalPatternStore::new(&learning_store_dir()) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open pattern store: {}", e);
return ExitCode::from(1);
}
};
// Collect patterns with their status
let mut results: Vec<(Uuid, String, KnowledgeStatus)> = Vec::new();
// Get all patterns and their statuses
for pattern in pattern_store.get_all_patterns() {
let status =
lifecycle_store.get_current_status(&pattern.id).unwrap_or(KnowledgeStatus::Active);
// Apply filters
if let Some(filter) = status_filter {
if status.status_name() != filter {
continue;
}
}
if overdue && !status.is_past_sunset() {
continue;
}
results.push((pattern.id, pattern.claim_template.predicate.clone(), status));
}
if results.is_empty() {
println!("No patterns found matching criteria.");
return ExitCode::SUCCESS;
}
match format {
"json" => {
let json_data: Vec<serde_json::Value> = results
.iter()
.map(|(id, name, status)| {
serde_json::json!({
"id": id.to_string(),
"name": name,
"status": status.status_name(),
"days_until_sunset": status.days_until_sunset(),
})
})
.collect();
let json =
serde_json::to_string_pretty(&json_data).unwrap_or_else(|_| "[]".to_string());
println!("{}", json);
}
_ => {
println!("Patterns by Lifecycle Status");
println!("{}", "=".repeat(60));
println!();
for (id, name, status) in &results {
let sunset_info = status
.days_until_sunset()
.map(|d| {
if d < 0 {
format!(" (OVERDUE by {} days)", -d)
} else {
format!(" ({} days until sunset)", d)
}
})
.unwrap_or_default();
println!("{:<40} {:<12} {}", name, status.status_name(), sunset_info);
println!(" {}", id);
}
println!();
println!("Total: {} patterns", results.len());
}
}
ExitCode::SUCCESS
}
/// Show migration status for deprecated patterns.
async fn handle_migration_status(
pattern_filter: Option<&str>,
_scope_filter: Option<&str>,
format: &str,
_config: &AphoriaConfig,
) -> ExitCode {
let migration_store = match MigrationStore::open_default() {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open migration store: {}", e);
return ExitCode::from(1);
}
};
let lifecycle_store = match LifecycleStore::open_default() {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open lifecycle store: {}", e);
return ExitCode::from(1);
}
};
let pattern_store = match LocalPatternStore::new(&learning_store_dir()) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open pattern store: {}", e);
return ExitCode::from(1);
}
};
// Get deprecated patterns
let deprecated = lifecycle_store.get_deprecated_patterns();
if deprecated.is_empty() {
println!("No deprecated patterns found.");
return ExitCode::SUCCESS;
}
let mut progress_list: Vec<MigrationProgress> = Vec::new();
for (pattern_id, _status) in &deprecated {
// Apply pattern filter if specified
if let Some(filter) = pattern_filter {
if let Ok(filter_id) = Uuid::parse_str(filter) {
if pattern_id != &filter_id {
continue;
}
}
}
// Get pattern name
let pattern_name = pattern_store
.get_pattern_by_id(pattern_id)
.map(|p| p.claim_template.predicate.clone())
.unwrap_or_else(|| "Unknown".to_string());
let progress = migration_store.get_progress(pattern_id, &pattern_name);
progress_list.push(progress);
}
if progress_list.is_empty() {
println!("No migration data found.");
return ExitCode::SUCCESS;
}
match format {
"json" => {
let json =
serde_json::to_string_pretty(&progress_list).unwrap_or_else(|_| "[]".to_string());
println!("{}", json);
}
_ => {
println!("Migration Status");
println!("{}", "=".repeat(70));
println!();
println!("{:<30} {:>8} {:>8} {:>10}", "Pattern", "Total", "Resolved", "Progress");
println!("{}", "-".repeat(70));
for progress in &progress_list {
println!(
"{:<30} {:>8} {:>8} {:>9.1}%",
truncate(&progress.pattern_name, 30),
progress.total_usages,
progress.resolved_usages,
progress.completion_percent()
);
}
println!();
let total_usages: usize = progress_list.iter().map(|p| p.total_usages).sum();
let total_resolved: usize = progress_list.iter().map(|p| p.resolved_usages).sum();
let overall_percent = if total_usages > 0 {
(total_resolved as f32 / total_usages as f32) * 100.0
} else {
100.0
};
println!(
"Overall: {} of {} usages resolved ({:.1}%)",
total_resolved, total_usages, overall_percent
);
}
}
ExitCode::SUCCESS
}
/// Export migration data.
async fn handle_migration_export(
output: &std::path::Path,
format: &str,
include_resolved: bool,
_config: &AphoriaConfig,
) -> ExitCode {
let migration_store = match MigrationStore::open_default() {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open migration store: {}", e);
return ExitCode::from(1);
}
};
let content = match format {
"csv" => migration_store.export_csv(include_resolved),
"json" => {
// For JSON, we need to manually build the data
let lifecycle_store = match LifecycleStore::open_default() {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open lifecycle store: {}", e);
return ExitCode::from(1);
}
};
let deprecated = lifecycle_store.get_deprecated_patterns();
let mut all_usages: Vec<DeprecatedUsage> = Vec::new();
for (pattern_id, _) in deprecated {
let usages = if include_resolved {
migration_store.get_usages(&pattern_id)
} else {
migration_store.get_pending_usages(&pattern_id)
};
all_usages.extend(usages);
}
serde_json::to_string_pretty(&all_usages).unwrap_or_else(|_| "[]".to_string())
}
_ => {
eprintln!("Unknown format '{}'. Use 'csv' or 'json'.", format);
return ExitCode::from(1);
}
};
if let Err(e) = std::fs::write(output, content) {
eprintln!("Failed to write to {}: {}", output.display(), e);
return ExitCode::from(1);
}
println!("Exported migration data to {}", output.display());
ExitCode::SUCCESS
}
/// Show migration blockers for a pattern.
async fn handle_migration_blockers(
pattern_id: &str,
_scope_filter: Option<&str>,
_config: &AphoriaConfig,
) -> ExitCode {
let id = match Uuid::parse_str(pattern_id) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid pattern ID '{}': {}", pattern_id, e);
return ExitCode::from(1);
}
};
let migration_store = match MigrationStore::open_default() {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open migration store: {}", e);
return ExitCode::from(1);
}
};
let pending = migration_store.get_pending_usages(&id);
if pending.is_empty() {
println!("No pending usages found for pattern {}", pattern_id);
println!();
println!("Migration is complete.");
return ExitCode::SUCCESS;
}
println!("Migration Blockers for {}", pattern_id);
println!("{}", "=".repeat(70));
println!();
for usage in &pending {
println!("{}:{}", usage.file_path, usage.line);
println!(" Project: {}", &usage.project_hash[..8.min(usage.project_hash.len())]);
println!(" First seen: {}", usage.first_detected.format("%Y-%m-%d"));
println!(" Last seen: {}", usage.last_detected.format("%Y-%m-%d"));
println!();
}
println!("Total blockers: {}", pending.len());
ExitCode::SUCCESS
}
/// Truncate a string to a maximum length.
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}...", &s[..max - 3])
}
}