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

697 lines
22 KiB
Rust

//! Governance command handlers for approval workflows and audit trails.
use std::path::Path;
use std::process::ExitCode;
use chrono::{NaiveDate, TimeZone, Utc};
use uuid::Uuid;
use aphoria::{
learning_store_dir, AphoriaConfig, ApprovalRequest, ApprovalStatus, AuditTrail, ExportFormat,
GovernanceStateMachine, GovernanceStore, LocalPatternStore, PatternStore,
};
use crate::cli::{AuditCommands, GovernanceCommands};
/// Handle governance subcommands.
pub async fn handle_governance_command(
command: GovernanceCommands,
config: &AphoriaConfig,
) -> ExitCode {
if !config.governance.enabled && !matches!(command, GovernanceCommands::Status { .. }) {
eprintln!("Governance is not enabled. Add [governance] enabled = true to aphoria.toml");
return ExitCode::from(1);
}
match command {
GovernanceCommands::Pending { workflow, format } => {
handle_pending(workflow.as_deref(), &format, config).await
}
GovernanceCommands::Approve { id, comment } => handle_approve(&id, comment, config).await,
GovernanceCommands::Reject { id, reason } => handle_reject(&id, &reason, config).await,
GovernanceCommands::Escalate { id } => handle_escalate(&id, config).await,
GovernanceCommands::Status { pattern, all, format } => {
handle_status(pattern.as_deref(), all, &format, config).await
}
GovernanceCommands::CheckTimeouts => handle_check_timeouts(config).await,
GovernanceCommands::Create { pattern_id, workflow } => {
handle_create(&pattern_id, workflow.as_deref(), config).await
}
}
}
/// Handle audit subcommands.
pub async fn handle_audit_command(command: AuditCommands, config: &AphoriaConfig) -> ExitCode {
match command {
AuditCommands::Trail { pattern, format } => handle_trail(&pattern, &format, config).await,
AuditCommands::Export { output, format, date_range } => {
handle_export(&output, &format, date_range.as_deref(), config).await
}
AuditCommands::Summary { format } => handle_summary(&format, config).await,
}
}
/// List pending approval requests.
async fn handle_pending(
workflow_filter: Option<&str>,
format: &str,
config: &AphoriaConfig,
) -> ExitCode {
let sm = match GovernanceStateMachine::open_default(config.governance.clone()) {
Ok(sm) => sm,
Err(e) => {
eprintln!("Failed to open governance store: {}", e);
return ExitCode::from(1);
}
};
// Check timeouts if configured
if config.governance.check_timeouts_on_access {
let _ = sm.check_timeouts();
}
let pending = match sm.list_pending() {
Ok(p) => p,
Err(e) => {
eprintln!("Failed to list pending requests: {}", e);
return ExitCode::from(1);
}
};
// Filter by workflow if specified
let filtered: Vec<_> = if let Some(wf) = workflow_filter {
pending.into_iter().filter(|r| r.workflow_name == wf).collect()
} else {
pending
};
if filtered.is_empty() {
println!("No pending approval requests.");
return ExitCode::SUCCESS;
}
match format {
"json" => {
let json = serde_json::to_string_pretty(&filtered).unwrap_or_else(|_| "[]".to_string());
println!("{}", json);
}
_ => {
println!("Pending Approval Requests");
println!("{}", "=".repeat(80));
println!();
println!("{:<36} {:<20} {:<15} {:<10}", "Request ID", "Pattern", "Stage", "Days");
println!("{}", "-".repeat(80));
for request in &filtered {
let stage = request.status.current_stage().unwrap_or("-");
let days = (Utc::now() - request.created_at).num_days();
let pattern_name = truncate(&request.pattern_name, 20);
println!("{:<36} {:<20} {:<15} {:<10}", request.id, pattern_name, stage, days);
}
println!();
println!("Total: {} pending requests", filtered.len());
}
}
ExitCode::SUCCESS
}
/// Approve a pending request.
async fn handle_approve(id: &str, comment: Option<String>, config: &AphoriaConfig) -> ExitCode {
let request_id = match Uuid::parse_str(id) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid request ID '{}': {}", id, e);
return ExitCode::from(1);
}
};
let sm = match GovernanceStateMachine::open_default(config.governance.clone()) {
Ok(sm) => sm,
Err(e) => {
eprintln!("Failed to open governance store: {}", e);
return ExitCode::from(1);
}
};
let approver = whoami::username();
match sm.approve(request_id, &approver, comment) {
Ok(request) => {
println!("Approved successfully");
println!();
println!(" Request ID: {}", request.id);
println!(" Pattern: {}", request.pattern_name);
println!(" Status: {}", request.status);
if request.status.is_approved() {
println!();
println!("Workflow complete. Pattern can now be promoted.");
}
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Failed to approve: {}", e);
ExitCode::from(1)
}
}
}
/// Reject a pending request.
async fn handle_reject(id: &str, reason: &str, config: &AphoriaConfig) -> ExitCode {
let request_id = match Uuid::parse_str(id) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid request ID '{}': {}", id, e);
return ExitCode::from(1);
}
};
let sm = match GovernanceStateMachine::open_default(config.governance.clone()) {
Ok(sm) => sm,
Err(e) => {
eprintln!("Failed to open governance store: {}", e);
return ExitCode::from(1);
}
};
let approver = whoami::username();
match sm.reject(request_id, &approver, reason.to_string()) {
Ok(request) => {
println!("Request rejected");
println!();
println!(" Request ID: {}", request.id);
println!(" Pattern: {}", request.pattern_name);
println!(" Reason: {}", reason);
println!();
println!("Pattern promotion blocked. Create a new request to try again.");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Failed to reject: {}", e);
ExitCode::from(1)
}
}
}
/// Escalate a pending request.
async fn handle_escalate(id: &str, config: &AphoriaConfig) -> ExitCode {
let request_id = match Uuid::parse_str(id) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid request ID '{}': {}", id, e);
return ExitCode::from(1);
}
};
let sm = match GovernanceStateMachine::open_default(config.governance.clone()) {
Ok(sm) => sm,
Err(e) => {
eprintln!("Failed to open governance store: {}", e);
return ExitCode::from(1);
}
};
let escalator = whoami::username();
match sm.escalate(request_id, &escalator) {
Ok(request) => {
println!("Request escalated");
println!();
println!(" Request ID: {}", request.id);
println!(" Pattern: {}", request.pattern_name);
println!(" Status: {}", request.status);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Failed to escalate: {}", e);
ExitCode::from(1)
}
}
}
/// Show request status.
async fn handle_status(
pattern_filter: Option<&str>,
show_all: bool,
format: &str,
_config: &AphoriaConfig,
) -> ExitCode {
let store = match GovernanceStore::open_default() {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open governance store: {}", e);
return ExitCode::from(1);
}
};
let requests = if let Some(pattern_str) = pattern_filter {
match Uuid::parse_str(pattern_str) {
Ok(pattern_id) => match store.get_request_by_pattern(&pattern_id) {
Ok(Some(r)) => vec![r],
Ok(None) => {
println!("No approval request found for pattern {}", pattern_str);
return ExitCode::SUCCESS;
}
Err(e) => {
eprintln!("Failed to get request: {}", e);
return ExitCode::from(1);
}
},
Err(e) => {
eprintln!("Invalid pattern ID '{}': {}", pattern_str, e);
return ExitCode::from(1);
}
}
} else if show_all {
match store.list_all() {
Ok(r) => r,
Err(e) => {
eprintln!("Failed to list requests: {}", e);
return ExitCode::from(1);
}
}
} else {
match store.list_pending() {
Ok(r) => r,
Err(e) => {
eprintln!("Failed to list pending: {}", e);
return ExitCode::from(1);
}
}
};
if requests.is_empty() {
println!("No approval requests found.");
return ExitCode::SUCCESS;
}
match format {
"json" => {
let json = serde_json::to_string_pretty(&requests).unwrap_or_else(|_| "[]".to_string());
println!("{}", json);
}
_ => {
for request in &requests {
print_request_details(request);
println!();
}
}
}
ExitCode::SUCCESS
}
/// Print detailed request information.
fn print_request_details(request: &ApprovalRequest) {
println!("Request: {}", request.id);
println!("{}", "=".repeat(60));
println!(" Pattern: {} ({})", request.pattern_name, request.pattern_id);
println!(" Workflow: {}", request.workflow_name);
println!(" Status: {}", request.status);
println!(
" Created: {} by {}",
request.created_at.format("%Y-%m-%d %H:%M"),
request.created_by
);
println!(" Updated: {}", request.updated_at.format("%Y-%m-%d %H:%M"));
if let Some(deadline) = request.stage_deadline {
let remaining = deadline - Utc::now();
if remaining.num_seconds() > 0 {
println!(
" Deadline: {} ({} hours remaining)",
deadline.format("%Y-%m-%d %H:%M"),
remaining.num_hours()
);
} else {
println!(" Deadline: {} (OVERDUE)", deadline.format("%Y-%m-%d %H:%M"));
}
}
if let Some(ref evidence) = request.evidence_summary {
println!(" Evidence: {}", evidence);
}
if !request.decisions.is_empty() {
println!();
println!(" Decisions:");
for decision in &request.decisions {
let comment = decision.comment.as_deref().unwrap_or("-");
println!(
" {} {} by {} at {} ({})",
decision.timestamp.format("%Y-%m-%d %H:%M"),
decision.decision,
decision.approver,
decision.stage,
comment
);
}
}
}
/// Check and process timed-out requests.
async fn handle_check_timeouts(config: &AphoriaConfig) -> ExitCode {
let sm = match GovernanceStateMachine::open_default(config.governance.clone()) {
Ok(sm) => sm,
Err(e) => {
eprintln!("Failed to open governance store: {}", e);
return ExitCode::from(1);
}
};
match sm.check_timeouts() {
Ok(processed) => {
if processed.is_empty() {
println!("No timed-out requests found.");
} else {
println!("Processed {} timed-out requests:", processed.len());
println!();
for request in &processed {
println!(" {} - {} - {}", request.id, request.pattern_name, request.status);
}
}
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Failed to check timeouts: {}", e);
ExitCode::from(1)
}
}
}
/// Create an approval request for a pattern.
async fn handle_create(
pattern_id_str: &str,
workflow_name: Option<&str>,
config: &AphoriaConfig,
) -> ExitCode {
let pattern_id = match Uuid::parse_str(pattern_id_str) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid pattern ID '{}': {}", pattern_id_str, 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);
}
};
let pattern = match pattern_store.get_pattern_by_id(&pattern_id) {
Some(p) => p,
None => {
eprintln!("Pattern '{}' not found", pattern_id_str);
return ExitCode::from(1);
}
};
// Get workflow
let workflow = if let Some(name) = workflow_name {
match config.governance.get_workflow(name) {
Some(w) => w.clone(),
None => {
eprintln!("Workflow '{}' not found", name);
return ExitCode::from(1);
}
}
} else {
match config.governance.get_default_workflow() {
Some(w) => w.clone(),
None => {
eprintln!("No default workflow configured");
return ExitCode::from(1);
}
}
};
let sm = match GovernanceStateMachine::open_default(config.governance.clone()) {
Ok(sm) => sm,
Err(e) => {
eprintln!("Failed to open governance store: {}", e);
return ExitCode::from(1);
}
};
let creator = whoami::username();
match sm.create_request(&pattern, &workflow, &creator) {
Ok(request) => {
println!("Approval request created");
println!();
println!(" Request ID: {}", request.id);
println!(" Pattern: {}", request.pattern_name);
println!(" Workflow: {}", request.workflow_name);
println!(" Status: {}", request.status);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Failed to create request: {}", e);
ExitCode::from(1)
}
}
}
/// Show audit trail for a pattern.
async fn handle_trail(pattern_id_str: &str, format: &str, config: &AphoriaConfig) -> ExitCode {
let _ = config; // Unused but kept for API consistency
let pattern_id = match Uuid::parse_str(pattern_id_str) {
Ok(id) => id,
Err(e) => {
eprintln!("Invalid pattern ID '{}': {}", pattern_id_str, e);
return ExitCode::from(1);
}
};
let trail = match AuditTrail::open_default() {
Ok(t) => t,
Err(e) => {
eprintln!("Failed to open audit trail: {}", e);
return ExitCode::from(1);
}
};
let events = match trail.get_pattern_timeline(&pattern_id) {
Ok(e) => e,
Err(e) => {
eprintln!("Failed to get timeline: {}", e);
return ExitCode::from(1);
}
};
if events.is_empty() {
println!("No audit events for pattern {}", pattern_id_str);
return ExitCode::SUCCESS;
}
match format {
"json" => {
let json = serde_json::to_string_pretty(&events).unwrap_or_else(|_| "[]".to_string());
println!("{}", json);
}
_ => {
println!("Audit Trail for {}", pattern_id_str);
println!("{}", "=".repeat(70));
println!();
println!("{:<20} {:<20} {:<15} {:<15}", "Timestamp", "Event", "Actor", "Request");
println!("{}", "-".repeat(70));
for event in &events {
println!(
"{:<20} {:<20} {:<15} {:<15}",
event.timestamp.format("%Y-%m-%d %H:%M"),
event.event_type.to_string(),
truncate(&event.actor, 15),
&event.request_id.to_string()[..8],
);
}
println!();
println!("Total: {} events", events.len());
}
}
ExitCode::SUCCESS
}
/// Export audit data.
async fn handle_export(
output: &Path,
format_str: &str,
date_range: Option<&str>,
_config: &AphoriaConfig,
) -> ExitCode {
let format = match format_str.parse::<ExportFormat>() {
Ok(f) => f,
Err(e) => {
eprintln!("Invalid format: {}", e);
return ExitCode::from(1);
}
};
let trail = match AuditTrail::open_default() {
Ok(t) => t,
Err(e) => {
eprintln!("Failed to open audit trail: {}", e);
return ExitCode::from(1);
}
};
let result = if let Some(range) = date_range {
// Parse date range
let parts: Vec<&str> = range.split("..").collect();
if parts.len() != 2 {
eprintln!("Invalid date range format. Use: YYYY-MM-DD..YYYY-MM-DD");
return ExitCode::from(1);
}
let start = match NaiveDate::parse_from_str(parts[0], "%Y-%m-%d") {
Ok(d) => Utc.from_utc_datetime(&d.and_hms_opt(0, 0, 0).unwrap_or_default()),
Err(e) => {
eprintln!("Invalid start date: {}", e);
return ExitCode::from(1);
}
};
let end = match NaiveDate::parse_from_str(parts[1], "%Y-%m-%d") {
Ok(d) => Utc.from_utc_datetime(&d.and_hms_opt(23, 59, 59).unwrap_or_default()),
Err(e) => {
eprintln!("Invalid end date: {}", e);
return ExitCode::from(1);
}
};
trail.export_date_range(format, output, start, end)
} else {
trail.export(format, output)
};
match result {
Ok(()) => {
println!("Exported audit data to {}", output.display());
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Failed to export: {}", e);
ExitCode::from(1)
}
}
}
/// Show audit summary.
async fn handle_summary(format: &str, _config: &AphoriaConfig) -> ExitCode {
let store = match GovernanceStore::open_default() {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open governance store: {}", e);
return ExitCode::from(1);
}
};
let trail = match AuditTrail::open_default() {
Ok(t) => t,
Err(e) => {
eprintln!("Failed to open audit trail: {}", e);
return ExitCode::from(1);
}
};
let requests = match store.list_all() {
Ok(r) => r,
Err(e) => {
eprintln!("Failed to list requests: {}", e);
return ExitCode::from(1);
}
};
let events = match trail.get_all_events() {
Ok(e) => e,
Err(e) => {
eprintln!("Failed to get events: {}", e);
return ExitCode::from(1);
}
};
// Calculate statistics
let total_requests = requests.len();
let approved = requests.iter().filter(|r| r.status.is_approved()).count();
let rejected = requests.iter().filter(|r| r.status.is_rejected()).count();
let pending = requests.iter().filter(|r| r.status.is_pending()).count();
let expired = requests.iter().filter(|r| matches!(r.status, ApprovalStatus::Expired)).count();
let approval_rate =
if total_requests > 0 { (approved as f32 / total_requests as f32) * 100.0 } else { 0.0 };
// Calculate average approval time for completed requests
let avg_approval_days: Option<f32> = {
let completed: Vec<_> = requests.iter().filter(|r| r.status.is_approved()).collect();
if completed.is_empty() {
None
} else {
let total_days: i64 =
completed.iter().map(|r| (r.updated_at - r.created_at).num_days()).sum();
Some(total_days as f32 / completed.len() as f32)
}
};
match format {
"json" => {
let summary = serde_json::json!({
"total_requests": total_requests,
"approved": approved,
"rejected": rejected,
"pending": pending,
"expired": expired,
"approval_rate_percent": approval_rate,
"avg_approval_days": avg_approval_days,
"total_events": events.len(),
});
let json = serde_json::to_string_pretty(&summary).unwrap_or_else(|_| "{}".to_string());
println!("{}", json);
}
_ => {
println!("Governance Audit Summary");
println!("{}", "=".repeat(50));
println!();
println!("Requests:");
println!(" Total: {}", total_requests);
println!(" Approved: {} ({:.1}%)", approved, approval_rate);
println!(" Rejected: {}", rejected);
println!(" Pending: {}", pending);
println!(" Expired: {}", expired);
println!();
if let Some(days) = avg_approval_days {
println!("Average approval time: {:.1} days", days);
}
println!("Total audit events: {}", events.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.saturating_sub(3)])
}
}