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>
This commit is contained in:
jordan 2026-02-07 05:16:26 -07:00
parent bbeee18b68
commit 8af9b48ac7
73 changed files with 11613 additions and 747 deletions

View File

@ -75,6 +75,9 @@ uuid = { version = "1.11", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
once_cell = "1.20"
# System info
whoami = "1.5"
# Observation storage for LLM evaluation
rusqlite = { version = "0.32", features = ["bundled"] }

View File

@ -2673,7 +2673,7 @@ Benchmark Results:
---
## Phase 11: Evidence-Based Authority 🎯
## Phase 11: Evidence-Based Authority
> **Vision:** Authority comes from evidence, not titles. Merit over tenure.
@ -2690,58 +2690,75 @@ Benchmark Results:
| Research | ADR-042, docs/decision-log.md | 0.70 | 5 usages |
| Commit | Just code, no context | 0.40 | 10 usages |
### 11.1 Evidence Level Types
### 11.1 Evidence Level Types
| Task | Status |
|------|--------|
| Create `src/evidence/mod.rs` module | |
| Define `EvidenceLevel` enum (Commit, Research, Standard, ProductSpec) | |
| Implement `authority_weight()` method | |
| Add evidence level to `LearnedPattern` struct | |
| Update pattern display to show evidence level | |
| Create `src/evidence/mod.rs` module | |
| Define `EvidenceLevel` enum (Commit, Research, Standard, ProductSpec) | |
| Implement `authority_weight()` method | |
| Add evidence level to `LearnedPattern` struct | |
| Update pattern display to show evidence level | |
### 11.2 Evidence Source Detection
### 11.2 Evidence Source Detection
| Task | Status |
|------|--------|
| Create `EvidenceSource` enum | |
| Implement commit message parsing for RFC/standard references | |
| Implement ADR file detection (docs/adr/*.md patterns) | |
| Implement spec file detection (specs/*.md, *.spec.md) | |
| Add `PatternEvidence::detect()` auto-detection | |
| Create `EvidenceSource` enum | |
| Implement commit message parsing for RFC/standard references | |
| Implement ADR file detection (docs/adr/*.md patterns) | |
| Implement spec file detection (specs/*.md, *.spec.md) | |
| Add `PatternEvidence::detect()` auto-detection | |
### 11.3 Evidence-Aware Graduation
### 11.3 Evidence-Aware Graduation
| Task | Status |
|------|--------|
| Update `GraduationManager` thresholds based on evidence | |
| ProductSpec: 1 usage → promotion candidate | |
| Standard: 3 usages → promotion candidate | |
| Research: 5 usages → promotion candidate | |
| Commit-only: 10 usages → promotion candidate | |
| Add evidence boost to shadow mode evaluation | |
| Update `GraduationManager` thresholds based on evidence | |
| ProductSpec: 1 usage → promotion candidate | |
| Standard: 3 usages → promotion candidate | |
| Research: 5 usages → promotion candidate | |
| Commit-only: 10 usages → promotion candidate | |
| Add evidence boost to shadow mode evaluation | |
### 11.4 Evidence Display
### 11.4 Evidence Display
| Task | Status |
|------|--------|
| Update `aphoria patterns show` to display evidence chain | |
| Show evidence level badge in table/JSON output | |
| Show linked sources (ADR, spec, RFC) in conflict output | |
| Add `--evidence` flag to filter patterns by evidence level | |
| Update `aphoria patterns show` to display evidence chain | |
| Show evidence level badge in table/JSON output | |
| Show linked sources (ADR, spec, RFC) in conflict output | |
| Add `--evidence` flag to filter patterns by evidence level | |
### Phase 11 Completion Criteria
| Metric | Target |
|--------|--------|
| Evidence detection working for 4 source types | ✓ |
| Graduation thresholds vary by evidence level | ✓ |
| Pattern display shows evidence chain | ✓ |
| ProductSpec-backed patterns graduate with 1 usage | ✓ |
| Evidence detection working for 4 source types | ✅ |
| Graduation thresholds vary by evidence level | ✅ |
| Pattern display shows evidence chain | ✅ |
| ProductSpec-backed patterns graduate with 1 usage | ✅ |
### Implementation Notes
**Files Created:**
- `src/evidence/mod.rs` - Module exports with flow documentation
- `src/evidence/types.rs` - `EvidenceLevel`, `EvidenceSource`, `PatternEvidence` types
- `src/evidence/detection.rs` - `EvidenceDetector` with regex-based parsing
**Files Modified:**
- `src/learning/types.rs` - Added `evidence` field to `LearnedPattern`
- `src/learning/store.rs` - Added `get_all_patterns()`, `get_pattern_by_id()`
- `src/shadow/types.rs` - Added `evidence_level`, `evidence_sources` to `ShadowTest`
- `src/shadow/graduation.rs` - Added `effective_min_scans()`, `meets_evidence_aware_criteria()`
- `src/cli.rs` - Added `Show` variant to `PatternCommands`
- `src/handlers/patterns.rs` - Implemented `handle_pattern_show()`
**Tests:** 29 evidence tests + 15 graduation tests passing (817 total)
---
## Phase 12: Knowledge Scope Hierarchy ⬜
## Phase 12: Knowledge Scope Hierarchy
> **Vision:** Knowledge applies at the right level - org, team, or project.
@ -2766,57 +2783,67 @@ Project Level (applies to single project)
└── Context-specific decisions
```
### 12.1 Scope Level Types
### 12.1 Scope Level Types
| Task | Status |
|------|--------|
| Create `src/scope/mod.rs` module | |
| Define `ScopeLevel` enum (Organization, Team, Project) | |
| Add `scope_level` and `scope_id` to `LearnedPattern` | |
| Add `ScopeConfig` to `.aphoria.toml` | |
| Implement `--scope` flag for CLI commands | |
| Create `src/scope/mod.rs` module | |
| Define `ScopeLevel` enum (Organization, Team, Project) | |
| Add `scope_level` and `scope_id` to `LearnedPattern` | |
| Add `ScopeConfig` to `.aphoria.toml` | |
| Implement `--scope` flag for CLI commands | |
### 12.2 Scope Inheritance
### 12.2 Scope Inheritance
| Task | Status |
|------|--------|
| Implement inheritance resolution (project → team → org) | |
| Security policies: auto-apply, no opt-out | |
| Conventions: auto-apply, teams can override with justification | |
| Observations: never inherited, team-specific only | |
| Add `ScopedKnowledge` struct with `inherited_from` chain | |
| Implement inheritance resolution (project → team → org) | |
| Security policies: auto-apply, no opt-out | |
| Conventions: auto-apply, teams can override with justification | |
| Observations: never inherited, team-specific only | |
| Add `ScopedKnowledge` struct with `inherited_from` chain | |
### 12.3 Scope Override Workflow
### 12.3 Scope Override Workflow
| Task | Status |
|------|--------|
| Implement `aphoria scope override` command | |
| Require justification for overrides | |
| Require evidence link (spec, ADR, ticket) for overrides | |
| Store override audit trail | |
| Implement `aphoria scope override` command | |
| Require justification for overrides | |
| Require evidence link (spec, ADR, ticket) for overrides | |
| Store override audit trail | |
| Show overrides in SOC 2 reports | ⬜ |
### 12.4 Cross-Scope Queries
### 12.4 Cross-Scope Queries
| Task | Status |
|------|--------|
| `aphoria patterns --scope org` (org-level only) | |
| `aphoria patterns --scope team --exclude-inherited` | |
| `aphoria patterns --scope project --only-local` | |
| Show scope in pattern list output | |
| `aphoria patterns --scope org` (org-level only) | |
| `aphoria patterns --scope team --exclude-inherited` | |
| `aphoria patterns --scope project --only-local` | |
| Show scope in pattern list output | |
### Phase 12 Completion Criteria
| Metric | Target |
|--------|--------|
| 3 scope levels working (org/team/project) | ✓ |
| Inheritance resolution correct | ✓ |
| Overrides require justification + evidence | ✓ |
| Cross-scope queries functional | ✓ |
| 3 scope levels working (org/team/project) | ✅ |
| Inheritance resolution correct | ✅ |
| Overrides require justification + evidence | ✅ |
| Cross-scope queries functional | ✅ |
**Implementation Notes:**
- `src/scope/mod.rs` - ScopeLevel, ScopeId, ScopeContext with inheritance chain
- `src/scope/config.rs` - ScopeConfig for aphoria.toml
- `src/scope/resolver.rs` - ScopeResolver with Replace/Merge/NoInherit policies
- `src/scope/override_record.rs` - ScopeOverride with OverrideValue, expiration
- `src/scope/store.rs` - OverrideStore with persistence to ~/.aphoria/scope/
- `src/handlers/scope.rs` - CLI command handlers (status, override, list, remove)
**Tests:** 884 tests passing, all scope tests passing
---
## Phase 13: Knowledge Lifecycle Management ⬜
## Phase 13: Knowledge Lifecycle Management
> **Vision:** Knowledge ages. Patterns can be deprecated and superseded.
@ -2831,58 +2858,68 @@ Superseded → Pattern replaced by another, link to replacement
Archived → Pattern removed from active use, historical only
```
### 13.1 Knowledge Status Types
### 13.1 Knowledge Status Types
| Task | Status |
|------|--------|
| Create `src/lifecycle/mod.rs` module | |
| Define `KnowledgeStatus` enum | |
| Add `Deprecated` variant with reason, superseded_by, sunset_date | |
| Add `KnowledgeLifecycle` struct with status history | |
| Store lifecycle in pattern metadata | |
| Create `src/lifecycle/mod.rs` module | |
| Define `KnowledgeStatus` enum | |
| Add `Deprecated` variant with reason, superseded_by, sunset_date | |
| Add `KnowledgeLifecycle` struct with status history | |
| Store lifecycle in pattern metadata | |
### 13.2 Deprecation Command
### 13.2 Deprecation Command
| Task | Status |
|------|--------|
| Implement `aphoria deprecate <pattern-id>` command | |
| Require `--reason` flag | |
| Optional `--superseded-by <new-pattern>` | |
| Optional `--sunset-date <ISO-8601>` | |
| Implement `aphoria deprecate <pattern-id>` command | |
| Require `--reason` flag | |
| Optional `--superseded-by <new-pattern>` | |
| Optional `--sunset-date <ISO-8601>` | |
| Notify connected teams on deprecation | ⬜ |
### 13.3 Migration Guidance
### 13.3 Migration Guidance
| Task | Status |
|------|--------|
| Show deprecation warning in scan output | |
| Link to superseding pattern when available | |
| Show migration guide/ADR when linked | |
| FLAG (not BLOCK) deprecated pattern usage | |
| Track migration progress across projects | |
| Show deprecation warning in scan output | |
| Link to superseding pattern when available | |
| Show migration guide/ADR when linked | |
| FLAG (not BLOCK) deprecated pattern usage | |
| Track migration progress across projects | |
### 13.4 Migration Tracking Dashboard
### 13.4 Migration Tracking Dashboard
| Task | Status |
|------|--------|
| Implement `aphoria migrations status` command | |
| Show progress by team (X/Y endpoints migrated) | |
| Show days remaining until sunset | |
| Show blockers (acknowledged exceptions) | |
| Export migration status for reporting | |
| Implement `aphoria migrations status` command | |
| Show progress by team (X/Y endpoints migrated) | |
| Show days remaining until sunset | |
| Show blockers (acknowledged exceptions) | |
| Export migration status for reporting | |
### Phase 13 Completion Criteria
| Metric | Target |
|--------|--------|
| Deprecation command working | ✓ |
| Deprecated patterns show warning in scan | ✓ |
| Migration tracking across projects | ✓ |
| SOC 2 report includes migration status | ✓ |
| Deprecation command working | ✅ |
| Deprecated patterns show warning in scan | ✅ |
| Migration tracking across projects | ✅ |
| SOC 2 report includes migration status | ⬜ |
**Implementation Notes:**
- `src/lifecycle/mod.rs` - KnowledgeStatus, KnowledgeLifecycle, StatusTransition
- `src/lifecycle/store.rs` - LifecycleStore for persistence
- `src/lifecycle/migration.rs` - MigrationStore, MigrationProgress tracking
- `src/handlers/lifecycle.rs` - CLI handlers for deprecate, archive, reactivate, history, list
- `src/handlers/lifecycle.rs` - Migration handlers for status, export, blockers
- `KnowledgeLifecycle` added to `LearnedPattern` for pattern-level lifecycle tracking
**Tests:** 884 tests passing (35 lifecycle-specific tests)
---
## Phase 14: Governance Workflows ⬜
## Phase 14: Governance Workflows 🎯
> **Vision:** Clear approval paths for pattern promotion with audit trails.

View File

@ -48,6 +48,7 @@ pub async fn show_diff(config: &AphoriaConfig) -> Result<String, AphoriaError> {
debug: false,
sync: false, // Diff does not write observations
file_source: crate::types::FileSource::All,
benchmark: false,
};
let result = run_scan(args, config).await?;

View File

@ -1,554 +0,0 @@
//! CLI argument definitions for Aphoria
use std::path::PathBuf;
use clap::{Parser, Subcommand};
/// A code-level truth linter powered by Episteme.
///
/// Aphoria scans a codebase, extracts the decisions embedded in config and code,
/// and checks them against authoritative sources. It finds the places where what
/// your code *does* contradicts what the specs *say*.
#[derive(Parser)]
#[command(name = "aphoria")]
#[command(version, about, long_about = None)]
pub struct Cli {
/// Path to aphoria.toml configuration file
#[arg(short, long, global = true)]
pub config: Option<PathBuf>,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Scan a project for epistemic drift
Scan {
/// Path to the project root to scan
#[arg(default_value = ".")]
path: PathBuf,
/// Output format: table, json, sarif, markdown
#[arg(short, long, default_value = "table")]
format: String,
/// Exit with non-zero code if conflicts found
#[arg(long)]
exit_code: bool,
/// Use stricter thresholds (FLAG at 0.3, BLOCK at 0.5)
#[arg(long)]
strict: bool,
/// Persist claims to Episteme storage (enables diff/baseline features).
/// Without this flag, scans are ephemeral and fast.
#[arg(long)]
persist: bool,
/// Enable debug output showing conflict resolution traces.
/// Shows why each conflict was raised, including authority matching.
#[arg(long)]
debug: bool,
/// Enable write-back of observations to local Episteme (requires --persist).
/// Claims with no authority conflict become Tier 4 observations,
/// creating "project memory" for future drift detection.
#[arg(long)]
sync: bool,
/// Scan only git-staged files (for pre-commit hooks).
/// Fast: only scans files in `git diff --cached`.
#[arg(long)]
staged: bool,
/// Preview what would be shared with the community corpus.
/// Shows anonymized observations without sending any data.
/// Requires [community] enabled = true in aphoria.toml.
#[arg(long)]
community_preview: bool,
},
/// Acknowledge a conflict (mark as intentional)
Ack {
/// The concept path to acknowledge
concept_path: String,
/// Reason for acknowledgment
#[arg(short, long)]
reason: String,
/// Optional expiry for acknowledgment
///
/// Duration format: "90d" (days from now)
/// Date format: "2026-12-31" (ISO 8601)
///
/// When an acknowledgment expires, the conflict resurfaces as BLOCK/FLAG.
/// The expired acknowledgment is preserved for audit trail.
#[arg(long, alias = "expires-at")]
expires: Option<String>,
},
/// Bless a code pattern as the authoritative standard
///
/// Unlike `ack` (which suppresses conflicts), `bless` defines the pattern
/// as the correct standard. Blessed patterns can be exported as Trust Packs
/// and imported into other projects where they become authoritative sources.
Bless {
/// The concept path to bless (e.g., "code://rust/grpc/tls")
concept_path: String,
/// The predicate (e.g., "enabled", "min_version")
#[arg(short, long)]
predicate: String,
/// The value (e.g., "true", "1.2")
#[arg(short = 'V', long)]
value: String,
/// Reason/description for this standard
#[arg(short, long)]
reason: String,
},
/// Record an intentional configuration change as a policy update
///
/// Unlike `ack` (which marks a conflict as reviewed), `update` records
/// a new baseline value for a concept. Use this when you intentionally
/// change a configuration and want future scans to recognize this as
/// the expected value.
Update {
/// The concept path being updated (e.g., "db/pool_size")
concept_path: String,
/// The new value for this concept
value: String,
/// Reason for the update
#[arg(short, long)]
reason: String,
},
/// Set the current scan as the baseline
Baseline,
/// Show changes since last baseline
Diff,
/// Show current scan status
Status,
/// Initialize Aphoria with authoritative corpus
Init,
/// Manage the authoritative corpus
Corpus {
#[command(subcommand)]
command: CorpusCommands,
},
/// Manage the research agent for filling corpus gaps
Research {
#[command(subcommand)]
command: ResearchCommands,
},
/// Manage federated policies (Trust Packs)
Policy {
#[command(subcommand)]
command: PolicyCommands,
},
/// Manage learned patterns and extractor promotion
Extractors {
#[command(subcommand)]
command: ExtractorCommands,
},
/// Evaluate LLM prompt effectiveness
///
/// Run extraction against golden fixtures to measure precision/recall
/// and detect prompt regressions.
Eval {
#[command(subcommand)]
command: EvalCommands,
},
/// Manage cross-project pattern learning
///
/// Sync learned patterns with the hosted server and pull community
/// extractors that have been aggregated from many organizations.
Patterns {
#[command(subcommand)]
command: PatternCommands,
},
}
#[derive(Subcommand)]
pub enum EvalCommands {
/// Run evaluation against fixtures
Run {
/// Path to fixtures directory
#[arg(long, default_value = "tests/llm_fixtures")]
fixtures: PathBuf,
/// Categories to evaluate (comma-separated)
#[arg(long)]
categories: Option<String>,
/// Maximum fixtures to run (for smoke tests)
#[arg(long)]
max_fixtures: Option<usize>,
/// Evaluation mode: live, cached, mock
#[arg(long, default_value = "mock")]
mode: String,
/// Exit with code 1 if regression detected
#[arg(long)]
fail_on_regression: bool,
/// Regression threshold (default: 0.05 = 5%)
#[arg(long, default_value = "0.05")]
threshold: f64,
/// Save observation logs
#[arg(long)]
save_observations: bool,
/// Output format: table, json, markdown
#[arg(long, default_value = "table")]
format: String,
},
/// Show current baseline metrics
Baseline {
/// Path to fixtures directory
#[arg(long, default_value = "tests/llm_fixtures")]
fixtures: PathBuf,
},
/// Update baseline from latest run
///
/// This overwrites the baseline metrics in manifest.toml.
/// Requires --force to prevent accidental overwrites.
UpdateBaseline {
/// Path to fixtures directory
#[arg(long, default_value = "tests/llm_fixtures")]
fixtures: PathBuf,
/// Required - prevents accidental baseline overwrites
#[arg(long, required = true)]
force: bool,
},
/// List available fixtures
ListFixtures {
/// Path to fixtures directory
#[arg(long, default_value = "tests/llm_fixtures")]
fixtures: PathBuf,
/// Filter by category
#[arg(long)]
category: Option<String>,
},
/// Validate fixture format
ValidateFixtures {
/// Path to fixtures directory
#[arg(long, default_value = "tests/llm_fixtures")]
fixtures: PathBuf,
},
}
#[derive(Subcommand)]
pub enum CorpusCommands {
/// Build the authoritative corpus from configured sources
Build {
/// Only include specific sources (comma-separated: rfc,owasp,vendor,hardcoded)
#[arg(long)]
only: Option<String>,
/// Run in offline mode (skip sources requiring network)
#[arg(long)]
offline: bool,
/// Clear cache before building
#[arg(long)]
clear_cache: bool,
},
/// List available corpus sources
List,
}
#[derive(Subcommand)]
pub enum ResearchCommands {
/// Run the research agent to fill corpus gaps
Run {
/// Minimum projects that must report a gap before researching (default: 3)
#[arg(short, long, default_value = "3")]
threshold: u32,
/// Use strict quality validation
#[arg(long)]
strict: bool,
/// Prune old gaps before researching
#[arg(long)]
prune: bool,
/// Maximum age of gaps to consider in days (default: 90)
#[arg(long, default_value = "90")]
max_age: u64,
},
/// Show research agent status and gap statistics
Status,
/// List gaps eligible for research
Gaps {
/// Minimum projects that must report a gap (default: 1)
#[arg(short, long, default_value = "1")]
threshold: u32,
/// Show only gaps ready for research (seen in 3+ projects)
#[arg(long)]
ready: bool,
},
}
#[derive(Subcommand)]
pub enum PolicyCommands {
/// Export acknowledged conflicts and manual aliases as a Trust Pack
Export {
/// Name of the policy pack
#[arg(long)]
name: String,
/// Output path for the pack file
#[arg(short, long)]
output: PathBuf,
},
/// Import a Trust Pack into the local Episteme
Import {
/// Path to the .pack file
file: PathBuf,
},
/// Re-sign a Trust Pack with a new key
///
/// Used for key rotation when the original signing key has changed.
/// The old signature is preserved in the signature chain for audit trail.
Resign {
/// Path to the .pack file to re-sign
file: PathBuf,
/// Output path for the re-signed pack
#[arg(short, long)]
output: PathBuf,
/// Path to new signing key (defaults to .aphoria/agent.key)
#[arg(long)]
key: Option<PathBuf>,
/// Reason for re-signing (for audit trail)
#[arg(long)]
reason: Option<String>,
/// Preserve signature chain for audit trail (default: true)
#[arg(long, default_value = "true")]
chain_signatures: bool,
},
}
#[derive(Subcommand)]
pub enum PatternCommands {
/// Sync learned patterns to hosted server
///
/// Uploads patterns that meet local thresholds (min projects, min confidence)
/// to the hosted server for cross-project learning.
Sync {
/// Preview what would be synced without sending
#[arg(long)]
dry_run: bool,
},
/// Show pattern sync status
///
/// Displays local pattern store stats, eligible patterns, and sync status.
Status,
/// Pull community extractors from hosted server
///
/// Downloads extractors that have been aggregated from patterns across
/// many organizations and saves them as YAML files.
PullCommunity {
/// Minimum projects threshold for community extractors (default: 50)
#[arg(long, default_value = "50")]
min_projects: u64,
/// Preview without saving to disk
#[arg(long)]
dry_run: bool,
},
}
#[derive(Subcommand)]
pub enum ExtractorCommands {
/// List patterns eligible for promotion to declarative extractors
Candidates {
/// Show verbose output with pattern details
#[arg(short, long)]
verbose: bool,
},
/// Interactive review session for promotion candidates
Review {
/// Maximum number of candidates to review
#[arg(short, long)]
limit: Option<usize>,
/// Auto-approve ready candidates without prompting
#[arg(long)]
auto: bool,
},
/// Promote a specific pattern by ID
Promote {
/// Pattern ID to promote (UUID format)
pattern_id: String,
/// Force promotion even if validation has warnings
#[arg(long)]
force: bool,
},
/// Show learning/promotion statistics
Stats,
/// Run autonomous promotion for high-confidence patterns
///
/// Automatically promotes patterns that meet strict thresholds:
/// - Confidence >= 0.95 (configurable)
/// - Projects >= 10 (configurable)
/// - Zero validation failures
/// - Zero validation warnings
///
/// All decisions are logged to ~/.aphoria/audit/autonomous-decisions.jsonl
/// for compliance and review.
AutoPromote {
/// Preview what would be auto-promoted without making changes
#[arg(long)]
dry_run: bool,
/// Override minimum confidence threshold
#[arg(long)]
min_confidence: Option<f32>,
/// Override minimum project count threshold
#[arg(long)]
min_projects: Option<usize>,
},
/// Show shadow mode testing status
///
/// Displays all extractors in shadow mode with their metrics,
/// including scan counts, FP rates, and graduation eligibility.
ShadowStatus {
/// Show detailed output including match history
#[arg(short, long)]
verbose: bool,
},
/// Provide feedback on shadow matches
///
/// Interactive session to mark shadow matches as true positives
/// or false positives. Feedback is used to calculate FP rates
/// for graduation eligibility.
Feedback {
/// Shadow test name or ID to provide feedback for
test: String,
/// Maximum matches to show per session
#[arg(short, long, default_value = "10")]
limit: usize,
},
/// Graduate a shadow extractor to production
///
/// Moves the extractor from shadow mode to production if it
/// meets graduation criteria (min scans + max FP rate).
Graduate {
/// Shadow test name or ID to graduate
test: String,
/// Force graduation even if criteria not met
#[arg(long)]
force: bool,
},
/// Rollback a shadow extractor
///
/// Removes the extractor from shadow mode and deletes its YAML file.
/// Use when an extractor has too many false positives or other issues.
Rollback {
/// Shadow test name or ID to rollback
test: String,
/// Reason for rollback (for audit log)
#[arg(short, long)]
reason: String,
},
/// Check all shadow tests for auto-rollback and apply if needed
///
/// Scans all active shadow tests and automatically rolls back any
/// that exceed the FP rate threshold (default 15%). Use this for
/// scheduled maintenance or to catch tests that haven't received
/// feedback recently.
AutoCheck,
/// List version history for an extractor
///
/// Shows all versions of an extractor with their changelog entries,
/// dates, and metrics deltas where available.
Versions {
/// Extractor name (e.g., "learned_tls_min_version").
name: String,
},
/// Compare metrics between two versions of an extractor
///
/// Shows the difference in match rate and false positive rate
/// between two versions. Requires shadow mode metrics to be available.
Compare {
/// Extractor name.
name: String,
/// First version to compare.
#[arg(short = 'a', long)]
version_a: u32,
/// Second version to compare.
#[arg(short = 'b', long)]
version_b: u32,
},
/// Rollback to a previous version of an extractor
///
/// Restores a previous version of the extractor as the current version.
/// The current version is archived before being replaced. A new changelog
/// entry is created documenting the rollback.
RollbackVersion {
/// Extractor name.
name: String,
/// Version to rollback to.
#[arg(short, long)]
version: u32,
/// Reason for rollback (recorded in changelog).
#[arg(short, long)]
reason: String,
},
}

View File

@ -0,0 +1,163 @@
//! Extractor CLI command definitions.
use clap::Subcommand;
#[derive(Subcommand)]
pub enum ExtractorCommands {
/// List patterns eligible for promotion to declarative extractors
Candidates {
/// Show verbose output with pattern details
#[arg(short, long)]
verbose: bool,
},
/// Interactive review session for promotion candidates
Review {
/// Maximum number of candidates to review
#[arg(short, long)]
limit: Option<usize>,
/// Auto-approve ready candidates without prompting
#[arg(long)]
auto: bool,
},
/// Promote a specific pattern by ID
Promote {
/// Pattern ID to promote (UUID format)
pattern_id: String,
/// Force promotion even if validation has warnings
#[arg(long)]
force: bool,
},
/// Show learning/promotion statistics
Stats,
/// Run autonomous promotion for high-confidence patterns
///
/// Automatically promotes patterns that meet strict thresholds:
/// - Confidence >= 0.95 (configurable)
/// - Projects >= 10 (configurable)
/// - Zero validation failures
/// - Zero validation warnings
///
/// All decisions are logged to ~/.aphoria/audit/autonomous-decisions.jsonl
/// for compliance and review.
AutoPromote {
/// Preview what would be auto-promoted without making changes
#[arg(long)]
dry_run: bool,
/// Override minimum confidence threshold
#[arg(long)]
min_confidence: Option<f32>,
/// Override minimum project count threshold
#[arg(long)]
min_projects: Option<usize>,
},
/// Show shadow mode testing status
///
/// Displays all extractors in shadow mode with their metrics,
/// including scan counts, FP rates, and graduation eligibility.
ShadowStatus {
/// Show detailed output including match history
#[arg(short, long)]
verbose: bool,
},
/// Provide feedback on shadow matches
///
/// Interactive session to mark shadow matches as true positives
/// or false positives. Feedback is used to calculate FP rates
/// for graduation eligibility.
Feedback {
/// Shadow test name or ID to provide feedback for
test: String,
/// Maximum matches to show per session
#[arg(short, long, default_value = "10")]
limit: usize,
},
/// Graduate a shadow extractor to production
///
/// Moves the extractor from shadow mode to production if it
/// meets graduation criteria (min scans + max FP rate).
Graduate {
/// Shadow test name or ID to graduate
test: String,
/// Force graduation even if criteria not met
#[arg(long)]
force: bool,
},
/// Rollback a shadow extractor
///
/// Removes the extractor from shadow mode and deletes its YAML file.
/// Use when an extractor has too many false positives or other issues.
Rollback {
/// Shadow test name or ID to rollback
test: String,
/// Reason for rollback (for audit log)
#[arg(short, long)]
reason: String,
},
/// Check all shadow tests for auto-rollback and apply if needed
///
/// Scans all active shadow tests and automatically rolls back any
/// that exceed the FP rate threshold (default 15%). Use this for
/// scheduled maintenance or to catch tests that haven't received
/// feedback recently.
AutoCheck,
/// List version history for an extractor
///
/// Shows all versions of an extractor with their changelog entries,
/// dates, and metrics deltas where available.
Versions {
/// Extractor name (e.g., "learned_tls_min_version").
name: String,
},
/// Compare metrics between two versions of an extractor
///
/// Shows the difference in match rate and false positive rate
/// between two versions. Requires shadow mode metrics to be available.
Compare {
/// Extractor name.
name: String,
/// First version to compare.
#[arg(short = 'a', long)]
version_a: u32,
/// Second version to compare.
#[arg(short = 'b', long)]
version_b: u32,
},
/// Rollback to a previous version of an extractor
///
/// Restores a previous version of the extractor as the current version.
/// The current version is archived before being replaced. A new changelog
/// entry is created documenting the rollback.
RollbackVersion {
/// Extractor name.
name: String,
/// Version to rollback to.
#[arg(short, long)]
version: u32,
/// Reason for rollback (recorded in changelog).
#[arg(short, long)]
reason: String,
},
}

View File

@ -0,0 +1,135 @@
//! Governance CLI command definitions.
use clap::Subcommand;
#[derive(Subcommand)]
pub enum GovernanceCommands {
/// List pending approval requests
///
/// Shows all patterns awaiting approval, grouped by workflow and stage.
Pending {
/// Filter by workflow name
#[arg(long)]
workflow: Option<String>,
/// Output format: table or json
#[arg(short, long, default_value = "table")]
format: String,
},
/// Approve the current stage of a request
///
/// Advances the request to the next stage, or completes approval
/// if this was the final stage.
Approve {
/// Request ID (UUID format)
id: String,
/// Optional comment explaining the approval
#[arg(short, long)]
comment: Option<String>,
},
/// Reject a pending request
///
/// Marks the request as rejected. The pattern will not be promoted
/// until a new approval request is created.
Reject {
/// Request ID (UUID format)
id: String,
/// Reason for rejection (required)
#[arg(short, long)]
reason: String,
},
/// Escalate a request to the next stage
///
/// Manually escalates a request to its configured escalation target.
/// Use this when a stage is taking too long or needs higher-level review.
Escalate {
/// Request ID (UUID format)
id: String,
},
/// Show approval request status
///
/// Display detailed status for approval requests, including
/// decisions made and current stage.
Status {
/// Show status for a specific pattern (UUID format)
#[arg(long)]
pattern: Option<String>,
/// Show all requests (including completed)
#[arg(long)]
all: bool,
/// Output format: table or json
#[arg(short, long, default_value = "table")]
format: String,
},
/// Check for timed-out requests and process them
///
/// Scans for requests past their stage deadline and either
/// escalates or expires them based on workflow configuration.
CheckTimeouts,
/// Create an approval request for a pattern
///
/// Manually create an approval request for a pattern. Normally
/// requests are created automatically during promotion.
Create {
/// Pattern ID (UUID format)
pattern_id: String,
/// Workflow to use (defaults to config default_workflow)
#[arg(short, long)]
workflow: Option<String>,
},
}
#[derive(Subcommand)]
pub enum AuditCommands {
/// Show audit trail for a pattern
///
/// Displays all governance events for a pattern in chronological order.
Trail {
/// Pattern ID (UUID format)
#[arg(long)]
pattern: String,
/// Output format: table or json
#[arg(short, long, default_value = "table")]
format: String,
},
/// Export governance audit history
///
/// Export all governance events and requests to a file for
/// compliance reporting or external analysis.
Export {
/// Output file path
#[arg(short, long)]
output: std::path::PathBuf,
/// Export format: json, csv, or markdown
#[arg(short, long, default_value = "json")]
format: String,
/// Filter by date range (YYYY-MM-DD..YYYY-MM-DD)
#[arg(long)]
date_range: Option<String>,
},
/// Show audit summary statistics
///
/// Display summary of governance activity including
/// approval rates, average times, and pending counts.
Summary {
/// Output format: table or json
#[arg(short, long, default_value = "table")]
format: String,
},
}

View File

@ -0,0 +1,135 @@
//! Lifecycle CLI command definitions.
use clap::Subcommand;
#[derive(Subcommand)]
pub enum LifecycleCommands {
/// Deprecate a pattern
///
/// Mark a pattern as deprecated with an optional replacement and sunset date.
/// Deprecated patterns continue to match but FLAG with migration guidance.
Deprecate {
/// Pattern ID (UUID format)
pattern_id: String,
/// Reason for deprecation (required)
#[arg(short, long)]
reason: String,
/// Pattern ID that supersedes this one
#[arg(long)]
superseded_by: Option<String>,
/// Sunset date (ISO 8601 format: YYYY-MM-DD)
#[arg(long)]
sunset_date: Option<String>,
/// URL or path to migration guide
#[arg(long)]
migration_guide: Option<String>,
},
/// Archive a pattern permanently
///
/// Move a pattern to archived status. Archived patterns do not match
/// during scans and are hidden from default listings.
Archive {
/// Pattern ID (UUID format)
pattern_id: String,
/// Reason for archival (required)
#[arg(short, long)]
reason: String,
},
/// Reactivate a deprecated pattern
///
/// Remove deprecation and return pattern to active status.
Reactivate {
/// Pattern ID (UUID format)
pattern_id: String,
/// Reason for reactivation (required)
#[arg(short, long)]
reason: String,
},
/// Show lifecycle history for a pattern
///
/// Display all status transitions for a pattern in chronological order.
History {
/// Pattern ID (UUID format)
pattern_id: String,
/// Output format: table or json
#[arg(short, long, default_value = "table")]
format: String,
},
/// List patterns by lifecycle status
///
/// Filter patterns by their current lifecycle status.
List {
/// Filter by status: active, deprecated, superseded, archived
#[arg(short, long)]
status: Option<String>,
/// Show only patterns past their sunset date
#[arg(long)]
overdue: bool,
/// Output format: table or json
#[arg(short, long, default_value = "table")]
format: String,
},
}
#[derive(Subcommand)]
pub enum MigrationCommands {
/// Show migration status for deprecated patterns
///
/// Display migration progress including usage counts, completion
/// percentages, and blockers.
Status {
/// Filter by pattern ID
#[arg(long)]
pattern: Option<String>,
/// Filter by scope
#[arg(long)]
scope: Option<String>,
/// Output format: table or json
#[arg(short, long, default_value = "table")]
format: String,
},
/// Export migration data
///
/// Export deprecated pattern usages to a file for reporting.
Export {
/// Output file path
#[arg(short, long)]
output: std::path::PathBuf,
/// Output format: json or csv
#[arg(short, long, default_value = "csv")]
format: String,
/// Include resolved usages
#[arg(long)]
include_resolved: bool,
},
/// Show migration blockers for a pattern
///
/// List the specific usages preventing migration completion.
Blockers {
/// Pattern ID (UUID format)
pattern_id: String,
/// Filter by scope
#[arg(long)]
scope: Option<String>,
},
}

View File

@ -0,0 +1,310 @@
//! CLI argument definitions for Aphoria
//!
//! This module is split into submodules to keep file sizes manageable:
//! - `extractors`: Extractor and shadow mode commands
//! - `governance`: Governance and Audit commands
//! - `lifecycle`: Lifecycle and Migration commands
//! - `patterns`: Pattern and Eval commands
//! - `scope`: Scope commands
mod extractors;
mod governance;
mod lifecycle;
mod patterns;
mod scope;
pub use extractors::ExtractorCommands;
pub use governance::{AuditCommands, GovernanceCommands};
pub use lifecycle::{LifecycleCommands, MigrationCommands};
pub use patterns::{EvalCommands, PatternCommands};
pub use scope::ScopeCommands;
use std::path::PathBuf;
use clap::{Parser, Subcommand};
/// A code-level truth linter powered by Episteme.
///
/// Aphoria scans a codebase, extracts the decisions embedded in config and code,
/// and checks them against authoritative sources. It finds the places where what
/// your code *does* contradicts what the specs *say*.
#[derive(Parser)]
#[command(name = "aphoria")]
#[command(version, about, long_about = None)]
pub struct Cli {
/// Path to aphoria.toml configuration file
#[arg(short, long, global = true)]
pub config: Option<PathBuf>,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Scan a project for epistemic drift
Scan {
/// Path to the project root to scan
#[arg(default_value = ".")]
path: PathBuf,
/// Output format: table, json, sarif, markdown
#[arg(short, long, default_value = "table")]
format: String,
/// Exit with non-zero code if conflicts found
#[arg(long)]
exit_code: bool,
/// Use stricter thresholds (FLAG at 0.3, BLOCK at 0.5)
#[arg(long)]
strict: bool,
/// Persist claims to Episteme storage (enables diff/baseline features).
/// Without this flag, scans are ephemeral and fast.
#[arg(long)]
persist: bool,
/// Enable debug output showing conflict resolution traces.
#[arg(long)]
debug: bool,
/// Enable write-back of observations to local Episteme (requires --persist).
#[arg(long)]
sync: bool,
/// Scan only git-staged files (for pre-commit hooks).
#[arg(long)]
staged: bool,
/// Preview what would be shared with the community corpus.
#[arg(long)]
community_preview: bool,
/// Run performance benchmark with timing breakdown.
#[arg(long)]
benchmark: bool,
},
/// Acknowledge a conflict (mark as intentional)
Ack {
/// The concept path to acknowledge
concept_path: String,
/// Reason for acknowledgment
#[arg(short, long)]
reason: String,
/// Optional expiry for acknowledgment (e.g., "90d" or "2026-12-31")
#[arg(long, alias = "expires-at")]
expires: Option<String>,
},
/// Bless a code pattern as the authoritative standard
Bless {
/// The concept path to bless
concept_path: String,
/// The predicate (e.g., "enabled", "min_version")
#[arg(short, long)]
predicate: String,
/// The value (e.g., "true", "1.2")
#[arg(short = 'V', long)]
value: String,
/// Reason/description for this standard
#[arg(short, long)]
reason: String,
},
/// Record an intentional configuration change as a policy update
Update {
/// The concept path being updated
concept_path: String,
/// The new value for this concept
value: String,
/// Reason for the update
#[arg(short, long)]
reason: String,
},
/// Set the current scan as the baseline
Baseline,
/// Show changes since last baseline
Diff,
/// Show current scan status
Status,
/// Initialize Aphoria with authoritative corpus
Init,
/// Manage the authoritative corpus
Corpus {
#[command(subcommand)]
command: CorpusCommands,
},
/// Manage the research agent for filling corpus gaps
Research {
#[command(subcommand)]
command: ResearchCommands,
},
/// Manage federated policies (Trust Packs)
Policy {
#[command(subcommand)]
command: PolicyCommands,
},
/// Manage learned patterns and extractor promotion
Extractors {
#[command(subcommand)]
command: ExtractorCommands,
},
/// Evaluate LLM prompt effectiveness
Eval {
#[command(subcommand)]
command: EvalCommands,
},
/// Manage cross-project pattern learning
Patterns {
#[command(subcommand)]
command: PatternCommands,
},
/// Manage knowledge scopes and inheritance
Scope {
#[command(subcommand)]
command: ScopeCommands,
},
/// Manage knowledge lifecycle (deprecation, archival)
Lifecycle {
#[command(subcommand)]
command: LifecycleCommands,
},
/// Track migration progress for deprecated patterns
Migrations {
#[command(subcommand)]
command: MigrationCommands,
},
/// Manage approval workflows for pattern promotion
Governance {
#[command(subcommand)]
command: GovernanceCommands,
},
/// View and export audit trails for compliance
Audit {
#[command(subcommand)]
command: AuditCommands,
},
}
#[derive(Subcommand)]
pub enum CorpusCommands {
/// Build the authoritative corpus from configured sources
Build {
/// Only include specific sources (comma-separated)
#[arg(long)]
only: Option<String>,
/// Run in offline mode (skip sources requiring network)
#[arg(long)]
offline: bool,
/// Clear cache before building
#[arg(long)]
clear_cache: bool,
},
/// List available corpus sources
List,
}
#[derive(Subcommand)]
pub enum ResearchCommands {
/// Run the research agent to fill corpus gaps
Run {
/// Minimum projects that must report a gap before researching
#[arg(short, long, default_value = "3")]
threshold: u32,
/// Use strict quality validation
#[arg(long)]
strict: bool,
/// Prune old gaps before researching
#[arg(long)]
prune: bool,
/// Maximum age of gaps to consider in days
#[arg(long, default_value = "90")]
max_age: u64,
},
/// Show research agent status and gap statistics
Status,
/// List gaps eligible for research
Gaps {
/// Minimum projects that must report a gap
#[arg(short, long, default_value = "1")]
threshold: u32,
/// Show only gaps ready for research (seen in 3+ projects)
#[arg(long)]
ready: bool,
},
}
#[derive(Subcommand)]
pub enum PolicyCommands {
/// Export acknowledged conflicts as a Trust Pack
Export {
/// Name of the policy pack
#[arg(long)]
name: String,
/// Output path for the pack file
#[arg(short, long)]
output: PathBuf,
},
/// Import a Trust Pack into the local Episteme
Import {
/// Path to the .pack file
file: PathBuf,
},
/// Re-sign a Trust Pack with a new key
Resign {
/// Path to the .pack file to re-sign
file: PathBuf,
/// Output path for the re-signed pack
#[arg(short, long)]
output: PathBuf,
/// Path to new signing key
#[arg(long)]
key: Option<PathBuf>,
/// Reason for re-signing (for audit trail)
#[arg(long)]
reason: Option<String>,
/// Preserve signature chain for audit trail
#[arg(long, default_value = "true")]
chain_signatures: bool,
},
}

View File

@ -0,0 +1,148 @@
//! Pattern CLI command definitions.
use std::path::PathBuf;
use clap::Subcommand;
#[derive(Subcommand)]
pub enum PatternCommands {
/// Sync learned patterns to hosted server
///
/// Uploads patterns that meet local thresholds (min projects, min confidence)
/// to the hosted server for cross-project learning.
Sync {
/// Preview what would be synced without sending
#[arg(long)]
dry_run: bool,
},
/// Show pattern sync status
///
/// Displays local pattern store stats, eligible patterns, and sync status.
Status,
/// Pull community extractors from hosted server
///
/// Downloads extractors that have been aggregated from patterns across
/// many organizations and saves them as YAML files.
PullCommunity {
/// Minimum projects threshold for community extractors (default: 50)
#[arg(long, default_value = "50")]
min_projects: u64,
/// Preview without saving to disk
#[arg(long)]
dry_run: bool,
},
/// Show learned patterns with evidence information
///
/// Displays patterns with their evidence levels, sources, and promotion eligibility.
/// Evidence levels: ProductSpec > Standard > Research > Commit.
Show {
/// Show a specific pattern by ID (UUID format)
#[arg(long)]
id: Option<String>,
/// Filter by evidence level: commit, research, standard, product_spec
#[arg(long)]
evidence: Option<String>,
/// Show only patterns eligible for promotion
#[arg(long)]
eligible: bool,
/// Output format: table or json
#[arg(short, long, default_value = "table")]
format: String,
/// Filter by scope level: organization, team, or project
#[arg(long)]
scope: Option<String>,
/// Exclude inherited patterns (show only local scope)
#[arg(long)]
only_local: bool,
/// Show full inheritance chain for each pattern
#[arg(long)]
show_inheritance: bool,
},
}
#[derive(Subcommand)]
pub enum EvalCommands {
/// Run evaluation against fixtures
Run {
/// Path to fixtures directory
#[arg(long, default_value = "tests/llm_fixtures")]
fixtures: PathBuf,
/// Categories to evaluate (comma-separated)
#[arg(long)]
categories: Option<String>,
/// Maximum fixtures to run (for smoke tests)
#[arg(long)]
max_fixtures: Option<usize>,
/// Evaluation mode: live, cached, mock
#[arg(long, default_value = "mock")]
mode: String,
/// Exit with code 1 if regression detected
#[arg(long)]
fail_on_regression: bool,
/// Regression threshold (default: 0.05 = 5%)
#[arg(long, default_value = "0.05")]
threshold: f64,
/// Save observation logs
#[arg(long)]
save_observations: bool,
/// Output format: table, json, markdown
#[arg(long, default_value = "table")]
format: String,
},
/// Show current baseline metrics
Baseline {
/// Path to fixtures directory
#[arg(long, default_value = "tests/llm_fixtures")]
fixtures: PathBuf,
},
/// Update baseline from latest run
///
/// This overwrites the baseline metrics in manifest.toml.
/// Requires --force to prevent accidental overwrites.
UpdateBaseline {
/// Path to fixtures directory
#[arg(long, default_value = "tests/llm_fixtures")]
fixtures: PathBuf,
/// Required - prevents accidental baseline overwrites
#[arg(long, required = true)]
force: bool,
},
/// List available fixtures
ListFixtures {
/// Path to fixtures directory
#[arg(long, default_value = "tests/llm_fixtures")]
fixtures: PathBuf,
/// Filter by category
#[arg(long)]
category: Option<String>,
},
/// Validate fixture format
ValidateFixtures {
/// Path to fixtures directory
#[arg(long, default_value = "tests/llm_fixtures")]
fixtures: PathBuf,
},
}

View File

@ -0,0 +1,62 @@
//! Scope CLI command definitions.
use clap::Subcommand;
#[derive(Subcommand)]
pub enum ScopeCommands {
/// Show current scope context
///
/// Displays the configured scope hierarchy and inheritance chain.
Status,
/// Override an inherited pattern
///
/// Creates an explicit override for a pattern inherited from a
/// broader scope (organization or team). Requires justification.
Override {
/// Concept path to override (e.g., "tls/min_version")
concept_path: String,
/// New value for the override
#[arg(short = 'V', long)]
value: String,
/// Reason for the override (required)
#[arg(short, long)]
reason: String,
/// Evidence reference (ADR, ticket, spec)
#[arg(short, long)]
evidence: Option<String>,
/// Expiration duration (e.g., "90d" for 90 days)
#[arg(long)]
expires: Option<String>,
},
/// List overrides at current scope
///
/// Shows all pattern overrides defined at the current scope level.
List {
/// Include inherited overrides from broader scopes
#[arg(long)]
include_inherited: bool,
/// Show expired overrides
#[arg(long)]
show_expired: bool,
},
/// Remove an override
///
/// Deletes a scope override, allowing the inherited pattern
/// to take effect again.
Remove {
/// Concept path of the override to remove
concept_path: String,
/// Force removal without confirmation
#[arg(short, long)]
force: bool,
},
}

View File

@ -166,6 +166,14 @@ mod tests {
fn pattern_count(&self) -> usize {
self.patterns.len()
}
fn get_all_patterns(&self) -> Vec<LearnedPattern> {
self.patterns.clone()
}
fn get_pattern_by_id(&self, id: &uuid::Uuid) -> Option<LearnedPattern> {
self.patterns.iter().find(|p| p.id == *id).cloned()
}
}
fn create_test_pattern(

View File

@ -21,7 +21,7 @@ pub use defaults::llm_cache_dir;
pub use types::{
AliasConfig, AphoriaConfig, AutonomousConfig, CommunityConfig, CorpusConfig,
CrossProjectConfig, DepVersionConfig, EntropyConfig, EpistemeConfig, EvalConfig,
ExtractorConfig, HostedConfig, LearningConfig, LlmConfig, OfflineFallback,
ExtractorConfig, GovernanceConfig, HostedConfig, LearningConfig, LlmConfig, OfflineFallback,
PredicateAliasConfig, ProjectConfig, PromotionConfig, ScanConfig, ShadowConfig, SyncMode,
ThresholdConfig, TimeoutExtractorConfig, DEFAULT_LLM_MODEL,
};

View File

@ -8,14 +8,18 @@ use super::autonomous::AutonomousConfig;
use super::cross_project::CrossProjectConfig;
use super::eval::EvalConfig;
use super::extractors::ExtractorConfig;
use super::governance::GovernanceConfig;
use super::hosted::HostedConfig;
use super::learning::LearningConfig;
use super::llm::LlmConfig;
use super::predicates::PredicateAliasConfig;
use super::scan::{AliasConfig, CorpusConfig, ScanConfig};
use super::shadow::ShadowConfig;
use super::trust_pack::TrustPackConfig;
use super::CommunityConfig;
use crate::scope::ScopeConfig;
/// Default LLM model for extraction.
///
/// This is the single source of truth for the default model.
@ -82,6 +86,15 @@ pub struct AphoriaConfig {
/// Cross-project learning settings for pattern sharing.
pub cross_project: CrossProjectConfig,
/// Trust Pack export settings.
pub trust_pack: TrustPackConfig,
/// Scope hierarchy settings for pattern inheritance.
pub scope: ScopeConfig,
/// Governance workflow settings for pattern promotion approval.
pub governance: GovernanceConfig,
}
/// Project identification settings.

View File

@ -0,0 +1,155 @@
//! Governance configuration types.
//!
//! Configuration for approval workflows and governance settings.
use std::path::PathBuf;
use serde::Deserialize;
use crate::governance::ApprovalWorkflow;
/// Governance workflow configuration.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct GovernanceConfig {
/// Whether governance is enabled.
///
/// When false, pattern promotion proceeds without approval workflows.
pub enabled: bool,
/// Custom governance data directory.
///
/// Defaults to `~/.aphoria/governance/`.
pub governance_dir: Option<PathBuf>,
/// Name of the default workflow to use.
///
/// Must match a workflow in the `workflows` list.
pub default_workflow: Option<String>,
/// Configured approval workflows.
#[serde(default)]
pub workflows: Vec<ApprovalWorkflow>,
/// Check for expired requests on every access.
///
/// When true, timeout checks run automatically during list operations.
/// When false, timeouts are only processed via explicit `governance check` command.
#[serde(default)]
pub check_timeouts_on_access: bool,
}
impl GovernanceConfig {
/// Create a new governance config with a single workflow.
pub fn with_workflow(workflow: ApprovalWorkflow) -> Self {
Self {
enabled: true,
governance_dir: None,
default_workflow: Some(workflow.name.clone()),
workflows: vec![workflow],
check_timeouts_on_access: false,
}
}
/// Get a workflow by name.
pub fn get_workflow(&self, name: &str) -> Option<&ApprovalWorkflow> {
self.workflows.iter().find(|w| w.name == name)
}
/// Get the default workflow.
pub fn get_default_workflow(&self) -> Option<&ApprovalWorkflow> {
self.default_workflow.as_ref().and_then(|name| self.get_workflow(name))
}
/// Validate the configuration.
pub fn validate(&self) -> Result<(), String> {
if self.enabled && self.workflows.is_empty() {
return Err("Governance is enabled but no workflows are configured".to_string());
}
if let Some(ref default) = self.default_workflow {
if self.get_workflow(default).is_none() {
return Err(format!("Default workflow '{}' not found", default));
}
}
// Validate each workflow
for workflow in &self.workflows {
workflow.validate()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::governance::workflow::{ApprovalStage, ApprovalWorkflow};
#[test]
fn test_default_config() {
let config = GovernanceConfig::default();
assert!(!config.enabled);
assert!(config.workflows.is_empty());
}
#[test]
fn test_with_workflow() {
let workflow =
ApprovalWorkflow::new("test", "Test").add_stage(ApprovalStage::new("s1", "Stage 1"));
let config = GovernanceConfig::with_workflow(workflow);
assert!(config.enabled);
assert_eq!(config.default_workflow, Some("test".to_string()));
assert_eq!(config.workflows.len(), 1);
}
#[test]
fn test_get_workflow() {
let workflow =
ApprovalWorkflow::new("test", "Test").add_stage(ApprovalStage::new("s1", "Stage 1"));
let config = GovernanceConfig::with_workflow(workflow);
assert!(config.get_workflow("test").is_some());
assert!(config.get_workflow("nonexistent").is_none());
}
#[test]
fn test_validation() {
// Enabled with no workflows - should fail
let config = GovernanceConfig { enabled: true, ..Default::default() };
assert!(config.validate().is_err());
// Enabled with workflows - should pass
let workflow =
ApprovalWorkflow::new("test", "Test").add_stage(ApprovalStage::new("s1", "Stage 1"));
let config = GovernanceConfig::with_workflow(workflow);
assert!(config.validate().is_ok());
// Invalid default workflow - should fail
let config = GovernanceConfig {
enabled: true,
default_workflow: Some("nonexistent".to_string()),
workflows: vec![
ApprovalWorkflow::new("other", "Other").add_stage(ApprovalStage::new("s1", "S1"))
],
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_serde_defaults() {
let toml = r#"
enabled = true
default_workflow = "standard"
"#;
let config: GovernanceConfig = toml::from_str(toml).expect("parse");
assert!(config.enabled);
assert_eq!(config.default_workflow, Some("standard".to_string()));
assert!(config.workflows.is_empty()); // Default to empty
}
}

View File

@ -19,12 +19,14 @@ mod core;
mod cross_project;
mod eval;
mod extractors;
mod governance;
mod hosted;
mod learning;
mod llm;
mod predicates;
mod scan;
mod shadow;
mod trust_pack;
// Re-export all public types for API compatibility.
#[allow(unused_imports)]
@ -40,6 +42,8 @@ pub use eval::EvalConfig;
#[allow(unused_imports)]
pub use extractors::{DepVersionConfig, EntropyConfig, ExtractorConfig, TimeoutExtractorConfig};
#[allow(unused_imports)]
pub use governance::GovernanceConfig;
#[allow(unused_imports)]
pub use hosted::{HostedConfig, OfflineFallback, SyncMode};
#[allow(unused_imports)]
pub use learning::{LearningConfig, PromotionConfig};
@ -51,3 +55,9 @@ pub use predicates::PredicateAliasConfig;
pub use scan::{AliasConfig, CorpusConfig, ScanConfig};
#[allow(unused_imports)]
pub use shadow::ShadowConfig;
#[allow(unused_imports)]
pub use trust_pack::TrustPackConfig;
// Re-export scope config from scope module
#[allow(unused_imports)]
pub use crate::scope::ScopeConfig;

View File

@ -0,0 +1,23 @@
//! Trust Pack export configuration.
use serde::Deserialize;
/// Configuration for Trust Pack exports.
///
/// These settings are used when exporting a Trust Pack via `aphoria policy export`.
/// The signer_name and contact fields help downstream importers know who to contact
/// when they see a conflict from this pack.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct TrustPackConfig {
/// Human-readable name of the signer or team.
///
/// Example: "Platform Security Team"
pub signer_name: Option<String>,
/// Contact information for the signer.
///
/// Can be a Slack channel, email, or other contact method.
/// Example: "#security-policy" or "security@acme.com"
pub contact: Option<String>,
}

View File

@ -138,6 +138,8 @@ impl EphemeralDetector {
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

View File

@ -123,6 +123,8 @@ impl LocalEpisteme {
pack_name: info.pack_name.clone(),
pack_version: info.pack_version.clone(),
issuer_hex: info.issuer_hex.clone(),
signer_name: info.signer_name.clone(),
contact: info.contact.clone(),
})
}
Ok(None) => {

View File

@ -136,4 +136,8 @@ pub enum AphoriaError {
/// Invalid expiry specification (e.g., invalid duration or date format).
#[error("Invalid expiry: {0}")]
InvalidExpiry(String),
/// Governance workflow error (approval pending, rejected, or configuration issue).
#[error("Governance error: {0}")]
Governance(String),
}

View File

@ -0,0 +1,417 @@
//! Evidence source detection from commit messages and file paths.
//!
//! Parses commit messages, file paths, and project directories to detect
//! references to RFCs, OWASP categories, ADRs, and specification documents.
use std::path::Path;
use std::sync::OnceLock;
use regex::Regex;
use super::types::{EvidenceSource, PatternEvidence};
/// Get the RFC detection regex.
///
/// Matches patterns like:
/// - "RFC 7519"
/// - "RFC7519"
/// - "rfc-7519"
/// - "RFC 7519 Section 4.1"
/// - "RFC 7519 sec. 4.1.2"
fn rfc_pattern() -> &'static Regex {
static PATTERN: OnceLock<Regex> = OnceLock::new();
PATTERN.get_or_init(|| {
// These regex patterns are compile-time constants validated by unit tests.
// Invalid patterns will cause test failures before deployment.
Regex::new(r"(?i)\bRFC[- ]?(\d{3,5})(?:\s+(?:Section|sec\.?)\s+(\d+(?:\.\d+)*))?")
.unwrap_or_else(|_| unreachable!("RFC regex is a valid compile-time constant"))
})
}
/// Get the OWASP detection regex.
///
/// Matches patterns like:
/// - "OWASP A03:2021"
/// - "A03:2021"
/// - "OWASP A03"
/// - "A01"
fn owasp_pattern() -> &'static Regex {
static PATTERN: OnceLock<Regex> = OnceLock::new();
PATTERN.get_or_init(|| {
Regex::new(r"(?i)\b(?:OWASP[- ]?)?([A-Z]\d{2})(?::(\d{4}))?")
.unwrap_or_else(|_| unreachable!("OWASP regex is a valid compile-time constant"))
})
}
/// Get the ADR detection regex for commit messages.
///
/// Matches patterns like:
/// - "ADR-042"
/// - "ADR 042"
/// - "adr:042"
/// - "adr_42"
fn adr_commit_pattern() -> &'static Regex {
static PATTERN: OnceLock<Regex> = OnceLock::new();
PATTERN.get_or_init(|| {
Regex::new(r"(?i)\bADR[- :_]?(\d+)")
.unwrap_or_else(|_| unreachable!("ADR regex is a valid compile-time constant"))
})
}
/// Get the requirement ID detection regex.
///
/// Matches patterns like:
/// - "REQ-API-001"
/// - "REQ-001"
/// - "REQUIREMENT-123"
fn requirement_pattern() -> &'static Regex {
static PATTERN: OnceLock<Regex> = OnceLock::new();
PATTERN.get_or_init(|| {
Regex::new(r"(?i)\b(REQ(?:UIREMENT)?-[A-Z0-9-]+)")
.unwrap_or_else(|_| unreachable!("Requirement regex is a valid compile-time constant"))
})
}
/// Evidence detector for extracting evidence sources from text and files.
pub struct EvidenceDetector;
impl EvidenceDetector {
/// Detect evidence sources from a commit message.
///
/// Parses the message for RFC references, OWASP categories, and ADR links.
/// The commit hash is included as a fallback Commit source.
#[must_use]
pub fn from_commit_message(message: &str, commit_hash: &str) -> PatternEvidence {
let mut sources = Vec::new();
// Detect RFC references
for cap in rfc_pattern().captures_iter(message) {
if let Some(number_match) = cap.get(1) {
if let Ok(number) = number_match.as_str().parse::<u32>() {
let section = cap.get(2).map(|m| m.as_str().to_string());
sources.push(EvidenceSource::Rfc { number, section });
}
}
}
// Detect OWASP references
for cap in owasp_pattern().captures_iter(message) {
if let Some(id_match) = cap.get(1) {
let id = id_match.as_str().to_uppercase();
let year = cap.get(2).and_then(|m| m.as_str().parse::<u16>().ok());
sources.push(EvidenceSource::Owasp { id, year });
}
}
// Detect ADR references
for cap in adr_commit_pattern().captures_iter(message) {
if let Some(id_match) = cap.get(1) {
sources.push(EvidenceSource::Adr { id: id_match.as_str().to_string(), path: None });
}
}
// Always include commit as fallback source
let message_excerpt = if message.len() > 100 {
Some(format!("{}...", &message[..100]))
} else if !message.is_empty() {
Some(message.to_string())
} else {
None
};
sources.push(EvidenceSource::Commit { hash: commit_hash.to_string(), message_excerpt });
PatternEvidence::from_sources(sources)
}
/// Detect evidence source from a file path.
///
/// Identifies ADR files, spec files, and decision logs based on path patterns.
#[must_use]
pub fn from_file_path(path: &Path) -> Option<EvidenceSource> {
let path_str = path.to_string_lossy().to_lowercase();
let file_name = path.file_name()?.to_string_lossy().to_lowercase();
// ADR files: docs/adr/*.md, docs/decisions/*.md, adr-*.md
if path_str.contains("/adr/")
|| path_str.contains("/decisions/")
|| file_name.starts_with("adr-")
|| file_name.starts_with("adr_")
{
// Extract ADR ID from filename if possible
let id = Self::extract_adr_id_from_filename(&file_name);
return Some(EvidenceSource::Adr {
id: id.unwrap_or_else(|| "unknown".to_string()),
path: Some(path.to_string_lossy().to_string()),
});
}
// Spec files: specs/*.md, *.spec.md, *.spec.yaml
if path_str.contains("/specs/")
|| path_str.starts_with("specs/")
|| file_name.contains(".spec.")
|| file_name.starts_with("spec-")
{
return Some(EvidenceSource::Spec {
path: path.to_string_lossy().to_string(),
requirement_id: None,
});
}
// Decision logs: decision-log.md, DECISIONS.md
if file_name == "decision-log.md"
|| file_name == "decisions.md"
|| file_name == "decision_log.md"
{
return Some(EvidenceSource::DecisionLog {
path: path.to_string_lossy().to_string(),
entry_id: None,
});
}
None
}
/// Scan a project directory for evidence sources.
///
/// Walks the directory tree looking for ADRs, specs, and decision logs.
#[must_use]
pub fn scan_project_directory(project_root: &Path) -> Vec<EvidenceSource> {
let mut sources = Vec::new();
// Common evidence directories
let evidence_dirs =
["docs/adr", "docs/decisions", "docs/adrs", "adr", "decisions", "specs", "spec"];
for dir_name in evidence_dirs {
let dir_path = project_root.join(dir_name);
if dir_path.is_dir() {
if let Ok(entries) = std::fs::read_dir(&dir_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(source) = Self::from_file_path(&path) {
sources.push(source);
}
}
}
}
}
}
// Check for decision logs at root
let decision_log_names = ["decision-log.md", "DECISIONS.md", "decision_log.md"];
for name in decision_log_names {
let log_path = project_root.join(name);
if log_path.is_file() {
sources.push(EvidenceSource::DecisionLog {
path: log_path.to_string_lossy().to_string(),
entry_id: None,
});
}
}
sources
}
/// Extract requirement IDs from text content.
///
/// Useful for finding requirement references in spec files.
#[must_use]
pub fn extract_requirement_ids(content: &str) -> Vec<String> {
requirement_pattern()
.captures_iter(content)
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
.collect()
}
/// Extract ADR ID from filename.
fn extract_adr_id_from_filename(filename: &str) -> Option<String> {
// Try patterns like: adr-042.md, 042-decision.md, adr_42.md
let patterns = [r"(?i)adr[-_]?(\d+)", r"^(\d+)[-_]"];
for pattern in patterns {
if let Ok(re) = Regex::new(pattern) {
if let Some(cap) = re.captures(filename) {
if let Some(id) = cap.get(1) {
return Some(id.as_str().to_string());
}
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rfc_detection_simple() {
let evidence =
EvidenceDetector::from_commit_message("Implement JWT per RFC 7519", "abc123");
let rfc_sources: Vec<_> =
evidence.sources.iter().filter(|s| matches!(s, EvidenceSource::Rfc { .. })).collect();
assert_eq!(rfc_sources.len(), 1);
if let EvidenceSource::Rfc { number, section } = rfc_sources[0] {
assert_eq!(*number, 7519);
assert!(section.is_none());
}
}
#[test]
fn test_rfc_detection_with_section() {
let evidence =
EvidenceDetector::from_commit_message("See RFC 7519 Section 4.1 for details", "abc123");
let rfc_sources: Vec<_> =
evidence.sources.iter().filter(|s| matches!(s, EvidenceSource::Rfc { .. })).collect();
assert_eq!(rfc_sources.len(), 1);
if let EvidenceSource::Rfc { number, section } = rfc_sources[0] {
assert_eq!(*number, 7519);
assert_eq!(section.as_deref(), Some("4.1"));
}
}
#[test]
fn test_rfc_detection_compact() {
let evidence = EvidenceDetector::from_commit_message("RFC7519 compliance", "abc123");
let rfc_sources: Vec<_> =
evidence.sources.iter().filter(|s| matches!(s, EvidenceSource::Rfc { .. })).collect();
assert_eq!(rfc_sources.len(), 1);
}
#[test]
fn test_owasp_detection() {
let evidence =
EvidenceDetector::from_commit_message("Fix OWASP A03:2021 injection issue", "abc123");
let owasp_sources: Vec<_> =
evidence.sources.iter().filter(|s| matches!(s, EvidenceSource::Owasp { .. })).collect();
assert_eq!(owasp_sources.len(), 1);
if let EvidenceSource::Owasp { id, year } = owasp_sources[0] {
assert_eq!(id, "A03");
assert_eq!(*year, Some(2021));
}
}
#[test]
fn test_owasp_detection_without_year() {
let evidence = EvidenceDetector::from_commit_message("Address A01 vulnerability", "abc123");
let owasp_sources: Vec<_> =
evidence.sources.iter().filter(|s| matches!(s, EvidenceSource::Owasp { .. })).collect();
assert_eq!(owasp_sources.len(), 1);
if let EvidenceSource::Owasp { id, year } = owasp_sources[0] {
assert_eq!(id, "A01");
assert!(year.is_none());
}
}
#[test]
fn test_adr_detection() {
let evidence =
EvidenceDetector::from_commit_message("Implement ADR-042 for auth flow", "abc123");
let adr_sources: Vec<_> =
evidence.sources.iter().filter(|s| matches!(s, EvidenceSource::Adr { .. })).collect();
assert_eq!(adr_sources.len(), 1);
if let EvidenceSource::Adr { id, path } = adr_sources[0] {
assert_eq!(id, "042");
assert!(path.is_none());
}
}
#[test]
fn test_commit_always_included() {
let evidence = EvidenceDetector::from_commit_message("Just a regular commit", "abc123");
let commit_sources: Vec<_> = evidence
.sources
.iter()
.filter(|s| matches!(s, EvidenceSource::Commit { .. }))
.collect();
assert_eq!(commit_sources.len(), 1);
}
#[test]
fn test_file_path_adr() {
let path = Path::new("docs/adr/042-use-jwt.md");
let source = EvidenceDetector::from_file_path(path);
assert!(source.is_some());
if let Some(EvidenceSource::Adr { id, path: _ }) = source {
assert_eq!(id, "042");
}
}
#[test]
fn test_file_path_spec() {
let path = Path::new("specs/api-design.md");
let source = EvidenceDetector::from_file_path(path);
assert!(source.is_some());
assert!(matches!(source, Some(EvidenceSource::Spec { .. })));
}
#[test]
fn test_file_path_decision_log() {
let path = Path::new("decision-log.md");
let source = EvidenceDetector::from_file_path(path);
assert!(source.is_some());
assert!(matches!(source, Some(EvidenceSource::DecisionLog { .. })));
}
#[test]
fn test_file_path_no_match() {
let path = Path::new("src/main.rs");
let source = EvidenceDetector::from_file_path(path);
assert!(source.is_none());
}
#[test]
fn test_multiple_sources_in_message() {
let evidence = EvidenceDetector::from_commit_message(
"Implement RFC 7519 and fix OWASP A03:2021 per ADR-042",
"abc123",
);
// Should have RFC, OWASP, ADR, and Commit
assert!(evidence.source_count() >= 4);
}
#[test]
fn test_extract_requirement_ids() {
let content = "This implements REQ-API-001 and REQ-AUTH-002.";
let ids = EvidenceDetector::extract_requirement_ids(content);
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"REQ-API-001".to_string()));
assert!(ids.contains(&"REQ-AUTH-002".to_string()));
}
#[test]
fn test_evidence_level_from_commit() {
let evidence = EvidenceDetector::from_commit_message("Regular commit", "abc123");
assert_eq!(evidence.effective_level(), super::super::types::EvidenceLevel::Commit);
}
#[test]
fn test_evidence_level_from_rfc() {
let evidence = EvidenceDetector::from_commit_message("Implement RFC 7519", "abc123");
assert_eq!(evidence.effective_level(), super::super::types::EvidenceLevel::Standard);
}
}

View File

@ -0,0 +1,32 @@
//! Evidence-based authority for pattern learning.
//!
//! Authority comes from evidence, not titles. Patterns backed by RFC research,
//! product specs, or ADRs carry more weight and graduate faster than patterns
//! that are just code commits with no context.
//!
//! # Evidence Levels
//!
//! | Level | Example | Weight | Graduation |
//! |-------------|---------------------------------------|--------|------------|
//! | ProductSpec | specs/api-design.md → REQ-API-001 | 0.95 | 1 usage |
//! | Standard | RFC 7519, OWASP A03:2021 | 0.85 | 3 usages |
//! | Research | ADR-042, docs/decision-log.md | 0.70 | 5 usages |
//! | Commit | Just code, no context | 0.40 | 10 usages |
//!
//! # Flow
//!
//! ```text
//! Pattern observed in commit message
//! ↓
//! Detect evidence sources (RFC refs, ADR links, etc.)
//! ↓
//! Compute evidence level (highest source wins)
//! ↓
//! Apply evidence-aware graduation threshold
//! ```
mod detection;
mod types;
pub use detection::EvidenceDetector;
pub use types::{EvidenceLevel, EvidenceLevelParseError, EvidenceSource, PatternEvidence};

View File

@ -0,0 +1,508 @@
//! Core types for evidence-based authority.
//!
//! These types represent the sources of evidence that back patterns
//! and determine their authority weight for graduation decisions.
use serde::{Deserialize, Serialize};
/// Evidence level determines authority weight and graduation threshold.
///
/// Higher evidence levels have more authority and graduate faster.
/// The level is determined by the highest-quality evidence source attached
/// to a pattern.
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceLevel {
/// Just code commits, no external context.
/// Weight: 0.40, Graduation threshold: 10 usages
#[default]
Commit = 0,
/// Research documentation (ADRs, decision logs).
/// Weight: 0.70, Graduation threshold: 5 usages
Research = 1,
/// External standards (RFCs, OWASP).
/// Weight: 0.85, Graduation threshold: 3 usages
Standard = 2,
/// Product specifications with requirement IDs.
/// Weight: 0.95, Graduation threshold: 1 usage
ProductSpec = 3,
}
impl EvidenceLevel {
/// Authority weight for this evidence level.
///
/// Higher weights indicate more authoritative evidence.
#[must_use]
pub const fn authority_weight(&self) -> f32 {
match self {
EvidenceLevel::Commit => 0.40,
EvidenceLevel::Research => 0.70,
EvidenceLevel::Standard => 0.85,
EvidenceLevel::ProductSpec => 0.95,
}
}
/// Minimum usages required for graduation at this evidence level.
///
/// Higher evidence levels require fewer usages to graduate.
#[must_use]
pub const fn graduation_threshold(&self) -> usize {
match self {
EvidenceLevel::Commit => 10,
EvidenceLevel::Research => 5,
EvidenceLevel::Standard => 3,
EvidenceLevel::ProductSpec => 1,
}
}
/// Display badge for this evidence level.
///
/// Used in CLI output to show the evidence level.
#[must_use]
pub const fn badge(&self) -> &'static str {
match self {
EvidenceLevel::Commit => "[COMMIT]",
EvidenceLevel::Research => "[RESEARCH]",
EvidenceLevel::Standard => "[STANDARD]",
EvidenceLevel::ProductSpec => "[SPEC]",
}
}
/// Short name for display.
#[must_use]
pub const fn name(&self) -> &'static str {
match self {
EvidenceLevel::Commit => "commit",
EvidenceLevel::Research => "research",
EvidenceLevel::Standard => "standard",
EvidenceLevel::ProductSpec => "product_spec",
}
}
/// Threshold multiplier for graduation calculations.
///
/// This is the fraction of the base threshold required at this evidence level.
#[must_use]
pub const fn threshold_multiplier(&self) -> f32 {
match self {
EvidenceLevel::ProductSpec => 0.1, // 10% of base
EvidenceLevel::Standard => 0.3, // 30% of base
EvidenceLevel::Research => 0.5, // 50% of base
EvidenceLevel::Commit => 1.0, // Full base
}
}
}
impl std::fmt::Display for EvidenceLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name())
}
}
impl std::str::FromStr for EvidenceLevel {
type Err = EvidenceLevelParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"commit" => Ok(EvidenceLevel::Commit),
"research" => Ok(EvidenceLevel::Research),
"standard" => Ok(EvidenceLevel::Standard),
"product_spec" | "productspec" | "spec" => Ok(EvidenceLevel::ProductSpec),
_ => Err(EvidenceLevelParseError(s.to_string())),
}
}
}
/// Error returned when parsing an invalid evidence level string.
#[derive(Debug, Clone)]
pub struct EvidenceLevelParseError(pub String);
impl std::fmt::Display for EvidenceLevelParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"invalid evidence level '{}': expected commit, research, standard, or product_spec",
self.0
)
}
}
impl std::error::Error for EvidenceLevelParseError {}
/// Source of evidence for a pattern.
///
/// Tagged enum for different evidence source types, enabling
/// source-specific formatting and future linking.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EvidenceSource {
/// Reference to an IETF RFC.
Rfc {
/// RFC number (e.g., 7519 for JWT).
number: u32,
/// Optional section reference (e.g., "4.1").
section: Option<String>,
},
/// Reference to an OWASP category.
Owasp {
/// OWASP ID (e.g., "A03" for Injection).
id: String,
/// Optional year (e.g., 2021).
year: Option<u16>,
},
/// Reference to an Architecture Decision Record.
Adr {
/// ADR identifier (e.g., "042").
id: String,
/// Optional file path.
path: Option<String>,
},
/// Reference to a product specification.
Spec {
/// Path to the spec file.
path: String,
/// Optional requirement ID (e.g., "REQ-API-001").
requirement_id: Option<String>,
},
/// Reference to a decision log entry.
DecisionLog {
/// Path to the decision log file.
path: String,
/// Optional entry ID within the log.
entry_id: Option<String>,
},
/// Reference to a git commit.
Commit {
/// Commit hash (full or short).
hash: String,
/// Optional excerpt from commit message.
message_excerpt: Option<String>,
},
}
impl EvidenceSource {
/// Get the evidence level for this source type.
#[must_use]
pub const fn level(&self) -> EvidenceLevel {
match self {
EvidenceSource::Rfc { .. } | EvidenceSource::Owasp { .. } => EvidenceLevel::Standard,
EvidenceSource::Adr { .. } | EvidenceSource::DecisionLog { .. } => {
EvidenceLevel::Research
}
EvidenceSource::Spec { .. } => EvidenceLevel::ProductSpec,
EvidenceSource::Commit { .. } => EvidenceLevel::Commit,
}
}
/// Format for display.
#[must_use]
pub fn display(&self) -> String {
match self {
EvidenceSource::Rfc { number, section } => {
if let Some(sec) = section {
format!("RFC {number} Section {sec}")
} else {
format!("RFC {number}")
}
}
EvidenceSource::Owasp { id, year } => {
if let Some(y) = year {
format!("OWASP {id}:{y}")
} else {
format!("OWASP {id}")
}
}
EvidenceSource::Adr { id, path } => {
if let Some(p) = path {
format!("ADR-{id} ({p})")
} else {
format!("ADR-{id}")
}
}
EvidenceSource::Spec { path, requirement_id } => {
if let Some(req) = requirement_id {
format!("{path}#{req}")
} else {
path.clone()
}
}
EvidenceSource::DecisionLog { path, entry_id } => {
if let Some(entry) = entry_id {
format!("{path}#{entry}")
} else {
path.clone()
}
}
EvidenceSource::Commit { hash, message_excerpt } => {
let short_hash = if hash.len() > 7 { &hash[..7] } else { hash };
if let Some(msg) = message_excerpt {
let short_msg =
if msg.len() > 30 { format!("{}...", &msg[..30]) } else { msg.clone() };
format!("{short_hash}: {short_msg}")
} else {
short_hash.to_string()
}
}
}
}
}
impl std::fmt::Display for EvidenceSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.display())
}
}
/// Evidence attached to a pattern.
///
/// Aggregates multiple evidence sources and caches the highest level.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PatternEvidence {
/// All evidence sources found for this pattern.
pub sources: Vec<EvidenceSource>,
/// Cached highest evidence level (computed from sources).
#[serde(skip_serializing_if = "Option::is_none")]
pub level: Option<EvidenceLevel>,
}
impl PatternEvidence {
/// Create new pattern evidence with no sources.
#[must_use]
pub fn new() -> Self {
Self { sources: Vec::new(), level: None }
}
/// Create pattern evidence from a list of sources.
#[must_use]
pub fn from_sources(sources: Vec<EvidenceSource>) -> Self {
let level = Self::compute_level(&sources);
Self { sources, level }
}
/// Add an evidence source.
pub fn add_source(&mut self, source: EvidenceSource) {
self.sources.push(source);
self.level = Self::compute_level(&self.sources);
}
/// Get the effective evidence level.
///
/// Returns the highest level from all sources, or `Commit` if no sources.
#[must_use]
pub fn effective_level(&self) -> EvidenceLevel {
self.level.unwrap_or(EvidenceLevel::Commit)
}
/// Check if this evidence has any sources.
#[must_use]
pub fn is_empty(&self) -> bool {
self.sources.is_empty()
}
/// Number of evidence sources.
#[must_use]
pub fn source_count(&self) -> usize {
self.sources.len()
}
/// Merge another evidence collection into this one.
pub fn merge(&mut self, other: PatternEvidence) {
for source in other.sources {
if !self.sources.contains(&source) {
self.sources.push(source);
}
}
self.level = Self::compute_level(&self.sources);
}
/// Compute the highest evidence level from sources.
fn compute_level(sources: &[EvidenceSource]) -> Option<EvidenceLevel> {
sources.iter().map(|s| s.level()).max()
}
/// Get authority weight based on evidence level.
#[must_use]
pub fn authority_weight(&self) -> f32 {
self.effective_level().authority_weight()
}
/// Get graduation threshold based on evidence level.
#[must_use]
pub fn graduation_threshold(&self) -> usize {
self.effective_level().graduation_threshold()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_evidence_level_ordering() {
assert!(EvidenceLevel::ProductSpec > EvidenceLevel::Standard);
assert!(EvidenceLevel::Standard > EvidenceLevel::Research);
assert!(EvidenceLevel::Research > EvidenceLevel::Commit);
}
#[test]
fn test_authority_weights() {
assert!((EvidenceLevel::ProductSpec.authority_weight() - 0.95).abs() < 0.001);
assert!((EvidenceLevel::Standard.authority_weight() - 0.85).abs() < 0.001);
assert!((EvidenceLevel::Research.authority_weight() - 0.70).abs() < 0.001);
assert!((EvidenceLevel::Commit.authority_weight() - 0.40).abs() < 0.001);
}
#[test]
fn test_graduation_thresholds() {
assert_eq!(EvidenceLevel::ProductSpec.graduation_threshold(), 1);
assert_eq!(EvidenceLevel::Standard.graduation_threshold(), 3);
assert_eq!(EvidenceLevel::Research.graduation_threshold(), 5);
assert_eq!(EvidenceLevel::Commit.graduation_threshold(), 10);
}
#[test]
fn test_badges() {
assert_eq!(EvidenceLevel::ProductSpec.badge(), "[SPEC]");
assert_eq!(EvidenceLevel::Standard.badge(), "[STANDARD]");
assert_eq!(EvidenceLevel::Research.badge(), "[RESEARCH]");
assert_eq!(EvidenceLevel::Commit.badge(), "[COMMIT]");
}
#[test]
fn test_evidence_source_levels() {
assert_eq!(
EvidenceSource::Rfc { number: 7519, section: None }.level(),
EvidenceLevel::Standard
);
assert_eq!(
EvidenceSource::Owasp { id: "A03".into(), year: Some(2021) }.level(),
EvidenceLevel::Standard
);
assert_eq!(
EvidenceSource::Adr { id: "042".into(), path: None }.level(),
EvidenceLevel::Research
);
assert_eq!(
EvidenceSource::Spec {
path: "specs/api.md".into(),
requirement_id: Some("REQ-001".into())
}
.level(),
EvidenceLevel::ProductSpec
);
assert_eq!(
EvidenceSource::Commit { hash: "abc123".into(), message_excerpt: None }.level(),
EvidenceLevel::Commit
);
}
#[test]
fn test_evidence_source_display() {
assert_eq!(
EvidenceSource::Rfc { number: 7519, section: Some("4.1".into()) }.display(),
"RFC 7519 Section 4.1"
);
assert_eq!(
EvidenceSource::Owasp { id: "A03".into(), year: Some(2021) }.display(),
"OWASP A03:2021"
);
assert_eq!(EvidenceSource::Adr { id: "042".into(), path: None }.display(), "ADR-042");
assert_eq!(
EvidenceSource::Spec {
path: "specs/api.md".into(),
requirement_id: Some("REQ-001".into())
}
.display(),
"specs/api.md#REQ-001"
);
}
#[test]
fn test_pattern_evidence_aggregation() {
let mut evidence = PatternEvidence::new();
assert_eq!(evidence.effective_level(), EvidenceLevel::Commit);
evidence.add_source(EvidenceSource::Commit { hash: "abc".into(), message_excerpt: None });
assert_eq!(evidence.effective_level(), EvidenceLevel::Commit);
evidence.add_source(EvidenceSource::Adr { id: "042".into(), path: None });
assert_eq!(evidence.effective_level(), EvidenceLevel::Research);
evidence.add_source(EvidenceSource::Rfc { number: 7519, section: None });
assert_eq!(evidence.effective_level(), EvidenceLevel::Standard);
evidence.add_source(EvidenceSource::Spec { path: "spec.md".into(), requirement_id: None });
assert_eq!(evidence.effective_level(), EvidenceLevel::ProductSpec);
}
#[test]
fn test_pattern_evidence_from_sources() {
let sources = vec![
EvidenceSource::Commit { hash: "abc".into(), message_excerpt: None },
EvidenceSource::Rfc { number: 7519, section: None },
];
let evidence = PatternEvidence::from_sources(sources);
assert_eq!(evidence.effective_level(), EvidenceLevel::Standard);
assert_eq!(evidence.source_count(), 2);
}
#[test]
fn test_pattern_evidence_merge() {
let mut e1 = PatternEvidence::from_sources(vec![EvidenceSource::Commit {
hash: "abc".into(),
message_excerpt: None,
}]);
let e2 = PatternEvidence::from_sources(vec![EvidenceSource::Rfc {
number: 7519,
section: None,
}]);
e1.merge(e2);
assert_eq!(e1.effective_level(), EvidenceLevel::Standard);
assert_eq!(e1.source_count(), 2);
}
#[test]
fn test_evidence_level_from_str() {
assert_eq!("commit".parse::<EvidenceLevel>().ok(), Some(EvidenceLevel::Commit));
assert_eq!("STANDARD".parse::<EvidenceLevel>().ok(), Some(EvidenceLevel::Standard));
assert_eq!("product_spec".parse::<EvidenceLevel>().ok(), Some(EvidenceLevel::ProductSpec));
assert_eq!("spec".parse::<EvidenceLevel>().ok(), Some(EvidenceLevel::ProductSpec));
assert!("unknown".parse::<EvidenceLevel>().is_err());
}
#[test]
fn test_threshold_multipliers() {
assert!((EvidenceLevel::ProductSpec.threshold_multiplier() - 0.1).abs() < 0.001);
assert!((EvidenceLevel::Standard.threshold_multiplier() - 0.3).abs() < 0.001);
assert!((EvidenceLevel::Research.threshold_multiplier() - 0.5).abs() < 0.001);
assert!((EvidenceLevel::Commit.threshold_multiplier() - 1.0).abs() < 0.001);
}
#[test]
fn test_serialization_roundtrip() {
let evidence = PatternEvidence::from_sources(vec![
EvidenceSource::Rfc { number: 7519, section: Some("4.1".into()) },
EvidenceSource::Owasp { id: "A03".into(), year: Some(2021) },
]);
let json = serde_json::to_string(&evidence).expect("serialize");
let parsed: PatternEvidence = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.source_count(), 2);
assert_eq!(parsed.effective_level(), EvidenceLevel::Standard);
}
}

View File

@ -0,0 +1,487 @@
//! SOC 2 audit trail for governance decisions.
//!
//! Provides append-only logging of all governance events and export
//! capabilities for compliance reporting.
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::store::{governance_store_dir, GovernanceStore};
use super::types::ApprovalRequest;
use crate::AphoriaError;
/// Type of audit event.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditEventType {
/// Approval request created.
RequestCreated,
/// Stage was approved.
StageApproved,
/// Stage was rejected.
StageRejected,
/// Request was escalated.
Escalated,
/// Stage was auto-approved based on evidence.
AutoApproved,
/// Request expired due to timeout.
Expired,
/// Workflow completed (fully approved).
WorkflowCompleted,
}
impl std::fmt::Display for AuditEventType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuditEventType::RequestCreated => write!(f, "Request Created"),
AuditEventType::StageApproved => write!(f, "Stage Approved"),
AuditEventType::StageRejected => write!(f, "Stage Rejected"),
AuditEventType::Escalated => write!(f, "Escalated"),
AuditEventType::AutoApproved => write!(f, "Auto-Approved"),
AuditEventType::Expired => write!(f, "Expired"),
AuditEventType::WorkflowCompleted => write!(f, "Workflow Completed"),
}
}
}
/// An audit event record.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
/// Unique event ID.
pub id: Uuid,
/// Type of event.
pub event_type: AuditEventType,
/// Request this event belongs to.
pub request_id: Uuid,
/// Pattern this event concerns.
pub pattern_id: Uuid,
/// Who performed the action.
pub actor: String,
/// When the event occurred.
pub timestamp: DateTime<Utc>,
/// Additional event details.
pub details: serde_json::Value,
}
impl AuditEvent {
/// Create a new audit event.
pub fn new(
event_type: AuditEventType,
request_id: Uuid,
pattern_id: Uuid,
actor: String,
details: serde_json::Value,
) -> Self {
Self {
id: Uuid::new_v4(),
event_type,
request_id,
pattern_id,
actor,
timestamp: Utc::now(),
details,
}
}
}
/// Export format for audit data.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
/// JSON format.
Json,
/// CSV format.
Csv,
/// Markdown format.
Markdown,
}
impl std::str::FromStr for ExportFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"json" => Ok(ExportFormat::Json),
"csv" => Ok(ExportFormat::Csv),
"markdown" | "md" => Ok(ExportFormat::Markdown),
_ => Err(format!("Unknown format '{}'. Use: json, csv, markdown", s)),
}
}
}
/// Audit trail for governance events.
pub struct AuditTrail {
/// Directory for audit files.
audit_dir: PathBuf,
}
impl AuditTrail {
/// Create a new audit trail.
pub fn new(audit_dir: impl AsRef<Path>) -> Result<Self, AphoriaError> {
let audit_dir = audit_dir.as_ref().to_path_buf();
fs::create_dir_all(&audit_dir).map_err(|e| {
AphoriaError::Storage(format!("Failed to create audit directory: {}", e))
})?;
Ok(Self { audit_dir })
}
/// Open the default audit trail.
pub fn open_default() -> Result<Self, AphoriaError> {
let dir = governance_store_dir().join("audit");
Self::new(dir)
}
/// Path to the audit events file.
fn events_path(&self) -> PathBuf {
self.audit_dir.join("events.jsonl")
}
/// Log an audit event.
pub fn log_event(&self, event: AuditEvent) -> Result<(), AphoriaError> {
let path = self.events_path();
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| AphoriaError::Storage(format!("Failed to open audit file: {}", e)))?;
let json = serde_json::to_string(&event)
.map_err(|e| AphoriaError::Storage(format!("Failed to serialize event: {}", e)))?;
writeln!(file, "{}", json)
.map_err(|e| AphoriaError::Storage(format!("Failed to write event: {}", e)))?;
Ok(())
}
/// Get all audit events.
pub fn get_all_events(&self) -> Result<Vec<AuditEvent>, AphoriaError> {
let path = self.events_path();
if !path.exists() {
return Ok(Vec::new());
}
let file = File::open(&path)
.map_err(|e| AphoriaError::Storage(format!("Failed to open audit file: {}", e)))?;
let reader = BufReader::new(file);
let mut events = Vec::new();
for line in reader.lines() {
let line = line
.map_err(|e| AphoriaError::Storage(format!("Failed to read audit line: {}", e)))?;
if line.trim().is_empty() {
continue;
}
let event: AuditEvent = serde_json::from_str(&line)
.map_err(|e| AphoriaError::Storage(format!("Failed to parse event: {}", e)))?;
events.push(event);
}
Ok(events)
}
/// Get audit timeline for a specific pattern.
pub fn get_pattern_timeline(&self, pattern_id: &Uuid) -> Result<Vec<AuditEvent>, AphoriaError> {
let events = self.get_all_events()?;
Ok(events.into_iter().filter(|e| e.pattern_id == *pattern_id).collect())
}
/// Get audit timeline for a specific request.
pub fn get_request_timeline(&self, request_id: &Uuid) -> Result<Vec<AuditEvent>, AphoriaError> {
let events = self.get_all_events()?;
Ok(events.into_iter().filter(|e| e.request_id == *request_id).collect())
}
/// Export audit data to a file.
pub fn export(&self, format: ExportFormat, output: &Path) -> Result<(), AphoriaError> {
let events = self.get_all_events()?;
// Also get request data for richer export
let store = GovernanceStore::open_default()?;
let requests = store.list_all()?;
let content = match format {
ExportFormat::Json => self.export_json(&events, &requests)?,
ExportFormat::Csv => self.export_csv(&events)?,
ExportFormat::Markdown => self.export_markdown(&events, &requests)?,
};
fs::write(output, content)
.map_err(|e| AphoriaError::Storage(format!("Failed to write export: {}", e)))?;
Ok(())
}
/// Export as JSON.
fn export_json(
&self,
events: &[AuditEvent],
requests: &[ApprovalRequest],
) -> Result<String, AphoriaError> {
let export = serde_json::json!({
"export_timestamp": Utc::now().to_rfc3339(),
"events_count": events.len(),
"requests_count": requests.len(),
"events": events,
"requests": requests,
});
serde_json::to_string_pretty(&export)
.map_err(|e| AphoriaError::Storage(format!("Failed to serialize export: {}", e)))
}
/// Export as CSV.
fn export_csv(&self, events: &[AuditEvent]) -> Result<String, AphoriaError> {
let mut csv = String::new();
csv.push_str("timestamp,event_type,request_id,pattern_id,actor,details\n");
for event in events {
let details_str =
serde_json::to_string(&event.details).unwrap_or_else(|_| "{}".to_string());
csv.push_str(&format!(
"{},{},{},{},{},\"{}\"\n",
event.timestamp.to_rfc3339(),
event.event_type,
event.request_id,
event.pattern_id,
event.actor,
details_str.replace('"', "\"\"")
));
}
Ok(csv)
}
/// Export as Markdown.
fn export_markdown(
&self,
events: &[AuditEvent],
requests: &[ApprovalRequest],
) -> Result<String, AphoriaError> {
let mut md = String::new();
md.push_str("# Governance Audit Report\n\n");
md.push_str(&format!("Generated: {}\n\n", Utc::now().format("%Y-%m-%d %H:%M:%S UTC")));
// Summary
md.push_str("## Summary\n\n");
md.push_str(&format!("- Total Events: {}\n", events.len()));
md.push_str(&format!("- Total Requests: {}\n", 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();
md.push_str(&format!("- Approved: {}\n", approved));
md.push_str(&format!("- Rejected: {}\n", rejected));
md.push_str(&format!("- Pending: {}\n\n", pending));
// Events table
md.push_str("## Audit Events\n\n");
md.push_str("| Timestamp | Type | Request | Pattern | Actor |\n");
md.push_str("|-----------|------|---------|---------|-------|\n");
for event in events {
md.push_str(&format!(
"| {} | {} | {} | {} | {} |\n",
event.timestamp.format("%Y-%m-%d %H:%M"),
event.event_type,
&event.request_id.to_string()[..8],
&event.pattern_id.to_string()[..8],
event.actor,
));
}
md.push('\n');
// Request details
md.push_str("## Request Details\n\n");
for request in requests {
md.push_str(&format!("### {} ({})\n\n", request.pattern_name, request.id));
md.push_str(&format!("- **Workflow**: {}\n", request.workflow_name));
md.push_str(&format!("- **Status**: {}\n", request.status));
md.push_str(&format!(
"- **Created**: {} by {}\n",
request.created_at.format("%Y-%m-%d %H:%M"),
request.created_by
));
md.push_str(&format!(
"- **Updated**: {}\n\n",
request.updated_at.format("%Y-%m-%d %H:%M")
));
if !request.decisions.is_empty() {
md.push_str("**Decisions:**\n\n");
for decision in &request.decisions {
let comment = decision.comment.as_deref().unwrap_or("-");
md.push_str(&format!(
"- {} at {}: {} by {} ({})\n",
decision.timestamp.format("%Y-%m-%d %H:%M"),
decision.stage,
decision.decision,
decision.approver,
comment
));
}
md.push('\n');
}
}
Ok(md)
}
/// Export events within a date range.
pub fn export_date_range(
&self,
format: ExportFormat,
output: &Path,
start: DateTime<Utc>,
end: DateTime<Utc>,
) -> Result<(), AphoriaError> {
let events: Vec<AuditEvent> = self
.get_all_events()?
.into_iter()
.filter(|e| e.timestamp >= start && e.timestamp <= end)
.collect();
let store = GovernanceStore::open_default()?;
let requests: Vec<ApprovalRequest> = store
.list_all()?
.into_iter()
.filter(|r| r.created_at >= start && r.created_at <= end)
.collect();
let content = match format {
ExportFormat::Json => self.export_json(&events, &requests)?,
ExportFormat::Csv => self.export_csv(&events)?,
ExportFormat::Markdown => self.export_markdown(&events, &requests)?,
};
fs::write(output, content)
.map_err(|e| AphoriaError::Storage(format!("Failed to write export: {}", e)))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_audit_event_creation() {
let event = AuditEvent::new(
AuditEventType::StageApproved,
Uuid::new_v4(),
Uuid::new_v4(),
"alice".to_string(),
serde_json::json!({"stage": "security_review"}),
);
assert_eq!(event.event_type, AuditEventType::StageApproved);
assert_eq!(event.actor, "alice");
}
#[test]
fn test_log_and_retrieve_events() {
let temp = TempDir::new().expect("temp dir");
let trail = AuditTrail::new(temp.path()).expect("create trail");
let event = AuditEvent::new(
AuditEventType::RequestCreated,
Uuid::new_v4(),
Uuid::new_v4(),
"test_user".to_string(),
serde_json::json!({}),
);
trail.log_event(event.clone()).expect("log");
let events = trail.get_all_events().expect("get all");
assert_eq!(events.len(), 1);
assert_eq!(events[0].id, event.id);
}
#[test]
fn test_pattern_timeline() {
let temp = TempDir::new().expect("temp dir");
let trail = AuditTrail::new(temp.path()).expect("create trail");
let pattern_id = Uuid::new_v4();
let other_pattern = Uuid::new_v4();
// Log events for pattern
trail
.log_event(AuditEvent::new(
AuditEventType::RequestCreated,
Uuid::new_v4(),
pattern_id,
"user".to_string(),
serde_json::json!({}),
))
.expect("log 1");
trail
.log_event(AuditEvent::new(
AuditEventType::StageApproved,
Uuid::new_v4(),
pattern_id,
"user".to_string(),
serde_json::json!({}),
))
.expect("log 2");
// Log event for other pattern
trail
.log_event(AuditEvent::new(
AuditEventType::RequestCreated,
Uuid::new_v4(),
other_pattern,
"user".to_string(),
serde_json::json!({}),
))
.expect("log 3");
let timeline = trail.get_pattern_timeline(&pattern_id).expect("get timeline");
assert_eq!(timeline.len(), 2);
}
#[test]
fn test_export_format_parsing() {
assert_eq!("json".parse::<ExportFormat>().ok(), Some(ExportFormat::Json));
assert_eq!("CSV".parse::<ExportFormat>().ok(), Some(ExportFormat::Csv));
assert_eq!("markdown".parse::<ExportFormat>().ok(), Some(ExportFormat::Markdown));
assert_eq!("md".parse::<ExportFormat>().ok(), Some(ExportFormat::Markdown));
assert!("unknown".parse::<ExportFormat>().is_err());
}
#[test]
fn test_export_csv() {
let temp = TempDir::new().expect("temp dir");
let trail = AuditTrail::new(temp.path()).expect("create trail");
trail
.log_event(AuditEvent::new(
AuditEventType::RequestCreated,
Uuid::new_v4(),
Uuid::new_v4(),
"user".to_string(),
serde_json::json!({"test": "value"}),
))
.expect("log");
let events = trail.get_all_events().expect("get");
let csv = trail.export_csv(&events).expect("export csv");
assert!(csv.contains("timestamp,event_type,request_id"));
assert!(csv.contains("Request Created"));
}
}

View File

@ -0,0 +1,81 @@
//! Governance workflows for pattern promotion.
//!
//! Implements structured approval workflows with full audit trails for SOC 2 compliance.
//!
//! # Overview
//!
//! When governance is enabled, pattern promotion requires going through an approval workflow
//! with defined stages. Each stage can have required approvers, timeouts, and escalation paths.
//!
//! # Workflow
//!
//! ```text
//! Pattern promotion candidate detected
//! ↓
//! Approval request created
//! ↓
//! ┌───────────────────────────┐
//! │ Stage: security_review │
//! │ Required: security-team │
//! │ Timeout: 48 hours │
//! └───────────────────────────┘
//! ↓ approve
//! ┌───────────────────────────┐
//! │ Stage: architecture_review│
//! │ Required: arch-team │
//! │ Timeout: 72 hours │
//! └───────────────────────────┘
//! ↓ approve
//! Pattern promoted
//! ```
//!
//! # State Machine
//!
//! ```text
//! Created ──▶ Pending(stage_0)
//! │
//! ┌───────┴───────┐
//! │ │
//! approve reject
//! │ │
//! ▼ ▼
//! Pending(stage_n+1) Rejected
//! │
//! ├── (last stage) ──▶ Approved
//! │
//! └── (timeout) ──▶ Escalated ──▶ Pending(escalate_to)
//! │
//! └── (no escalate_to) ──▶ Expired
//! ```
//!
//! # Example Configuration
//!
//! ```toml
//! [governance]
//! enabled = true
//! default_workflow = "standard_review"
//!
//! [[governance.workflows]]
//! name = "standard_review"
//! description = "Standard pattern review for production promotion"
//!
//! [[governance.workflows.stages]]
//! name = "security_review"
//! label = "Security Review"
//! required_approvers = ["security-team"]
//! timeout_hours = 48
//! ```
mod audit;
mod state_machine;
mod store;
mod types;
pub mod workflow;
pub use audit::{AuditEvent, AuditEventType, AuditTrail, ExportFormat};
pub use state_machine::GovernanceStateMachine;
pub use store::{governance_store_dir, GovernanceStore};
pub use types::{ApprovalDecision, ApprovalRequest, ApprovalStatus, Decision};
pub use workflow::{
fast_track_workflow, standard_review_workflow, ApprovalStage, ApprovalWorkflow,
};

View File

@ -0,0 +1,667 @@
//! State machine for approval workflow transitions.
//!
//! Handles the state transitions for approval requests including
//! approve, reject, escalate, and timeout handling.
use chrono::{Duration, Utc};
use tracing::{debug, info, warn};
use uuid::Uuid;
use super::audit::{AuditEvent, AuditEventType, AuditTrail};
use super::store::GovernanceStore;
use super::types::{ApprovalDecision, ApprovalRequest, ApprovalStatus, Decision};
use super::workflow::ApprovalWorkflow;
use crate::config::GovernanceConfig;
use crate::evidence::EvidenceLevel;
use crate::learning::LearnedPattern;
use crate::AphoriaError;
/// State machine for governance approval workflows.
pub struct GovernanceStateMachine {
store: GovernanceStore,
/// Configuration for governance workflows.
pub config: GovernanceConfig,
}
impl GovernanceStateMachine {
/// Create a new state machine with the given store and config.
pub fn new(store: GovernanceStore, config: GovernanceConfig) -> Self {
Self { store, config }
}
/// Create a state machine with the default store.
pub fn open_default(config: GovernanceConfig) -> Result<Self, AphoriaError> {
let store = GovernanceStore::open_default()?;
Ok(Self::new(store, config))
}
/// Get the appropriate workflow for a pattern.
pub fn get_workflow_for_pattern(&self, pattern: &LearnedPattern) -> Option<ApprovalWorkflow> {
let evidence_level = pattern.evidence.effective_level();
// Find a matching workflow based on evidence level
for workflow in &self.config.workflows {
if workflow.applies_to_evidence(evidence_level) {
return Some(workflow.clone());
}
}
// Fall back to default workflow
self.config
.default_workflow
.as_ref()
.and_then(|name| self.config.workflows.iter().find(|w| w.name == *name).cloned())
}
/// Create a new approval request for a pattern.
pub fn create_request(
&self,
pattern: &LearnedPattern,
workflow: &ApprovalWorkflow,
created_by: &str,
) -> Result<ApprovalRequest, AphoriaError> {
// Get first stage
let first_stage = workflow
.first_stage()
.ok_or_else(|| AphoriaError::Governance("Workflow has no stages".to_string()))?;
// Create the request
let mut request = ApprovalRequest::new(
pattern.id,
&pattern.claim_template.predicate,
&workflow.name,
&first_stage.name,
created_by,
);
// Set evidence summary
let evidence_summary = format!(
"Evidence level: {} ({} sources)",
pattern.evidence.effective_level(),
pattern.evidence.source_count()
);
request = request.with_evidence_summary(evidence_summary);
// Set deadline if stage has timeout
if let Some(hours) = first_stage.timeout_hours {
let deadline = Utc::now() + Duration::hours(i64::from(hours));
request = request.with_deadline(deadline);
}
// Check for auto-approval at first stage
if first_stage.should_auto_approve(pattern.evidence.effective_level()) {
debug!(
pattern_id = %pattern.id,
stage = %first_stage.name,
"Auto-approving first stage based on evidence level"
);
// Add auto-approval decision
let decision = ApprovalDecision::new(
request.id,
&first_stage.name,
Decision::Approved,
"system",
Some("Auto-approved based on evidence level".to_string()),
);
request.add_decision(decision.clone());
self.store.log_decision(&decision)?;
// Advance or complete
self.advance_after_approval(
&mut request,
workflow,
pattern.evidence.effective_level(),
)?;
}
// Save the request
self.store.save_request(&request)?;
// Log audit event
if let Ok(audit) = AuditTrail::open_default() {
let _ = audit.log_event(AuditEvent::new(
AuditEventType::RequestCreated,
request.id,
pattern.id,
created_by.to_string(),
serde_json::json!({
"workflow": workflow.name,
"first_stage": first_stage.name,
}),
));
}
info!(
request_id = %request.id,
pattern_id = %pattern.id,
workflow = %workflow.name,
"Created approval request"
);
Ok(request)
}
/// Approve the current stage of a request.
pub fn approve(
&self,
request_id: Uuid,
approver: &str,
comment: Option<String>,
) -> Result<ApprovalRequest, AphoriaError> {
let mut request = self
.store
.get_request(&request_id)?
.ok_or_else(|| AphoriaError::Governance(format!("Request {} not found", request_id)))?;
// Check if request is pending
let stage_name = match &request.status {
ApprovalStatus::Pending { stage } => stage.clone(),
status => {
return Err(AphoriaError::Governance(format!(
"Request is not pending: {}",
status
)));
}
};
// Get the workflow
let workflow = self
.config
.workflows
.iter()
.find(|w| w.name == request.workflow_name)
.ok_or_else(|| {
AphoriaError::Governance(format!("Workflow '{}' not found", request.workflow_name))
})?;
// Get the current stage
let (_, stage) = workflow
.get_stage_by_name(&stage_name)
.ok_or_else(|| AphoriaError::Governance(format!("Stage '{}' not found", stage_name)))?;
// Check if approver is authorized
if !stage.is_authorized(approver) {
return Err(AphoriaError::Governance(format!(
"User '{}' is not authorized to approve stage '{}'",
approver, stage_name
)));
}
// Create and record the decision
let decision =
ApprovalDecision::new(request_id, &stage_name, Decision::Approved, approver, comment);
request.add_decision(decision.clone());
self.store.log_decision(&decision)?;
// Check if we have enough approvals
let approval_count = request.current_stage_approval_count();
if approval_count >= stage.min_approvals {
debug!(
request_id = %request_id,
stage = %stage_name,
approvals = approval_count,
required = stage.min_approvals,
"Stage approval threshold reached"
);
// Determine evidence level for auto-approve checks
let evidence_level = EvidenceLevel::default(); // Could be passed in
self.advance_after_approval(&mut request, workflow, evidence_level)?;
}
// Save the updated request
self.store.save_request(&request)?;
// Log audit event
if let Ok(audit) = AuditTrail::open_default() {
let _ = audit.log_event(AuditEvent::new(
AuditEventType::StageApproved,
request_id,
request.pattern_id,
approver.to_string(),
serde_json::json!({
"stage": stage_name,
"approval_count": approval_count,
}),
));
}
info!(
request_id = %request_id,
stage = %stage_name,
approver = %approver,
"Stage approved"
);
Ok(request)
}
/// Advance the request after stage approval.
fn advance_after_approval(
&self,
request: &mut ApprovalRequest,
workflow: &ApprovalWorkflow,
evidence_level: EvidenceLevel,
) -> Result<(), AphoriaError> {
let current_index = request.current_stage_index;
if workflow.is_last_stage(current_index) {
// Final stage - mark as approved
request.mark_approved();
info!(request_id = %request.id, "Request fully approved");
} else {
// Advance to next stage
if let Some(next_stage) = workflow.next_stage(current_index) {
request.advance_to_stage(&next_stage.name);
// Set deadline for next stage
if let Some(hours) = next_stage.timeout_hours {
let deadline = Utc::now() + Duration::hours(i64::from(hours));
request.stage_deadline = Some(deadline);
}
// Check for auto-approval at next stage
if next_stage.should_auto_approve(evidence_level) {
debug!(
request_id = %request.id,
stage = %next_stage.name,
"Auto-approving stage based on evidence level"
);
let decision = ApprovalDecision::new(
request.id,
&next_stage.name,
Decision::Approved,
"system",
Some("Auto-approved based on evidence level".to_string()),
);
request.add_decision(decision.clone());
self.store.log_decision(&decision)?;
// Recurse to handle next stage
request.current_stage_index += 1;
self.advance_after_approval(request, workflow, evidence_level)?;
}
}
}
Ok(())
}
/// Reject a request at the current stage.
pub fn reject(
&self,
request_id: Uuid,
approver: &str,
reason: String,
) -> Result<ApprovalRequest, AphoriaError> {
let mut request = self
.store
.get_request(&request_id)?
.ok_or_else(|| AphoriaError::Governance(format!("Request {} not found", request_id)))?;
let stage_name = match &request.status {
ApprovalStatus::Pending { stage } => stage.clone(),
status => {
return Err(AphoriaError::Governance(format!(
"Request is not pending: {}",
status
)));
}
};
// Create and record the decision
let decision = ApprovalDecision::new(
request_id,
&stage_name,
Decision::Rejected,
approver,
Some(reason.clone()),
);
request.add_decision(decision.clone());
self.store.log_decision(&decision)?;
// Mark as rejected
request.mark_rejected(&stage_name, &reason);
self.store.save_request(&request)?;
// Log audit event
if let Ok(audit) = AuditTrail::open_default() {
let _ = audit.log_event(AuditEvent::new(
AuditEventType::StageRejected,
request_id,
request.pattern_id,
approver.to_string(),
serde_json::json!({
"stage": stage_name,
"reason": reason,
}),
));
}
info!(
request_id = %request_id,
stage = %stage_name,
approver = %approver,
reason = %reason,
"Request rejected"
);
Ok(request)
}
/// Escalate a request to the next stage.
pub fn escalate(
&self,
request_id: Uuid,
escalator: &str,
) -> Result<ApprovalRequest, AphoriaError> {
let mut request = self
.store
.get_request(&request_id)?
.ok_or_else(|| AphoriaError::Governance(format!("Request {} not found", request_id)))?;
let stage_name = match &request.status {
ApprovalStatus::Pending { stage } => stage.clone(),
status => {
return Err(AphoriaError::Governance(format!(
"Request is not pending: {}",
status
)));
}
};
// Get the workflow and current stage
let workflow = self
.config
.workflows
.iter()
.find(|w| w.name == request.workflow_name)
.ok_or_else(|| {
AphoriaError::Governance(format!("Workflow '{}' not found", request.workflow_name))
})?;
let (_stage_index, stage) = workflow
.get_stage_by_name(&stage_name)
.ok_or_else(|| AphoriaError::Governance(format!("Stage '{}' not found", stage_name)))?;
// Check if escalation is configured
let escalate_to = stage.escalate_to.as_ref().ok_or_else(|| {
AphoriaError::Governance(format!("Stage '{}' has no escalation target", stage_name))
})?;
// Get the escalation target stage
let (target_index, target_stage) =
workflow.get_stage_by_name(escalate_to).ok_or_else(|| {
AphoriaError::Governance(format!("Escalation target '{}' not found", escalate_to))
})?;
// Perform escalation
request.mark_escalated(&stage_name, escalate_to);
request.current_stage_index = target_index;
// Set deadline for target stage
if let Some(hours) = target_stage.timeout_hours {
let deadline = Utc::now() + Duration::hours(i64::from(hours));
request.stage_deadline = Some(deadline);
}
self.store.save_request(&request)?;
// Log audit event
if let Ok(audit) = AuditTrail::open_default() {
let _ = audit.log_event(AuditEvent::new(
AuditEventType::Escalated,
request_id,
request.pattern_id,
escalator.to_string(),
serde_json::json!({
"from_stage": stage_name,
"to_stage": escalate_to,
}),
));
}
info!(
request_id = %request_id,
from = %stage_name,
to = %escalate_to,
"Request escalated"
);
Ok(request)
}
/// Check for timed-out requests and handle them.
///
/// Returns list of requests that were processed.
pub fn check_timeouts(&self) -> Result<Vec<ApprovalRequest>, AphoriaError> {
let expired = self.store.get_expired_requests()?;
let mut processed = Vec::new();
for mut request in expired {
let stage_name = match &request.status {
ApprovalStatus::Pending { stage } => stage.clone(),
_ => continue,
};
// Get the workflow and stage
let workflow =
match self.config.workflows.iter().find(|w| w.name == request.workflow_name) {
Some(w) => w,
None => {
warn!(
request_id = %request.id,
workflow = %request.workflow_name,
"Workflow not found for expired request"
);
continue;
}
};
let stage = match workflow.get_stage_by_name(&stage_name) {
Some((_, s)) => s,
None => {
warn!(
request_id = %request.id,
stage = %stage_name,
"Stage not found for expired request"
);
continue;
}
};
// Handle timeout: escalate or expire
if let Some(ref escalate_to) = stage.escalate_to {
// Escalate to next stage
if let Some((target_index, target_stage)) = workflow.get_stage_by_name(escalate_to)
{
request.mark_escalated(&stage_name, escalate_to);
request.current_stage_index = target_index;
if let Some(hours) = target_stage.timeout_hours {
let deadline = Utc::now() + Duration::hours(i64::from(hours));
request.stage_deadline = Some(deadline);
}
info!(
request_id = %request.id,
from = %stage_name,
to = %escalate_to,
"Request escalated due to timeout"
);
}
} else {
// No escalation target - expire the request
request.mark_expired();
info!(
request_id = %request.id,
stage = %stage_name,
"Request expired due to timeout"
);
// Log audit event
if let Ok(audit) = AuditTrail::open_default() {
let _ = audit.log_event(AuditEvent::new(
AuditEventType::Expired,
request.id,
request.pattern_id,
"system".to_string(),
serde_json::json!({
"stage": stage_name,
}),
));
}
}
self.store.save_request(&request)?;
processed.push(request);
}
Ok(processed)
}
/// Get a request by ID.
pub fn get_request(&self, id: &Uuid) -> Result<Option<ApprovalRequest>, AphoriaError> {
self.store.get_request(id)
}
/// Get a request by pattern ID.
pub fn get_request_by_pattern(
&self,
pattern_id: &Uuid,
) -> Result<Option<ApprovalRequest>, AphoriaError> {
self.store.get_request_by_pattern(pattern_id)
}
/// List all pending requests.
pub fn list_pending(&self) -> Result<Vec<ApprovalRequest>, AphoriaError> {
self.store.list_pending()
}
/// List all requests.
pub fn list_all(&self) -> Result<Vec<ApprovalRequest>, AphoriaError> {
self.store.list_all()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::GovernanceConfig;
use crate::governance::workflow::{ApprovalStage, ApprovalWorkflow};
use crate::learning::{ClaimTemplate, LearnedPattern, ValueType};
use crate::types::Language;
use tempfile::TempDir;
fn create_test_config() -> GovernanceConfig {
GovernanceConfig {
enabled: true,
governance_dir: None,
default_workflow: Some("test_workflow".to_string()),
workflows: vec![ApprovalWorkflow::new("test_workflow", "Test workflow")
.add_stage(ApprovalStage::new("stage1", "Stage 1"))
.add_stage(ApprovalStage::new("stage2", "Stage 2"))],
check_timeouts_on_access: false,
}
}
fn create_test_pattern() -> LearnedPattern {
LearnedPattern::new(
"test code",
"test pattern",
ClaimTemplate::new("test/subject", "predicate", ValueType::Text, "description"),
Language::Rust,
"project_hash",
0.9,
)
}
fn create_test_state_machine(temp: &TempDir) -> GovernanceStateMachine {
let store = GovernanceStore::new(temp.path()).expect("create store");
let config = create_test_config();
GovernanceStateMachine::new(store, config)
}
#[test]
fn test_create_request() {
let temp = TempDir::new().expect("temp dir");
let sm = create_test_state_machine(&temp);
let pattern = create_test_pattern();
let workflow = sm.config.workflows.first().unwrap();
let request = sm.create_request(&pattern, workflow, "test_user").expect("create");
assert_eq!(request.pattern_id, pattern.id);
assert_eq!(request.workflow_name, "test_workflow");
assert!(request.status.is_pending());
assert_eq!(request.status.current_stage(), Some("stage1"));
}
#[test]
fn test_approve_stage() {
let temp = TempDir::new().expect("temp dir");
let sm = create_test_state_machine(&temp);
let pattern = create_test_pattern();
let workflow = sm.config.workflows.first().unwrap();
let request = sm.create_request(&pattern, workflow, "test_user").expect("create");
let updated =
sm.approve(request.id, "approver", Some("LGTM".to_string())).expect("approve");
// Should advance to stage2
assert!(updated.status.is_pending());
assert_eq!(updated.status.current_stage(), Some("stage2"));
assert_eq!(updated.decisions.len(), 1);
}
#[test]
fn test_full_approval_workflow() {
let temp = TempDir::new().expect("temp dir");
let sm = create_test_state_machine(&temp);
let pattern = create_test_pattern();
let workflow = sm.config.workflows.first().unwrap();
let request = sm.create_request(&pattern, workflow, "test_user").expect("create");
// Approve stage 1
let updated = sm.approve(request.id, "approver1", None).expect("approve 1");
assert_eq!(updated.status.current_stage(), Some("stage2"));
// Approve stage 2
let final_req = sm.approve(request.id, "approver2", None).expect("approve 2");
assert!(final_req.status.is_approved());
}
#[test]
fn test_reject_request() {
let temp = TempDir::new().expect("temp dir");
let sm = create_test_state_machine(&temp);
let pattern = create_test_pattern();
let workflow = sm.config.workflows.first().unwrap();
let request = sm.create_request(&pattern, workflow, "test_user").expect("create");
let rejected = sm.reject(request.id, "reviewer", "Too broad".to_string()).expect("reject");
assert!(rejected.status.is_rejected());
if let ApprovalStatus::Rejected { stage, reason } = &rejected.status {
assert_eq!(stage, "stage1");
assert_eq!(reason, "Too broad");
}
}
#[test]
fn test_list_pending() {
let temp = TempDir::new().expect("temp dir");
let sm = create_test_state_machine(&temp);
let pattern = create_test_pattern();
let workflow = sm.config.workflows.first().unwrap();
sm.create_request(&pattern, workflow, "test_user").expect("create");
let pending = sm.list_pending().expect("list");
assert_eq!(pending.len(), 1);
}
}

View File

@ -0,0 +1,398 @@
//! Governance storage for approval requests and decisions.
//!
//! Uses JSONL (JSON Lines) format for append-only audit logging
//! of approval requests and decisions.
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use uuid::Uuid;
use super::types::{ApprovalDecision, ApprovalRequest, ApprovalStatus};
use crate::AphoriaError;
/// Get the default governance store directory.
pub fn governance_store_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".aphoria").join("governance")
} else {
PathBuf::from(".aphoria/governance")
}
}
/// Storage for governance requests and decisions.
pub struct GovernanceStore {
/// Base directory for governance data.
base_dir: PathBuf,
}
impl GovernanceStore {
/// Create a new governance store.
pub fn new(base_dir: impl AsRef<Path>) -> Result<Self, AphoriaError> {
let base_dir = base_dir.as_ref().to_path_buf();
fs::create_dir_all(&base_dir).map_err(|e| {
AphoriaError::Storage(format!("Failed to create governance directory: {}", e))
})?;
Ok(Self { base_dir })
}
/// Open the default governance store.
pub fn open_default() -> Result<Self, AphoriaError> {
Self::new(governance_store_dir())
}
/// Path to the requests JSONL file.
fn requests_path(&self) -> PathBuf {
self.base_dir.join("requests.jsonl")
}
/// Path to the decisions JSONL file.
fn decisions_path(&self) -> PathBuf {
self.base_dir.join("decisions.jsonl")
}
/// Save an approval request.
///
/// For new requests, appends to the file. For updates, rewrites the file
/// with the updated request.
pub fn save_request(&self, request: &ApprovalRequest) -> Result<(), AphoriaError> {
// Read all existing requests
let mut requests = self.list_all()?;
// Find and update existing, or add new
let mut found = false;
for existing in &mut requests {
if existing.id == request.id {
*existing = request.clone();
found = true;
break;
}
}
if !found {
requests.push(request.clone());
}
// Rewrite the file
self.write_requests(&requests)
}
/// Write all requests to file.
fn write_requests(&self, requests: &[ApprovalRequest]) -> Result<(), AphoriaError> {
let path = self.requests_path();
let mut file = File::create(&path)
.map_err(|e| AphoriaError::Storage(format!("Failed to create requests file: {}", e)))?;
for request in requests {
let json = serde_json::to_string(request).map_err(|e| {
AphoriaError::Storage(format!("Failed to serialize request: {}", e))
})?;
writeln!(file, "{}", json)
.map_err(|e| AphoriaError::Storage(format!("Failed to write request: {}", e)))?;
}
Ok(())
}
/// Get a request by ID.
pub fn get_request(&self, id: &Uuid) -> Result<Option<ApprovalRequest>, AphoriaError> {
let requests = self.list_all()?;
Ok(requests.into_iter().find(|r| r.id == *id))
}
/// Get a request by pattern ID.
pub fn get_request_by_pattern(
&self,
pattern_id: &Uuid,
) -> Result<Option<ApprovalRequest>, AphoriaError> {
let requests = self.list_all()?;
// Return the most recent request for this pattern
Ok(requests.into_iter().filter(|r| r.pattern_id == *pattern_id).next_back())
}
/// List all pending requests.
pub fn list_pending(&self) -> Result<Vec<ApprovalRequest>, AphoriaError> {
let requests = self.list_all()?;
Ok(requests.into_iter().filter(|r| r.status.is_pending()).collect())
}
/// List all requests.
pub fn list_all(&self) -> Result<Vec<ApprovalRequest>, AphoriaError> {
let path = self.requests_path();
if !path.exists() {
return Ok(Vec::new());
}
let file = File::open(&path)
.map_err(|e| AphoriaError::Storage(format!("Failed to open requests file: {}", e)))?;
let reader = BufReader::new(file);
let mut requests = Vec::new();
for line in reader.lines() {
let line = line.map_err(|e| {
AphoriaError::Storage(format!("Failed to read requests line: {}", e))
})?;
if line.trim().is_empty() {
continue;
}
let request: ApprovalRequest = serde_json::from_str(&line)
.map_err(|e| AphoriaError::Storage(format!("Failed to parse request: {}", e)))?;
requests.push(request);
}
Ok(requests)
}
/// Log a decision (append-only audit log).
pub fn log_decision(&self, decision: &ApprovalDecision) -> Result<(), AphoriaError> {
let path = self.decisions_path();
let mut file =
OpenOptions::new().create(true).append(true).open(&path).map_err(|e| {
AphoriaError::Storage(format!("Failed to open decisions file: {}", e))
})?;
let json = serde_json::to_string(decision)
.map_err(|e| AphoriaError::Storage(format!("Failed to serialize decision: {}", e)))?;
writeln!(file, "{}", json)
.map_err(|e| AphoriaError::Storage(format!("Failed to write decision: {}", e)))?;
Ok(())
}
/// Get all decisions for a request.
pub fn get_decisions(&self, request_id: &Uuid) -> Result<Vec<ApprovalDecision>, AphoriaError> {
let path = self.decisions_path();
if !path.exists() {
return Ok(Vec::new());
}
let file = File::open(&path)
.map_err(|e| AphoriaError::Storage(format!("Failed to open decisions file: {}", e)))?;
let reader = BufReader::new(file);
let mut decisions = Vec::new();
for line in reader.lines() {
let line = line.map_err(|e| {
AphoriaError::Storage(format!("Failed to read decisions line: {}", e))
})?;
if line.trim().is_empty() {
continue;
}
let decision: ApprovalDecision = serde_json::from_str(&line)
.map_err(|e| AphoriaError::Storage(format!("Failed to parse decision: {}", e)))?;
if decision.request_id == *request_id {
decisions.push(decision);
}
}
Ok(decisions)
}
/// Get all decisions (for export).
pub fn get_all_decisions(&self) -> Result<Vec<ApprovalDecision>, AphoriaError> {
let path = self.decisions_path();
if !path.exists() {
return Ok(Vec::new());
}
let file = File::open(&path)
.map_err(|e| AphoriaError::Storage(format!("Failed to open decisions file: {}", e)))?;
let reader = BufReader::new(file);
let mut decisions = Vec::new();
for line in reader.lines() {
let line = line.map_err(|e| {
AphoriaError::Storage(format!("Failed to read decisions line: {}", e))
})?;
if line.trim().is_empty() {
continue;
}
let decision: ApprovalDecision = serde_json::from_str(&line)
.map_err(|e| AphoriaError::Storage(format!("Failed to parse decision: {}", e)))?;
decisions.push(decision);
}
Ok(decisions)
}
/// Delete a request (for cleanup).
pub fn delete_request(&self, id: &Uuid) -> Result<bool, AphoriaError> {
let mut requests = self.list_all()?;
let len_before = requests.len();
requests.retain(|r| r.id != *id);
if requests.len() < len_before {
self.write_requests(&requests)?;
Ok(true)
} else {
Ok(false)
}
}
/// Get requests past their stage deadline.
pub fn get_expired_requests(&self) -> Result<Vec<ApprovalRequest>, AphoriaError> {
let requests = self.list_pending()?;
Ok(requests.into_iter().filter(|r| r.is_past_deadline()).collect())
}
/// Get count of pending requests by workflow.
pub fn pending_by_workflow(&self) -> Result<Vec<(String, usize)>, AphoriaError> {
let pending = self.list_pending()?;
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for request in pending {
*counts.entry(request.workflow_name.clone()).or_insert(0) += 1;
}
let mut result: Vec<_> = counts.into_iter().collect();
result.sort_by(|a, b| b.1.cmp(&a.1));
Ok(result)
}
/// Get requests by status.
pub fn get_by_status(&self, status_filter: &str) -> Result<Vec<ApprovalRequest>, AphoriaError> {
let requests = self.list_all()?;
Ok(requests
.into_iter()
.filter(|r| match status_filter.to_lowercase().as_str() {
"pending" => r.status.is_pending(),
"approved" => matches!(r.status, ApprovalStatus::Approved),
"rejected" => matches!(r.status, ApprovalStatus::Rejected { .. }),
"expired" => matches!(r.status, ApprovalStatus::Expired),
_ => false,
})
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_store() -> (GovernanceStore, TempDir) {
let temp = TempDir::new().expect("temp dir");
let store = GovernanceStore::new(temp.path()).expect("create store");
(store, temp)
}
#[test]
fn test_store_creation() {
let (store, _temp) = create_test_store();
assert!(store.base_dir.exists());
}
#[test]
fn test_save_and_get_request() {
let (store, _temp) = create_test_store();
let request =
ApprovalRequest::new(Uuid::new_v4(), "test_pattern", "workflow", "stage1", "user");
store.save_request(&request).expect("save");
let loaded = store.get_request(&request.id).expect("get");
assert!(loaded.is_some());
let loaded = loaded.unwrap();
assert_eq!(loaded.id, request.id);
assert_eq!(loaded.pattern_name, "test_pattern");
}
#[test]
fn test_list_pending() {
let (store, _temp) = create_test_store();
// Create pending request
let pending =
ApprovalRequest::new(Uuid::new_v4(), "pending_pattern", "workflow", "stage1", "user");
store.save_request(&pending).expect("save pending");
// Create approved request
let mut approved =
ApprovalRequest::new(Uuid::new_v4(), "approved_pattern", "workflow", "stage1", "user");
approved.mark_approved();
store.save_request(&approved).expect("save approved");
let pending_list = store.list_pending().expect("list pending");
assert_eq!(pending_list.len(), 1);
assert_eq!(pending_list[0].pattern_name, "pending_pattern");
}
#[test]
fn test_log_and_get_decisions() {
let (store, _temp) = create_test_store();
let request_id = Uuid::new_v4();
let decision = ApprovalDecision::new(
request_id,
"security_review",
super::super::types::Decision::Approved,
"alice",
Some("LGTM".to_string()),
);
store.log_decision(&decision).expect("log");
let decisions = store.get_decisions(&request_id).expect("get");
assert_eq!(decisions.len(), 1);
assert_eq!(decisions[0].approver, "alice");
}
#[test]
fn test_update_request() {
let (store, _temp) = create_test_store();
let mut request =
ApprovalRequest::new(Uuid::new_v4(), "test_pattern", "workflow", "stage1", "user");
store.save_request(&request).expect("save initial");
// Update the request
request.advance_to_stage("stage2");
store.save_request(&request).expect("save updated");
// Verify update
let loaded = store.get_request(&request.id).expect("get").unwrap();
assert_eq!(loaded.current_stage_index, 1);
assert_eq!(loaded.status.current_stage(), Some("stage2"));
// Verify only one request exists
let all = store.list_all().expect("list all");
assert_eq!(all.len(), 1);
}
#[test]
fn test_get_request_by_pattern() {
let (store, _temp) = create_test_store();
let pattern_id = Uuid::new_v4();
let request =
ApprovalRequest::new(pattern_id, "test_pattern", "workflow", "stage1", "user");
store.save_request(&request).expect("save");
let loaded = store.get_request_by_pattern(&pattern_id).expect("get").unwrap();
assert_eq!(loaded.pattern_id, pattern_id);
}
#[test]
fn test_delete_request() {
let (store, _temp) = create_test_store();
let request =
ApprovalRequest::new(Uuid::new_v4(), "test_pattern", "workflow", "stage1", "user");
store.save_request(&request).expect("save");
assert!(store.delete_request(&request.id).expect("delete"));
assert!(store.get_request(&request.id).expect("get").is_none());
}
}

View File

@ -0,0 +1,392 @@
//! Core types for governance workflows.
//!
//! These types represent approval requests, decisions, and status tracking
//! for pattern promotion governance.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Status of an approval request.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum ApprovalStatus {
/// Request is pending at a specific stage.
Pending {
/// Current stage name.
stage: String,
},
/// Request has been fully approved.
Approved,
/// Request was rejected at a specific stage.
Rejected {
/// Stage where rejection occurred.
stage: String,
/// Reason for rejection.
reason: String,
},
/// Request was escalated from one stage to another.
Escalated {
/// Stage escalated from.
from_stage: String,
/// Stage escalated to.
to_stage: String,
},
/// Request expired due to timeout.
Expired,
}
impl ApprovalStatus {
/// Returns true if the request is still pending.
pub fn is_pending(&self) -> bool {
matches!(self, ApprovalStatus::Pending { .. })
}
/// Returns true if the request is approved.
pub fn is_approved(&self) -> bool {
matches!(self, ApprovalStatus::Approved)
}
/// Returns true if the request is rejected.
pub fn is_rejected(&self) -> bool {
matches!(self, ApprovalStatus::Rejected { .. })
}
/// Returns true if the request is terminal (approved, rejected, or expired).
pub fn is_terminal(&self) -> bool {
matches!(
self,
ApprovalStatus::Approved | ApprovalStatus::Rejected { .. } | ApprovalStatus::Expired
)
}
/// Get the current stage name if pending.
pub fn current_stage(&self) -> Option<&str> {
match self {
ApprovalStatus::Pending { stage } => Some(stage),
_ => None,
}
}
/// Display name for the status.
pub fn display_name(&self) -> &'static str {
match self {
ApprovalStatus::Pending { .. } => "Pending",
ApprovalStatus::Approved => "Approved",
ApprovalStatus::Rejected { .. } => "Rejected",
ApprovalStatus::Escalated { .. } => "Escalated",
ApprovalStatus::Expired => "Expired",
}
}
}
impl Default for ApprovalStatus {
fn default() -> Self {
ApprovalStatus::Pending { stage: String::new() }
}
}
impl std::fmt::Display for ApprovalStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ApprovalStatus::Pending { stage } => write!(f, "Pending ({})", stage),
ApprovalStatus::Approved => write!(f, "Approved"),
ApprovalStatus::Rejected { stage, reason } => {
write!(f, "Rejected at {}: {}", stage, reason)
}
ApprovalStatus::Escalated { from_stage, to_stage } => {
write!(f, "Escalated {} → {}", from_stage, to_stage)
}
ApprovalStatus::Expired => write!(f, "Expired"),
}
}
}
/// Decision type for a stage.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Decision {
/// Stage was approved.
Approved,
/// Stage was rejected.
Rejected,
}
impl std::fmt::Display for Decision {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Decision::Approved => write!(f, "approved"),
Decision::Rejected => write!(f, "rejected"),
}
}
}
/// An approval decision record (audit trail).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalDecision {
/// Unique identifier for this decision.
pub id: Uuid,
/// The request this decision belongs to.
pub request_id: Uuid,
/// Stage where this decision was made.
pub stage: String,
/// The decision made (approved or rejected).
pub decision: Decision,
/// Who made the decision.
pub approver: String,
/// Optional comment explaining the decision.
pub comment: Option<String>,
/// When the decision was made.
pub timestamp: DateTime<Utc>,
}
impl ApprovalDecision {
/// Create a new approval decision.
pub fn new(
request_id: Uuid,
stage: impl Into<String>,
decision: Decision,
approver: impl Into<String>,
comment: Option<String>,
) -> Self {
Self {
id: Uuid::new_v4(),
request_id,
stage: stage.into(),
decision,
approver: approver.into(),
comment,
timestamp: Utc::now(),
}
}
}
/// An approval request for a pattern.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRequest {
/// Unique identifier for this request.
pub id: Uuid,
/// The pattern ID being reviewed.
pub pattern_id: Uuid,
/// Human-readable pattern name.
pub pattern_name: String,
/// Name of the workflow being used.
pub workflow_name: String,
/// Current status of the request.
pub status: ApprovalStatus,
/// Index of the current stage in the workflow.
pub current_stage_index: usize,
/// All decisions made on this request.
pub decisions: Vec<ApprovalDecision>,
/// When the request was created.
pub created_at: DateTime<Utc>,
/// When the request was last updated.
pub updated_at: DateTime<Utc>,
/// Who created the request.
pub created_by: String,
/// Optional deadline for the current stage.
pub stage_deadline: Option<DateTime<Utc>>,
/// Optional summary of evidence for reviewers.
pub evidence_summary: Option<String>,
}
impl ApprovalRequest {
/// Create a new approval request.
pub fn new(
pattern_id: Uuid,
pattern_name: impl Into<String>,
workflow_name: impl Into<String>,
first_stage: impl Into<String>,
created_by: impl Into<String>,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
pattern_id,
pattern_name: pattern_name.into(),
workflow_name: workflow_name.into(),
status: ApprovalStatus::Pending { stage: first_stage.into() },
current_stage_index: 0,
decisions: Vec::new(),
created_at: now,
updated_at: now,
created_by: created_by.into(),
stage_deadline: None,
evidence_summary: None,
}
}
/// Set the stage deadline.
pub fn with_deadline(mut self, deadline: DateTime<Utc>) -> Self {
self.stage_deadline = Some(deadline);
self
}
/// Set the evidence summary.
pub fn with_evidence_summary(mut self, summary: impl Into<String>) -> Self {
self.evidence_summary = Some(summary.into());
self
}
/// Add a decision to the request.
pub fn add_decision(&mut self, decision: ApprovalDecision) {
self.decisions.push(decision);
self.updated_at = Utc::now();
}
/// Advance to the next stage.
pub fn advance_to_stage(&mut self, stage: impl Into<String>) {
self.current_stage_index += 1;
self.status = ApprovalStatus::Pending { stage: stage.into() };
self.updated_at = Utc::now();
self.stage_deadline = None; // Will be set by caller if needed
}
/// Mark as approved.
pub fn mark_approved(&mut self) {
self.status = ApprovalStatus::Approved;
self.updated_at = Utc::now();
}
/// Mark as rejected.
pub fn mark_rejected(&mut self, stage: impl Into<String>, reason: impl Into<String>) {
self.status = ApprovalStatus::Rejected { stage: stage.into(), reason: reason.into() };
self.updated_at = Utc::now();
}
/// Mark as escalated.
pub fn mark_escalated(&mut self, from_stage: impl Into<String>, to_stage: impl Into<String>) {
let to = to_stage.into();
self.status =
ApprovalStatus::Escalated { from_stage: from_stage.into(), to_stage: to.clone() };
self.updated_at = Utc::now();
// Also set pending to the escalated stage
self.status = ApprovalStatus::Pending { stage: to };
}
/// Mark as expired.
pub fn mark_expired(&mut self) {
self.status = ApprovalStatus::Expired;
self.updated_at = Utc::now();
}
/// Check if the current stage deadline has passed.
pub fn is_past_deadline(&self) -> bool {
self.stage_deadline.map(|d| Utc::now() > d).unwrap_or(false)
}
/// Get the number of approvals for the current stage.
pub fn current_stage_approval_count(&self) -> usize {
if let ApprovalStatus::Pending { stage } = &self.status {
self.decisions
.iter()
.filter(|d| d.stage == *stage && d.decision == Decision::Approved)
.count()
} else {
0
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_approval_status_pending() {
let status = ApprovalStatus::Pending { stage: "security_review".to_string() };
assert!(status.is_pending());
assert!(!status.is_terminal());
assert_eq!(status.current_stage(), Some("security_review"));
}
#[test]
fn test_approval_status_terminal() {
assert!(ApprovalStatus::Approved.is_terminal());
assert!(ApprovalStatus::Rejected { stage: "test".into(), reason: "reason".into() }
.is_terminal());
assert!(ApprovalStatus::Expired.is_terminal());
assert!(!ApprovalStatus::Pending { stage: "test".into() }.is_terminal());
}
#[test]
fn test_approval_decision_creation() {
let decision = ApprovalDecision::new(
Uuid::new_v4(),
"security_review",
Decision::Approved,
"alice",
Some("LGTM".to_string()),
);
assert_eq!(decision.stage, "security_review");
assert_eq!(decision.decision, Decision::Approved);
assert_eq!(decision.approver, "alice");
assert_eq!(decision.comment, Some("LGTM".to_string()));
}
#[test]
fn test_approval_request_lifecycle() {
let mut request = ApprovalRequest::new(
Uuid::new_v4(),
"tls_min_version",
"standard_review",
"security_review",
"system",
);
assert!(request.status.is_pending());
assert_eq!(request.current_stage_index, 0);
// Add approval decision
let decision =
ApprovalDecision::new(request.id, "security_review", Decision::Approved, "alice", None);
request.add_decision(decision);
assert_eq!(request.current_stage_approval_count(), 1);
// Advance to next stage
request.advance_to_stage("architecture_review");
assert_eq!(request.current_stage_index, 1);
assert_eq!(request.status.current_stage(), Some("architecture_review"));
// Final approval
request.mark_approved();
assert!(request.status.is_approved());
assert!(request.status.is_terminal());
}
#[test]
fn test_approval_request_rejection() {
let mut request = ApprovalRequest::new(
Uuid::new_v4(),
"weak_pattern",
"standard_review",
"security_review",
"system",
);
request.mark_rejected("security_review", "Pattern too broad");
assert!(request.status.is_rejected());
assert!(request.status.is_terminal());
if let ApprovalStatus::Rejected { stage, reason } = &request.status {
assert_eq!(stage, "security_review");
assert_eq!(reason, "Pattern too broad");
} else {
panic!("Expected Rejected status");
}
}
#[test]
fn test_serialization_roundtrip() {
let request =
ApprovalRequest::new(Uuid::new_v4(), "test_pattern", "workflow", "stage1", "user");
let json = serde_json::to_string(&request).expect("serialize");
let parsed: ApprovalRequest = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.id, request.id);
assert_eq!(parsed.pattern_name, request.pattern_name);
}
}

View File

@ -0,0 +1,387 @@
//! Workflow definition types for approval workflows.
//!
//! Defines the structure of approval workflows including stages,
//! required approvers, timeouts, and escalation paths.
use serde::{Deserialize, Serialize};
use crate::evidence::EvidenceLevel;
use crate::scope::ScopeLevel;
/// Default minimum approvals required per stage.
const fn default_min_approvals() -> usize {
1
}
/// A single approval stage in a workflow.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalStage {
/// Stage identifier (e.g., "security_review").
pub name: String,
/// Human-readable label (e.g., "Security Review").
pub label: String,
/// Teams or individuals who can approve this stage.
#[serde(default)]
pub required_approvers: Vec<String>,
/// Minimum number of approvals needed to pass this stage.
#[serde(default = "default_min_approvals")]
pub min_approvals: usize,
/// Optional timeout in hours for this stage.
#[serde(default)]
pub timeout_hours: Option<u32>,
/// Optional stage to escalate to on timeout.
#[serde(default)]
pub escalate_to: Option<String>,
/// Auto-approve if pattern has this evidence level or higher.
#[serde(default)]
pub auto_approve_evidence_level: Option<EvidenceLevel>,
}
impl ApprovalStage {
/// Create a new approval stage.
pub fn new(name: impl Into<String>, label: impl Into<String>) -> Self {
Self {
name: name.into(),
label: label.into(),
required_approvers: Vec::new(),
min_approvals: 1,
timeout_hours: None,
escalate_to: None,
auto_approve_evidence_level: None,
}
}
/// Set required approvers.
pub fn with_approvers(mut self, approvers: Vec<String>) -> Self {
self.required_approvers = approvers;
self
}
/// Set minimum approvals.
pub fn with_min_approvals(mut self, min: usize) -> Self {
self.min_approvals = min;
self
}
/// Set timeout in hours.
pub fn with_timeout(mut self, hours: u32) -> Self {
self.timeout_hours = Some(hours);
self
}
/// Set escalation target.
pub fn with_escalation(mut self, stage: impl Into<String>) -> Self {
self.escalate_to = Some(stage.into());
self
}
/// Set auto-approve evidence level.
pub fn with_auto_approve_level(mut self, level: EvidenceLevel) -> Self {
self.auto_approve_evidence_level = Some(level);
self
}
/// Check if a user is an authorized approver for this stage.
pub fn is_authorized(&self, approver: &str) -> bool {
if self.required_approvers.is_empty() {
// No restrictions, anyone can approve
true
} else {
self.required_approvers.iter().any(|a| a == approver)
}
}
/// Check if this stage should auto-approve based on evidence level.
pub fn should_auto_approve(&self, evidence_level: EvidenceLevel) -> bool {
self.auto_approve_evidence_level.map(|required| evidence_level >= required).unwrap_or(false)
}
}
impl Default for ApprovalStage {
fn default() -> Self {
Self::new("default", "Default Stage")
}
}
/// An approval workflow template.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalWorkflow {
/// Workflow identifier.
pub name: String,
/// Human-readable description.
pub description: String,
/// Ordered list of approval stages.
#[serde(default)]
pub stages: Vec<ApprovalStage>,
/// Optional scope level this workflow applies to.
#[serde(default)]
pub applies_to_scope: Option<ScopeLevel>,
/// Apply this workflow for patterns below this evidence level.
#[serde(default)]
pub applies_to_evidence_below: Option<EvidenceLevel>,
/// Overall timeout for the entire workflow in hours.
#[serde(default)]
pub overall_timeout_hours: Option<u32>,
}
impl ApprovalWorkflow {
/// Create a new workflow.
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
stages: Vec::new(),
applies_to_scope: None,
applies_to_evidence_below: None,
overall_timeout_hours: None,
}
}
/// Add a stage to the workflow.
pub fn add_stage(mut self, stage: ApprovalStage) -> Self {
self.stages.push(stage);
self
}
/// Set the scope level this workflow applies to.
pub fn with_scope(mut self, scope: ScopeLevel) -> Self {
self.applies_to_scope = Some(scope);
self
}
/// Set the evidence level threshold.
pub fn with_evidence_below(mut self, level: EvidenceLevel) -> Self {
self.applies_to_evidence_below = Some(level);
self
}
/// Set overall timeout.
pub fn with_overall_timeout(mut self, hours: u32) -> Self {
self.overall_timeout_hours = Some(hours);
self
}
/// Get the first stage of the workflow.
pub fn first_stage(&self) -> Option<&ApprovalStage> {
self.stages.first()
}
/// Get the next stage after the current index.
pub fn next_stage(&self, current_index: usize) -> Option<&ApprovalStage> {
self.stages.get(current_index + 1)
}
/// Check if a stage index is the last stage.
pub fn is_last_stage(&self, index: usize) -> bool {
index >= self.stages.len().saturating_sub(1)
}
/// Get a stage by name.
pub fn get_stage_by_name(&self, name: &str) -> Option<(usize, &ApprovalStage)> {
self.stages.iter().enumerate().find(|(_, s)| s.name == name)
}
/// Get a stage by index.
pub fn get_stage(&self, index: usize) -> Option<&ApprovalStage> {
self.stages.get(index)
}
/// Check if this workflow applies to a pattern based on evidence level.
pub fn applies_to_evidence(&self, level: EvidenceLevel) -> bool {
match self.applies_to_evidence_below {
Some(threshold) => level < threshold,
None => true, // No threshold, applies to all
}
}
/// Check if this workflow applies based on scope.
pub fn applies_to_scope_level(&self, level: ScopeLevel) -> bool {
match self.applies_to_scope {
Some(required) => level == required,
None => true, // No scope requirement, applies to all
}
}
/// Check if the workflow should auto-approve at a given stage.
pub fn should_auto_approve(&self, stage_index: usize, evidence_level: EvidenceLevel) -> bool {
self.stages.get(stage_index).map(|s| s.should_auto_approve(evidence_level)).unwrap_or(false)
}
/// Validate the workflow configuration.
pub fn validate(&self) -> Result<(), String> {
if self.name.is_empty() {
return Err("Workflow name cannot be empty".to_string());
}
if self.stages.is_empty() {
return Err("Workflow must have at least one stage".to_string());
}
// Check that escalation targets exist
for stage in &self.stages {
if let Some(ref escalate_to) = stage.escalate_to {
if self.get_stage_by_name(escalate_to).is_none() {
return Err(format!(
"Stage '{}' escalates to non-existent stage '{}'",
stage.name, escalate_to
));
}
}
}
Ok(())
}
}
impl Default for ApprovalWorkflow {
fn default() -> Self {
Self::new("default", "Default approval workflow")
.add_stage(ApprovalStage::new("review", "Review").with_min_approvals(1))
}
}
/// Create a standard review workflow.
///
/// Two stages: security review (48h) → architecture review (72h).
pub fn standard_review_workflow() -> ApprovalWorkflow {
ApprovalWorkflow::new("standard_review", "Standard pattern review for production promotion")
.with_overall_timeout(168) // 1 week
.add_stage(
ApprovalStage::new("security_review", "Security Review")
.with_approvers(vec!["security-team".to_string()])
.with_timeout(48)
.with_escalation("architecture_review"),
)
.add_stage(
ApprovalStage::new("architecture_review", "Architecture Review")
.with_approvers(vec!["arch-team".to_string()])
.with_timeout(72)
.with_auto_approve_level(EvidenceLevel::Standard),
)
}
/// Create a fast-track workflow for high-evidence patterns.
///
/// Single stage with auto-approval for Standard evidence or higher.
pub fn fast_track_workflow() -> ApprovalWorkflow {
ApprovalWorkflow::new("fast_track", "Fast-track for high-evidence patterns")
.with_overall_timeout(24)
.add_stage(
ApprovalStage::new("quick_review", "Quick Review")
.with_min_approvals(1)
.with_timeout(24)
.with_auto_approve_level(EvidenceLevel::Standard),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_approval_stage_creation() {
let stage = ApprovalStage::new("security", "Security Review")
.with_approvers(vec!["sec-team".to_string()])
.with_timeout(48);
assert_eq!(stage.name, "security");
assert_eq!(stage.label, "Security Review");
assert_eq!(stage.timeout_hours, Some(48));
assert!(stage.is_authorized("sec-team"));
assert!(!stage.is_authorized("other"));
}
#[test]
fn test_stage_auto_approve() {
let stage =
ApprovalStage::new("review", "Review").with_auto_approve_level(EvidenceLevel::Standard);
assert!(stage.should_auto_approve(EvidenceLevel::Standard));
assert!(stage.should_auto_approve(EvidenceLevel::ProductSpec));
assert!(!stage.should_auto_approve(EvidenceLevel::Research));
assert!(!stage.should_auto_approve(EvidenceLevel::Commit));
}
#[test]
fn test_workflow_creation() {
let workflow = ApprovalWorkflow::new("test", "Test workflow")
.add_stage(ApprovalStage::new("stage1", "Stage 1"))
.add_stage(ApprovalStage::new("stage2", "Stage 2"));
assert_eq!(workflow.stages.len(), 2);
assert!(workflow.first_stage().is_some());
assert_eq!(workflow.first_stage().unwrap().name, "stage1");
}
#[test]
fn test_workflow_navigation() {
let workflow = ApprovalWorkflow::new("test", "Test")
.add_stage(ApprovalStage::new("s1", "S1"))
.add_stage(ApprovalStage::new("s2", "S2"))
.add_stage(ApprovalStage::new("s3", "S3"));
assert!(!workflow.is_last_stage(0));
assert!(!workflow.is_last_stage(1));
assert!(workflow.is_last_stage(2));
assert_eq!(workflow.next_stage(0).unwrap().name, "s2");
assert_eq!(workflow.next_stage(1).unwrap().name, "s3");
assert!(workflow.next_stage(2).is_none());
}
#[test]
fn test_workflow_validation() {
let empty = ApprovalWorkflow::new("empty", "").stages;
let workflow =
ApprovalWorkflow { name: "test".into(), stages: empty, ..Default::default() };
assert!(workflow.validate().is_err());
let workflow = ApprovalWorkflow::new("", "").add_stage(ApprovalStage::new("s1", "S1"));
assert!(workflow.validate().is_err());
let workflow = ApprovalWorkflow::new("test", "Test")
.add_stage(ApprovalStage::new("s1", "S1").with_escalation("nonexistent"));
assert!(workflow.validate().is_err());
let workflow =
ApprovalWorkflow::new("test", "Test").add_stage(ApprovalStage::new("s1", "S1"));
assert!(workflow.validate().is_ok());
}
#[test]
fn test_standard_review_workflow() {
let workflow = standard_review_workflow();
assert!(workflow.validate().is_ok());
assert_eq!(workflow.stages.len(), 2);
assert_eq!(workflow.first_stage().unwrap().name, "security_review");
}
#[test]
fn test_fast_track_workflow() {
let workflow = fast_track_workflow();
assert!(workflow.validate().is_ok());
assert_eq!(workflow.stages.len(), 1);
assert!(workflow.should_auto_approve(0, EvidenceLevel::Standard));
}
#[test]
fn test_serialization_roundtrip() {
let workflow = standard_review_workflow();
let json = serde_json::to_string(&workflow).expect("serialize");
let parsed: ApprovalWorkflow = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.name, workflow.name);
assert_eq!(parsed.stages.len(), workflow.stages.len());
}
}

View File

@ -0,0 +1,696 @@
//! 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)])
}
}

View File

@ -0,0 +1,694 @@
//! 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])
}
}

View File

@ -9,11 +9,14 @@ use crate::cli::Commands;
mod corpus;
mod eval;
mod extractors;
mod governance;
mod lifecycle;
mod patterns;
mod policy;
mod policy_ops;
mod research;
mod scan;
mod scope;
mod shadow;
mod utils;
@ -27,6 +30,10 @@ pub use eval::*;
#[allow(unused_imports)]
pub use extractors::*;
#[allow(unused_imports)]
pub use governance::*;
#[allow(unused_imports)]
pub use lifecycle::*;
#[allow(unused_imports)]
pub use patterns::*;
#[allow(unused_imports)]
pub use policy::*;
@ -37,6 +44,8 @@ pub use research::*;
#[allow(unused_imports)]
pub use scan::*;
#[allow(unused_imports)]
pub use scope::*;
#[allow(unused_imports)]
pub use shadow::*;
#[allow(unused_imports)]
pub use utils::*;
@ -54,12 +63,14 @@ pub async fn handle_command(command: Commands, config: &AphoriaConfig) -> ExitCo
sync,
staged,
community_preview,
benchmark,
} => {
if community_preview {
scan::handle_community_preview(path, config).await
} else {
scan::handle_scan(
path, format, exit_code, strict, persist, debug, sync, staged, config,
path, format, exit_code, strict, persist, debug, sync, staged, benchmark,
config,
)
.await
}
@ -98,5 +109,21 @@ pub async fn handle_command(command: Commands, config: &AphoriaConfig) -> ExitCo
Commands::Eval { command } => eval::handle_eval_command(command, config).await,
Commands::Patterns { command } => patterns::handle_pattern_command(command, config).await,
Commands::Scope { command } => scope::handle_scope_command(command, config).await,
Commands::Lifecycle { command } => {
lifecycle::handle_lifecycle_command(command, config).await
}
Commands::Migrations { command } => {
lifecycle::handle_migrations_command(command, config).await
}
Commands::Governance { command } => {
governance::handle_governance_command(command, config).await
}
Commands::Audit { command } => governance::handle_audit_command(command, config).await,
}
}

View File

@ -3,9 +3,14 @@
use std::process::ExitCode;
use aphoria::{
bridge::generate_signing_key, community::CommunityExtractorLoader, community::PatternSyncer,
hosted::HostedClient, learning::learning_store_dir, AphoriaConfig, LocalPatternStore,
PatternStore,
bridge::generate_signing_key,
community::CommunityExtractorLoader,
community::PatternSyncer,
evidence::EvidenceLevel,
hosted::HostedClient,
learning::{learning_store_dir, LearnedPattern},
scope::{OverridePolicy, ScopeLevel, ScopeResolver},
AphoriaConfig, LocalPatternStore, PatternStore,
};
use crate::cli::PatternCommands;
@ -17,6 +22,24 @@ pub async fn handle_pattern_command(command: PatternCommands, config: &AphoriaCo
PatternCommands::PullCommunity { min_projects, dry_run } => {
handle_pull_community(config, min_projects, dry_run)
}
PatternCommands::Show {
id,
evidence,
eligible,
format,
scope,
only_local,
show_inheritance,
} => handle_pattern_show(
config,
id,
evidence,
eligible,
format,
scope,
only_local,
show_inheritance,
),
}
}
@ -299,3 +322,194 @@ fn truncate(s: &str, max_len: usize) -> String {
format!("{}...", &s[..max_len.saturating_sub(3)])
}
}
#[allow(clippy::too_many_arguments)]
fn handle_pattern_show(
config: &AphoriaConfig,
id: Option<String>,
evidence_filter: Option<String>,
eligible_only: bool,
format: String,
scope_filter: Option<String>,
only_local: bool,
show_inheritance: bool,
) -> ExitCode {
// Open pattern store
let store_dir = learning_store_dir();
let store = match LocalPatternStore::new(&store_dir) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to open pattern store: {e}");
return ExitCode::from(3);
}
};
// Parse evidence filter if provided
let evidence_level_filter = match evidence_filter.as_ref() {
Some(s) => match s.parse::<EvidenceLevel>() {
Ok(level) => Some(level),
Err(e) => {
eprintln!("{e}");
return ExitCode::from(1);
}
},
None => None,
};
// Parse scope filter if provided
let scope_level_filter = match scope_filter.as_ref() {
Some(s) => match s.parse::<ScopeLevel>() {
Ok(level) => Some(level),
Err(e) => {
eprintln!("{e}");
return ExitCode::from(1);
}
},
None => None,
};
// Get patterns based on filters
let patterns: Vec<LearnedPattern> = if let Some(ref id_str) = id {
// Specific pattern by ID
match uuid::Uuid::parse_str(id_str) {
Ok(uuid) => store.get_pattern_by_id(&uuid).into_iter().collect(),
Err(_) => {
eprintln!("Invalid UUID: {}", id_str);
return ExitCode::from(1);
}
}
} else {
// All patterns, optionally filtered
let mut patterns = store.get_all_patterns();
// Filter by evidence level if specified
if let Some(level) = evidence_level_filter {
patterns.retain(|p| p.evidence.effective_level() == level);
}
// Filter by scope level if specified
if let Some(level) = scope_level_filter {
patterns.retain(|p| p.scope_level == level);
}
// Apply scope resolver if only_local is set
if only_local {
let scope_ctx = config.scope.to_context();
let resolver = ScopeResolver::with_policy(scope_ctx, OverridePolicy::NoInherit);
let filtered: Vec<_> =
resolver.resolve_all_patterns(&patterns).into_iter().cloned().collect();
patterns = filtered;
}
// Filter to eligible only if requested
if eligible_only {
patterns.retain(|p| {
p.is_promotion_candidate(
config.cross_project.min_local_projects,
config.cross_project.min_local_confidence,
)
});
}
// Sort by evidence level (highest first), then by project count
patterns.sort_by(|a, b| {
b.evidence
.effective_level()
.cmp(&a.evidence.effective_level())
.then_with(|| b.project_count().cmp(&a.project_count()))
});
patterns
};
// Output based on format
match format.as_str() {
"json" => match serde_json::to_string_pretty(&patterns) {
Ok(json) => println!("{}", json),
Err(e) => {
eprintln!("Failed to serialize patterns: {e}");
return ExitCode::from(3);
}
},
_ => {
// Table format
print_patterns_table(&patterns, &store, config, show_inheritance);
}
}
ExitCode::SUCCESS
}
fn print_patterns_table(
patterns: &[LearnedPattern],
store: &LocalPatternStore,
config: &AphoriaConfig,
show_inheritance: bool,
) {
let total = store.pattern_count();
if patterns.is_empty() {
println!("No patterns found.");
return;
}
println!("Learned Patterns ({} shown, {} total)", patterns.len(), total);
println!();
if show_inheritance {
println!(
"{:<36} {:>8} {:>6} {:>10} {:>12} Subject",
"ID", "Projects", "Conf", "Evidence", "Scope"
);
println!("{}", "-".repeat(95));
} else {
println!("{:<36} {:>8} {:>6} {:>10} Subject", "ID", "Projects", "Conf", "Evidence");
println!("{}", "-".repeat(80));
}
for pattern in patterns {
let id_short = &pattern.id.to_string()[..8];
let evidence_badge = pattern.evidence.effective_level().badge();
let subject = &pattern.claim_template.subject_template;
// Check if eligible for promotion
let eligible = pattern.is_promotion_candidate(
config.cross_project.min_local_projects,
config.cross_project.min_local_confidence,
);
let eligibility_marker = if eligible { " *" } else { "" };
if show_inheritance {
let scope_info =
format!("{}:{}", pattern.scope_level, pattern.scope_id.as_deref().unwrap_or("-"));
println!(
"{:<36} {:>8} {:>6.2} {:>10} {:>12} {}{}",
format!("{}...", id_short),
pattern.project_count(),
pattern.avg_confidence,
evidence_badge,
truncate(&scope_info, 12),
truncate(subject, 25),
eligibility_marker
);
} else {
println!(
"{:<36} {:>8} {:>6.2} {:>10} {}{}",
format!("{}...", id_short),
pattern.project_count(),
pattern.avg_confidence,
evidence_badge,
truncate(subject, 30),
eligibility_marker
);
}
// Show evidence sources on next line(s)
for source in &pattern.evidence.sources {
println!(" -> {}", source.display());
}
}
println!();
println!("* = eligible for promotion");
}

View File

@ -14,6 +14,7 @@ pub async fn handle_scan(
debug: bool,
sync: bool,
staged: bool,
benchmark: bool,
config: &AphoriaConfig,
) -> ExitCode {
// Validate: --sync requires --persist
@ -26,8 +27,16 @@ pub async fn handle_scan(
let mode = if persist { ScanMode::Persistent } else { ScanMode::Ephemeral };
let file_source = if staged { FileSource::Staged } else { FileSource::All };
let args =
ScanArgs { path, format, exit_code_enabled: exit_code, mode, debug, sync, file_source };
let args = ScanArgs {
path,
format,
exit_code_enabled: exit_code,
mode,
debug,
sync,
file_source,
benchmark,
};
// Apply stricter thresholds if requested
let config = if strict {
@ -89,6 +98,7 @@ pub async fn handle_community_preview(
debug: false,
sync: false,
file_source: FileSource::All,
benchmark: false,
};
let claims = match extract_claims(&args, config).await {

View File

@ -0,0 +1,393 @@
//! 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());
}
}

View File

@ -68,6 +68,12 @@ pub trait PatternStore: Send + Sync {
/// Get the total number of stored patterns.
fn pattern_count(&self) -> usize;
/// Get all stored patterns.
fn get_all_patterns(&self) -> Vec<LearnedPattern>;
/// Get a specific pattern by ID.
fn get_pattern_by_id(&self, id: &Uuid) -> Option<LearnedPattern>;
}
/// Local JSON-backed pattern store.
@ -268,6 +274,14 @@ impl PatternStore for LocalPatternStore {
fn pattern_count(&self) -> usize {
self.cache.read().map(|c| c.len()).unwrap_or(0)
}
fn get_all_patterns(&self) -> Vec<LearnedPattern> {
self.cache.read().map(|c| c.values().cloned().collect()).unwrap_or_default()
}
fn get_pattern_by_id(&self, id: &Uuid) -> Option<LearnedPattern> {
self.cache.read().ok()?.get(id).cloned()
}
}
/// Get the default learning store directory.

View File

@ -9,6 +9,9 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::evidence::PatternEvidence;
use crate::lifecycle::KnowledgeLifecycle;
use crate::scope::ScopeLevel;
use crate::types::Language;
/// Value types for pattern placeholders.
@ -136,6 +139,31 @@ pub struct LearnedPattern {
/// If promoted, the name of the generated extractor.
pub promoted_to: Option<String>,
/// Evidence backing this pattern (RFC references, ADRs, specs, etc.).
///
/// Patterns with higher evidence levels graduate faster and carry more authority.
#[serde(default)]
pub evidence: PatternEvidence,
/// Scope level where this pattern was learned.
///
/// Defaults to Project for backward compatibility.
#[serde(default)]
pub scope_level: ScopeLevel,
/// Scope identifier (e.g., "acme-corp", "platform-team", "api-gateway").
///
/// Combined with scope_level to uniquely identify the scope.
#[serde(default)]
pub scope_id: Option<String>,
/// Lifecycle metadata for deprecation, supersession, and archival.
///
/// Tracks whether this pattern is active, deprecated, superseded, or archived.
/// Deprecated patterns continue to match but FLAG with migration guidance.
#[serde(default)]
pub lifecycle: KnowledgeLifecycle,
}
impl LearnedPattern {
@ -165,9 +193,35 @@ impl LearnedPattern {
avg_confidence: confidence,
promoted: false,
promoted_to: None,
evidence: PatternEvidence::new(),
scope_level: ScopeLevel::default(),
scope_id: None,
lifecycle: KnowledgeLifecycle::default(),
}
}
/// Create a new learned pattern with evidence.
pub fn with_evidence(
example_code: impl Into<String>,
normalized_pattern: impl Into<String>,
claim_template: ClaimTemplate,
language: Language,
project_hash: impl Into<String>,
confidence: f32,
evidence: PatternEvidence,
) -> Self {
let mut pattern = Self::new(
example_code,
normalized_pattern,
claim_template,
language,
project_hash,
confidence,
);
pattern.evidence = evidence;
pattern
}
/// Record a new observation of this pattern.
///
/// Updates occurrence count, project set, confidence average, and last_seen.
@ -194,13 +248,30 @@ impl LearnedPattern {
/// Check if this pattern is eligible for promotion.
///
/// A pattern is eligible when it meets minimum thresholds for
/// project count and confidence.
/// project count and confidence. Evidence level affects the required
/// project count: higher evidence levels graduate faster.
pub fn is_promotion_candidate(&self, min_projects: usize, min_confidence: f32) -> bool {
!self.promoted
&& self.project_count() >= min_projects
&& self.project_count() >= self.effective_min_projects(min_projects)
&& self.avg_confidence >= min_confidence
}
/// Get effective minimum projects based on evidence level.
///
/// Higher evidence levels require fewer projects to graduate.
pub fn effective_min_projects(&self, base_min: usize) -> usize {
// Use evidence-aware threshold if we have evidence
let evidence_threshold = self.evidence.graduation_threshold();
// Take the lower of evidence threshold and base threshold
std::cmp::min(evidence_threshold, base_min)
}
/// Add evidence to this pattern.
pub fn add_evidence(&mut self, evidence: PatternEvidence) {
self.evidence.merge(evidence);
}
/// Days since this pattern was last seen.
pub fn days_since_last_seen(&self) -> i64 {
(Utc::now() - self.last_seen).num_days()

View File

@ -46,14 +46,18 @@ mod config;
pub mod corpus;
mod corpus_build;
mod episteme;
pub mod scope;
pub use episteme::{current_timestamp, current_timestamp_millis};
mod error;
pub mod eval;
pub mod evidence;
pub mod expiry;
pub mod extractors;
pub mod governance;
pub mod hosted;
mod init;
pub mod learning;
pub mod lifecycle;
pub mod llm;
pub mod policy;
mod policy_ops;
@ -75,8 +79,8 @@ pub use community::{
};
pub use config::{
AphoriaConfig, AutonomousConfig, CommunityConfig, CorpusConfig, CrossProjectConfig, EvalConfig,
HostedConfig, LearningConfig, LlmConfig, OfflineFallback, PredicateAliasConfig,
PromotionConfig, ShadowConfig, SyncMode,
GovernanceConfig, HostedConfig, LearningConfig, LlmConfig, OfflineFallback,
PredicateAliasConfig, PromotionConfig, ShadowConfig, SyncMode,
};
pub use corpus::{CorpusBuildResult, CorpusBuilderInfo, CorpusRegistry};
pub use corpus_build::{build_corpus, list_corpus_sources, CorpusBuildArgs};
@ -88,8 +92,20 @@ pub use eval::{
FixtureMetadata, FixtureResult, FixtureScoring, FixtureStatus, FixtureSummary, MatchResult,
Metrics, Observation, ParsedClaim, Report, ReportFormat, ValidationError,
};
pub use evidence::{EvidenceDetector, EvidenceLevel, EvidenceSource, PatternEvidence};
pub use governance::{
governance_store_dir, ApprovalDecision, ApprovalRequest, ApprovalStage, ApprovalStatus,
ApprovalWorkflow, AuditEvent, AuditEventType, AuditTrail, Decision, ExportFormat,
GovernanceStateMachine, GovernanceStore,
};
pub use init::{initialize, show_status};
pub use learning::{ClaimTemplate, LearnedPattern, LocalPatternStore, PatternStore, ValueType};
pub use learning::{
learning_store_dir, ClaimTemplate, LearnedPattern, LocalPatternStore, PatternStore, ValueType,
};
pub use lifecycle::{
lifecycle_store_dir, DeprecatedUsage, KnowledgeLifecycle, KnowledgeStatus, LifecycleStore,
MigrationProgress, MigrationStore, StatusTransition,
};
pub use policy::{PackPredicateAliasSet, PolicyManager, SignatureRecord, TrustPack};
pub use policy_ops::{
acknowledge, bless, export_policy, import_policy, parse_value, resign_policy, update,
@ -107,6 +123,10 @@ pub use research::{
};
pub use research_commands::{record_scan_gaps, run_research, show_research_status, ResearchArgs};
pub use scan::{extract_claims, run_scan};
pub use scope::{
override_store_dir, OverridePolicy, OverrideStore, OverrideValue, ScopeConfig, ScopeContext,
ScopeId, ScopeLevel, ScopeOverride, ScopeResolver,
};
pub use shadow::{
AutoRollbackResult, FeedbackCollector, FeedbackWithRollback, GraduationManager, MatchFeedback,
ShadowDecision, ShadowDecisionKind, ShadowExecutor, ShadowExtractorRegistry, ShadowMatch,
@ -114,8 +134,8 @@ pub use shadow::{
};
pub use types::{
extract_leaf_concept, predicates, AcknowledgeArgs, BlessArgs, ConflictResult, ConflictTrace,
ExtractedClaim, FileSource, PolicySourceInfo, PredicateAliasSet, ScanArgs, ScanMode,
ScanResult, UpdateArgs, Verdict,
DeprecatedUsageResult, ExtractedClaim, FileSource, PolicySourceInfo, PredicateAliasSet,
ScanArgs, ScanMode, ScanResult, UpdateArgs, Verdict,
};
#[cfg(test)]

View File

@ -0,0 +1,536 @@
//! Migration tracking for deprecated patterns.
//!
//! Tracks usage of deprecated patterns across projects and scopes,
//! enabling migration progress dashboards and sunset planning.
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AphoriaError;
use crate::scope::ScopeId;
/// Usage of a deprecated pattern in a specific location.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeprecatedUsage {
/// ID of the deprecated pattern.
pub pattern_id: Uuid,
/// Name of the pattern (for display).
pub pattern_name: String,
/// File where the pattern was used.
pub file_path: String,
/// Line number in the file.
pub line: usize,
/// Hash of the project (privacy-preserving).
pub project_hash: String,
/// Scope where this usage was detected.
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<ScopeId>,
/// When this usage was first detected.
pub first_detected: DateTime<Utc>,
/// When this usage was last detected.
pub last_detected: DateTime<Utc>,
/// Whether this usage has been resolved (migrated away).
#[serde(default)]
pub resolved: bool,
/// When this usage was resolved.
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_at: Option<DateTime<Utc>>,
}
impl DeprecatedUsage {
/// Create a new deprecated usage record.
pub fn new(
pattern_id: Uuid,
pattern_name: impl Into<String>,
file_path: impl Into<String>,
line: usize,
project_hash: impl Into<String>,
) -> Self {
let now = Utc::now();
Self {
pattern_id,
pattern_name: pattern_name.into(),
file_path: file_path.into(),
line,
project_hash: project_hash.into(),
scope: None,
first_detected: now,
last_detected: now,
resolved: false,
resolved_at: None,
}
}
/// Create with a scope.
pub fn with_scope(mut self, scope: ScopeId) -> Self {
self.scope = Some(scope);
self
}
/// Mark this usage as resolved (migrated away).
pub fn resolve(&mut self) {
self.resolved = true;
self.resolved_at = Some(Utc::now());
}
/// Update the last detected time.
pub fn update_detected(&mut self) {
self.last_detected = Utc::now();
}
/// Get a unique key for this usage.
pub fn unique_key(&self) -> String {
format!("{}:{}:{}", self.pattern_id, self.file_path, self.line)
}
}
/// Migration progress summary for a deprecated pattern.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationProgress {
/// ID of the deprecated pattern.
pub pattern_id: Uuid,
/// Name of the pattern.
pub pattern_name: String,
/// Total number of usages detected.
pub total_usages: usize,
/// Number of usages that have been resolved.
pub resolved_usages: usize,
/// Usages by scope (scope ID -> count).
pub usages_by_scope: HashMap<String, usize>,
/// Usages by project hash (project_hash -> count).
pub usages_by_project: HashMap<String, usize>,
/// When migration tracking started.
pub tracking_started: DateTime<Utc>,
/// When the last usage was detected.
pub last_usage_detected: Option<DateTime<Utc>>,
}
impl MigrationProgress {
/// Create a new migration progress record.
pub fn new(pattern_id: Uuid, pattern_name: impl Into<String>) -> Self {
Self {
pattern_id,
pattern_name: pattern_name.into(),
total_usages: 0,
resolved_usages: 0,
usages_by_scope: HashMap::new(),
usages_by_project: HashMap::new(),
tracking_started: Utc::now(),
last_usage_detected: None,
}
}
/// Calculate the migration completion percentage.
pub fn completion_percent(&self) -> f32 {
if self.total_usages == 0 {
100.0 // No usages = fully migrated
} else {
(self.resolved_usages as f32 / self.total_usages as f32) * 100.0
}
}
/// Get the number of pending (unresolved) usages.
pub fn pending_usages(&self) -> usize {
self.total_usages.saturating_sub(self.resolved_usages)
}
/// Check if migration is complete (all usages resolved).
pub fn is_complete(&self) -> bool {
self.total_usages > 0 && self.resolved_usages >= self.total_usages
}
}
/// JSON-backed migration store.
///
/// Tracks deprecated pattern usages in `~/.aphoria/lifecycle/migrations.json`.
pub struct MigrationStore {
/// Path to the migrations JSON file.
path: PathBuf,
/// In-memory cache of usages, keyed by unique_key.
cache: RwLock<HashMap<String, DeprecatedUsage>>,
}
impl MigrationStore {
/// Create a new migration store.
pub fn new(store_dir: &Path) -> Result<Self, AphoriaError> {
let path = store_dir.join("migrations.json");
// Ensure directory exists
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
AphoriaError::LearningStore(format!("Failed to create migration directory: {}", e))
})?;
}
// Load existing usages if file exists
let cache = if path.exists() {
let content = fs::read_to_string(&path).map_err(|e| {
AphoriaError::LearningStore(format!("Failed to read migrations file: {}", e))
})?;
// Handle empty file
if content.trim().is_empty() {
RwLock::new(HashMap::new())
} else {
let usages: Vec<DeprecatedUsage> = serde_json::from_str(&content).map_err(|e| {
AphoriaError::LearningStore(format!("Failed to parse migrations file: {}", e))
})?;
let map: HashMap<String, DeprecatedUsage> =
usages.into_iter().map(|u| (u.unique_key(), u)).collect();
RwLock::new(map)
}
} else {
RwLock::new(HashMap::new())
};
Ok(Self { path, cache })
}
/// Open a migration store at the default location.
pub fn open_default() -> Result<Self, AphoriaError> {
Self::new(&super::lifecycle_store_dir())
}
/// Record a usage of a deprecated pattern.
///
/// If the usage already exists, updates the last_detected time.
pub fn record_usage(&self, usage: DeprecatedUsage) -> Result<(), AphoriaError> {
{
let mut cache = self.cache.write().map_err(|e| {
AphoriaError::LearningStore(format!("Failed to acquire write lock: {}", e))
})?;
let key = usage.unique_key();
if let Some(existing) = cache.get_mut(&key) {
existing.update_detected();
} else {
cache.insert(key, usage);
}
}
self.persist()
}
/// Mark a usage as resolved.
pub fn resolve_usage(
&self,
pattern_id: &Uuid,
file_path: &str,
line: usize,
) -> Result<bool, AphoriaError> {
let key = format!("{}:{}:{}", pattern_id, file_path, line);
let found = {
let mut cache = self.cache.write().map_err(|e| {
AphoriaError::LearningStore(format!("Failed to acquire write lock: {}", e))
})?;
if let Some(usage) = cache.get_mut(&key) {
usage.resolve();
true
} else {
false
}
};
if found {
self.persist()?;
}
Ok(found)
}
/// Get all usages for a pattern.
pub fn get_usages(&self, pattern_id: &Uuid) -> Vec<DeprecatedUsage> {
let cache = match self.cache.read() {
Ok(c) => c,
Err(_) => return vec![],
};
cache.values().filter(|u| &u.pattern_id == pattern_id).cloned().collect()
}
/// Get all unresolved usages for a pattern.
pub fn get_pending_usages(&self, pattern_id: &Uuid) -> Vec<DeprecatedUsage> {
self.get_usages(pattern_id).into_iter().filter(|u| !u.resolved).collect()
}
/// Get migration progress for a pattern.
pub fn get_progress(&self, pattern_id: &Uuid, pattern_name: &str) -> MigrationProgress {
let usages = self.get_usages(pattern_id);
let mut progress = MigrationProgress::new(*pattern_id, pattern_name);
progress.total_usages = usages.len();
for usage in &usages {
if usage.resolved {
progress.resolved_usages += 1;
}
// Track by scope
if let Some(ref scope) = usage.scope {
*progress.usages_by_scope.entry(scope.to_string()).or_insert(0) += 1;
}
// Track by project
*progress.usages_by_project.entry(usage.project_hash.clone()).or_insert(0) += 1;
// Track last usage
if progress.last_usage_detected.map_or(true, |t| usage.last_detected > t) {
progress.last_usage_detected = Some(usage.last_detected);
}
}
progress
}
/// Get all usages by scope.
pub fn get_usages_by_scope(&self, scope: &ScopeId) -> Vec<DeprecatedUsage> {
let cache = match self.cache.read() {
Ok(c) => c,
Err(_) => return vec![],
};
cache.values().filter(|u| u.scope.as_ref() == Some(scope)).cloned().collect()
}
/// Get total usage count.
pub fn total_usages(&self) -> usize {
self.cache.read().map(|c| c.len()).unwrap_or(0)
}
/// Get resolved usage count.
pub fn resolved_count(&self) -> usize {
let cache = match self.cache.read() {
Ok(c) => c,
Err(_) => return 0,
};
cache.values().filter(|u| u.resolved).count()
}
/// Export usages to CSV format.
pub fn export_csv(&self, include_resolved: bool) -> String {
let cache = match self.cache.read() {
Ok(c) => c,
Err(_) => return String::new(),
};
let mut csv = String::from("pattern_id,pattern_name,file_path,line,project_hash,scope,first_detected,last_detected,resolved\n");
for usage in cache.values() {
if !include_resolved && usage.resolved {
continue;
}
let scope_str = usage.scope.as_ref().map(|s| s.to_string()).unwrap_or_default();
csv.push_str(&format!(
"{},{},{},{},{},{},{},{},{}\n",
usage.pattern_id,
usage.pattern_name,
usage.file_path,
usage.line,
usage.project_hash,
scope_str,
usage.first_detected.format("%Y-%m-%d"),
usage.last_detected.format("%Y-%m-%d"),
usage.resolved
));
}
csv
}
/// Persist the cache to disk.
fn persist(&self) -> Result<(), AphoriaError> {
let cache = self.cache.read().map_err(|e| {
AphoriaError::LearningStore(format!("Failed to acquire read lock: {}", e))
})?;
let usages: Vec<&DeprecatedUsage> = cache.values().collect();
let content = serde_json::to_string_pretty(&usages).map_err(|e| {
AphoriaError::LearningStore(format!("Failed to serialize migrations: {}", e))
})?;
fs::write(&self.path, content).map_err(|e| {
AphoriaError::LearningStore(format!("Failed to write migrations file: {}", e))
})?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn create_test_store() -> (MigrationStore, tempfile::TempDir) {
let dir = tempdir().expect("create temp dir");
let store = MigrationStore::new(dir.path()).expect("create store");
(store, dir)
}
#[test]
fn test_deprecated_usage_new() {
let usage =
DeprecatedUsage::new(Uuid::new_v4(), "tls_min_version", "src/config.rs", 42, "hash123");
assert!(!usage.resolved);
assert!(usage.resolved_at.is_none());
}
#[test]
fn test_deprecated_usage_resolve() {
let mut usage =
DeprecatedUsage::new(Uuid::new_v4(), "tls_min_version", "src/config.rs", 42, "hash123");
usage.resolve();
assert!(usage.resolved);
assert!(usage.resolved_at.is_some());
}
#[test]
fn test_migration_progress_completion() {
let mut progress = MigrationProgress::new(Uuid::new_v4(), "test_pattern");
// Empty = 100%
assert_eq!(progress.completion_percent(), 100.0);
progress.total_usages = 10;
assert_eq!(progress.completion_percent(), 0.0);
progress.resolved_usages = 5;
assert_eq!(progress.completion_percent(), 50.0);
progress.resolved_usages = 10;
assert_eq!(progress.completion_percent(), 100.0);
assert!(progress.is_complete());
}
#[test]
fn test_migration_store_record_usage() {
let (store, _dir) = create_test_store();
let usage =
DeprecatedUsage::new(Uuid::new_v4(), "tls_min_version", "src/config.rs", 42, "hash123");
store.record_usage(usage).expect("record");
assert_eq!(store.total_usages(), 1);
}
#[test]
fn test_migration_store_resolve_usage() {
let (store, _dir) = create_test_store();
let pattern_id = Uuid::new_v4();
let usage =
DeprecatedUsage::new(pattern_id, "tls_min_version", "src/config.rs", 42, "hash123");
store.record_usage(usage).expect("record");
// Resolve it
let resolved = store.resolve_usage(&pattern_id, "src/config.rs", 42).expect("resolve");
assert!(resolved);
assert_eq!(store.resolved_count(), 1);
}
#[test]
fn test_migration_store_get_progress() {
let (store, _dir) = create_test_store();
let pattern_id = Uuid::new_v4();
// Add usages
for i in 0..5 {
let usage = DeprecatedUsage::new(
pattern_id,
"tls_min_version",
format!("src/file{}.rs", i),
10,
format!("project{}", i % 2), // 2 different projects
);
store.record_usage(usage).expect("record");
}
// Resolve 2
store.resolve_usage(&pattern_id, "src/file0.rs", 10).expect("resolve");
store.resolve_usage(&pattern_id, "src/file1.rs", 10).expect("resolve");
let progress = store.get_progress(&pattern_id, "tls_min_version");
assert_eq!(progress.total_usages, 5);
assert_eq!(progress.resolved_usages, 2);
assert_eq!(progress.pending_usages(), 3);
assert_eq!(progress.completion_percent(), 40.0);
assert_eq!(progress.usages_by_project.len(), 2);
}
#[test]
fn test_migration_store_export_csv() {
let (store, _dir) = create_test_store();
let pattern_id = Uuid::new_v4();
let usage = DeprecatedUsage::new(pattern_id, "test_pattern", "src/test.rs", 42, "hash123");
store.record_usage(usage).expect("record");
let csv = store.export_csv(true);
assert!(csv.contains("pattern_id"));
assert!(csv.contains("test_pattern"));
assert!(csv.contains("src/test.rs"));
assert!(csv.contains("42"));
}
#[test]
fn test_migration_store_persistence() {
let dir = tempdir().expect("create temp dir");
let pattern_id = Uuid::new_v4();
// Create store and add usage
{
let store = MigrationStore::new(dir.path()).expect("create store");
let usage =
DeprecatedUsage::new(pattern_id, "persist_test", "src/persist.rs", 100, "hash456");
store.record_usage(usage).expect("record");
}
// Reopen store and verify
{
let store = MigrationStore::new(dir.path()).expect("reopen store");
assert_eq!(store.total_usages(), 1);
let usages = store.get_usages(&pattern_id);
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].pattern_name, "persist_test");
}
}
}

View File

@ -0,0 +1,52 @@
//! Knowledge lifecycle management for pattern deprecation and migration.
//!
//! Implements the knowledge lifecycle state machine:
//!
//! ```text
//! Active ────────────────────────┐
//! │ │
//! ├── deprecate ──────┐ │
//! │ ▼ │
//! │ Deprecated │
//! │ │ │
//! │ ┌─────────────┤ │
//! │ │ │ │
//! │ reactivate archive │
//! │ │ │ │
//! │ ▼ ▼ │
//! └─────┬─────────► Archived │
//! │ │ │
//! │ (terminal) │
//! │ │
//! └─ superseded ────────┘
//! │
//! ▼
//! Superseded
//! (terminal)
//! ```
//!
//! # Features
//!
//! - **Deprecation**: Mark patterns as deprecated with reason, superseded_by, sunset date
//! - **Migration tracking**: Track usage of deprecated patterns across projects
//! - **Audit trail**: Full history of status transitions with timestamp and initiator
//! - **Soft removal**: Deprecated patterns FLAG (not BLOCK) to allow gradual migration
mod migration;
mod store;
mod types;
pub use migration::{DeprecatedUsage, MigrationProgress, MigrationStore};
pub use store::LifecycleStore;
pub use types::{KnowledgeLifecycle, KnowledgeStatus, StatusTransition};
use std::path::PathBuf;
/// Get the default lifecycle store directory.
pub fn lifecycle_store_dir() -> PathBuf {
if let Some(home) = dirs::home_dir() {
home.join(".aphoria").join("lifecycle")
} else {
PathBuf::from(".aphoria/lifecycle")
}
}

View File

@ -0,0 +1,392 @@
//! Lifecycle store for persisting status transitions and audit trail.
//!
//! Provides JSON-backed storage for pattern lifecycle events, following
//! the same pattern as LocalPatternStore.
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use uuid::Uuid;
use crate::error::AphoriaError;
use super::types::{KnowledgeStatus, StatusTransition};
/// JSON-backed lifecycle store.
///
/// Stores status transitions in `~/.aphoria/lifecycle/transitions.json`
/// with in-memory caching and write-through persistence.
pub struct LifecycleStore {
/// Path to the transitions JSON file.
path: PathBuf,
/// In-memory cache of transitions, keyed by pattern ID.
///
/// Each pattern can have multiple transitions (audit trail).
cache: RwLock<HashMap<Uuid, Vec<StatusTransition>>>,
}
impl LifecycleStore {
/// Create a new lifecycle store.
///
/// Creates the storage directory if it doesn't exist.
pub fn new(store_dir: &Path) -> Result<Self, AphoriaError> {
let path = store_dir.join("transitions.json");
// Ensure directory exists
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
AphoriaError::LearningStore(format!("Failed to create lifecycle directory: {}", e))
})?;
}
// Load existing transitions if file exists
let cache = if path.exists() {
let content = fs::read_to_string(&path).map_err(|e| {
AphoriaError::LearningStore(format!("Failed to read transitions file: {}", e))
})?;
// Handle empty file
if content.trim().is_empty() {
RwLock::new(HashMap::new())
} else {
let transitions: Vec<StatusTransition> =
serde_json::from_str(&content).map_err(|e| {
AphoriaError::LearningStore(format!(
"Failed to parse transitions file: {}",
e
))
})?;
// Group by pattern_id
let mut map: HashMap<Uuid, Vec<StatusTransition>> = HashMap::new();
for t in transitions {
map.entry(t.pattern_id).or_default().push(t);
}
// Sort each list by timestamp
for transitions in map.values_mut() {
transitions.sort_by_key(|t| t.timestamp);
}
RwLock::new(map)
}
} else {
RwLock::new(HashMap::new())
};
Ok(Self { path, cache })
}
/// Open a lifecycle store at the default location.
pub fn open_default() -> Result<Self, AphoriaError> {
Self::new(&super::lifecycle_store_dir())
}
/// Record a status transition.
///
/// Appends the transition to the pattern's history and persists to disk.
pub fn record_transition(&self, transition: StatusTransition) -> Result<(), AphoriaError> {
{
let mut cache = self.cache.write().map_err(|e| {
AphoriaError::LearningStore(format!("Failed to acquire write lock: {}", e))
})?;
cache.entry(transition.pattern_id).or_default().push(transition);
}
self.persist()
}
/// Get the transition history for a pattern.
///
/// Returns transitions in chronological order (oldest first).
pub fn get_history(&self, pattern_id: &Uuid) -> Vec<StatusTransition> {
let cache = match self.cache.read() {
Ok(c) => c,
Err(_) => return vec![],
};
cache.get(pattern_id).cloned().unwrap_or_default()
}
/// Get the current status for a pattern based on most recent transition.
///
/// Returns None if no transitions exist for this pattern.
pub fn get_current_status(&self, pattern_id: &Uuid) -> Option<KnowledgeStatus> {
let history = self.get_history(pattern_id);
history.last().map(|t| t.to_status.clone())
}
/// Get all patterns with a specific status.
pub fn get_patterns_by_status(&self, status_name: &str) -> Vec<Uuid> {
let cache = match self.cache.read() {
Ok(c) => c,
Err(_) => return vec![],
};
cache
.iter()
.filter_map(|(pattern_id, transitions)| {
transitions
.last()
.filter(|t| t.to_status.status_name() == status_name)
.map(|_| *pattern_id)
})
.collect()
}
/// Get all deprecated patterns.
pub fn get_deprecated_patterns(&self) -> Vec<(Uuid, KnowledgeStatus)> {
let cache = match self.cache.read() {
Ok(c) => c,
Err(_) => return vec![],
};
cache
.iter()
.filter_map(|(pattern_id, transitions)| {
transitions.last().and_then(|t| {
if t.to_status.is_deprecated() {
Some((*pattern_id, t.to_status.clone()))
} else {
None
}
})
})
.collect()
}
/// Get patterns that are past their sunset date.
pub fn get_overdue_patterns(&self) -> Vec<(Uuid, KnowledgeStatus)> {
self.get_deprecated_patterns()
.into_iter()
.filter(|(_, status)| status.is_past_sunset())
.collect()
}
/// Get the total number of tracked patterns.
pub fn pattern_count(&self) -> usize {
self.cache.read().map(|c| c.len()).unwrap_or(0)
}
/// Get the total number of transitions.
pub fn transition_count(&self) -> usize {
let cache = match self.cache.read() {
Ok(c) => c,
Err(_) => return 0,
};
cache.values().map(|v| v.len()).sum()
}
/// Persist the cache to disk.
fn persist(&self) -> Result<(), AphoriaError> {
let cache = self.cache.read().map_err(|e| {
AphoriaError::LearningStore(format!("Failed to acquire read lock: {}", e))
})?;
// Flatten all transitions into a single list
let transitions: Vec<&StatusTransition> = cache.values().flatten().collect();
let content = serde_json::to_string_pretty(&transitions).map_err(|e| {
AphoriaError::LearningStore(format!("Failed to serialize transitions: {}", e))
})?;
fs::write(&self.path, content).map_err(|e| {
AphoriaError::LearningStore(format!("Failed to write transitions file: {}", e))
})?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use tempfile::tempdir;
fn create_test_store() -> (LifecycleStore, tempfile::TempDir) {
let dir = tempdir().expect("create temp dir");
let store = LifecycleStore::new(dir.path()).expect("create store");
(store, dir)
}
#[test]
fn test_lifecycle_store_new() {
let (store, _dir) = create_test_store();
assert_eq!(store.pattern_count(), 0);
assert_eq!(store.transition_count(), 0);
}
#[test]
fn test_record_transition() {
let (store, _dir) = create_test_store();
let pattern_id = Uuid::new_v4();
let transition = StatusTransition::new(
pattern_id,
KnowledgeStatus::Active,
KnowledgeStatus::Deprecated {
reason: "Test".to_string(),
superseded_by: None,
sunset_date: None,
migration_guide: None,
},
"test",
None,
);
store.record_transition(transition).expect("record");
assert_eq!(store.pattern_count(), 1);
assert_eq!(store.transition_count(), 1);
}
#[test]
fn test_get_history() {
let (store, _dir) = create_test_store();
let pattern_id = Uuid::new_v4();
// No history initially
let history = store.get_history(&pattern_id);
assert!(history.is_empty());
// Record transitions
let t1 = StatusTransition::new(
pattern_id,
KnowledgeStatus::Active,
KnowledgeStatus::Deprecated {
reason: "First".to_string(),
superseded_by: None,
sunset_date: None,
migration_guide: None,
},
"admin",
None,
);
store.record_transition(t1).expect("record");
let t2 = StatusTransition::new(
pattern_id,
KnowledgeStatus::Deprecated {
reason: "First".to_string(),
superseded_by: None,
sunset_date: None,
migration_guide: None,
},
KnowledgeStatus::Archived { archived_at: Utc::now(), reason: "Done".to_string() },
"admin",
None,
);
store.record_transition(t2).expect("record");
let history = store.get_history(&pattern_id);
assert_eq!(history.len(), 2);
}
#[test]
fn test_get_current_status() {
let (store, _dir) = create_test_store();
let pattern_id = Uuid::new_v4();
// No status initially
assert!(store.get_current_status(&pattern_id).is_none());
// Record transition
let transition = StatusTransition::new(
pattern_id,
KnowledgeStatus::Active,
KnowledgeStatus::Deprecated {
reason: "Test".to_string(),
superseded_by: None,
sunset_date: None,
migration_guide: None,
},
"test",
None,
);
store.record_transition(transition).expect("record");
let status = store.get_current_status(&pattern_id);
assert!(status.is_some());
assert!(matches!(status.unwrap(), KnowledgeStatus::Deprecated { .. }));
}
#[test]
fn test_get_deprecated_patterns() {
let (store, _dir) = create_test_store();
let pattern1 = Uuid::new_v4();
let pattern2 = Uuid::new_v4();
// Pattern 1: deprecated
store
.record_transition(StatusTransition::new(
pattern1,
KnowledgeStatus::Active,
KnowledgeStatus::Deprecated {
reason: "Old".to_string(),
superseded_by: None,
sunset_date: None,
migration_guide: None,
},
"test",
None,
))
.expect("record");
// Pattern 2: archived (not deprecated)
store
.record_transition(StatusTransition::new(
pattern2,
KnowledgeStatus::Active,
KnowledgeStatus::Archived { archived_at: Utc::now(), reason: "Done".to_string() },
"test",
None,
))
.expect("record");
let deprecated = store.get_deprecated_patterns();
assert_eq!(deprecated.len(), 1);
assert_eq!(deprecated[0].0, pattern1);
}
#[test]
fn test_persistence() {
let dir = tempdir().expect("create temp dir");
let pattern_id = Uuid::new_v4();
// Create store and add transition
{
let store = LifecycleStore::new(dir.path()).expect("create store");
store
.record_transition(StatusTransition::new(
pattern_id,
KnowledgeStatus::Active,
KnowledgeStatus::Deprecated {
reason: "Persist test".to_string(),
superseded_by: None,
sunset_date: None,
migration_guide: None,
},
"test",
None,
))
.expect("record");
}
// Reopen store and verify
{
let store = LifecycleStore::new(dir.path()).expect("reopen store");
assert_eq!(store.pattern_count(), 1);
let status = store.get_current_status(&pattern_id);
assert!(status.is_some());
assert!(status.unwrap().is_deprecated());
}
}
}

View File

@ -0,0 +1,433 @@
//! Core types for knowledge lifecycle management.
//!
//! These types track the lifecycle state of patterns through deprecation,
//! supersession, and archival with full audit trail.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Lifecycle status of a learned pattern.
///
/// Patterns move through this state machine as they age, become obsolete,
/// or are replaced by better alternatives.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum KnowledgeStatus {
/// Pattern is active and should trigger matches.
///
/// This is the default state for all patterns.
Active,
/// Pattern is deprecated but still active.
///
/// Deprecated patterns continue to FLAG during scans but include
/// migration guidance pointing to the replacement pattern.
Deprecated {
/// Reason for deprecation.
reason: String,
/// Pattern ID that supersedes this one (if any).
#[serde(skip_serializing_if = "Option::is_none")]
superseded_by: Option<Uuid>,
/// When this pattern should be fully removed.
#[serde(skip_serializing_if = "Option::is_none")]
sunset_date: Option<DateTime<Utc>>,
/// URL or text with migration guidance.
#[serde(skip_serializing_if = "Option::is_none")]
migration_guide: Option<String>,
},
/// Pattern was replaced by another and is no longer active.
///
/// Superseded patterns do not match during scans; they exist only
/// for historical reference and linking.
Superseded {
/// The pattern that replaced this one.
replaced_by: Uuid,
/// When this pattern was superseded.
superseded_at: DateTime<Utc>,
},
/// Pattern is permanently archived (soft deleted).
///
/// Archived patterns are not used for matching and are hidden
/// from default listings. They remain for audit purposes.
Archived {
/// When this pattern was archived.
archived_at: DateTime<Utc>,
/// Reason for archival.
reason: String,
},
}
impl Default for KnowledgeStatus {
fn default() -> Self {
Self::Active
}
}
impl KnowledgeStatus {
/// Check if this status is active (pattern should match during scans).
pub fn is_active(&self) -> bool {
matches!(self, Self::Active | Self::Deprecated { .. })
}
/// Check if this status is deprecated.
pub fn is_deprecated(&self) -> bool {
matches!(self, Self::Deprecated { .. })
}
/// Check if this status is a terminal state (superseded or archived).
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Superseded { .. } | Self::Archived { .. })
}
/// Get the status name as a string.
pub fn status_name(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Deprecated { .. } => "deprecated",
Self::Superseded { .. } => "superseded",
Self::Archived { .. } => "archived",
}
}
/// Get the sunset date if deprecated.
pub fn sunset_date(&self) -> Option<DateTime<Utc>> {
match self {
Self::Deprecated { sunset_date, .. } => *sunset_date,
_ => None,
}
}
/// Get the superseding pattern ID if deprecated or superseded.
pub fn superseded_by(&self) -> Option<Uuid> {
match self {
Self::Deprecated { superseded_by, .. } => *superseded_by,
Self::Superseded { replaced_by, .. } => Some(*replaced_by),
_ => None,
}
}
/// Check if this pattern is past its sunset date.
pub fn is_past_sunset(&self) -> bool {
self.sunset_date().map(|date| date <= Utc::now()).unwrap_or(false)
}
/// Days until sunset date (negative if past).
pub fn days_until_sunset(&self) -> Option<i64> {
self.sunset_date().map(|date| (date - Utc::now()).num_days())
}
}
impl std::fmt::Display for KnowledgeStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Active => write!(f, "Active"),
Self::Deprecated { reason, sunset_date, .. } => {
write!(f, "Deprecated: {}", reason)?;
if let Some(date) = sunset_date {
write!(f, " (sunset: {})", date.format("%Y-%m-%d"))?;
}
Ok(())
}
Self::Superseded { replaced_by, superseded_at } => {
write!(f, "Superseded by {} on {}", replaced_by, superseded_at.format("%Y-%m-%d"))
}
Self::Archived { archived_at, reason } => {
write!(f, "Archived on {}: {}", archived_at.format("%Y-%m-%d"), reason)
}
}
}
}
/// Complete lifecycle metadata for a pattern.
///
/// Wraps the current status with creation timestamp and optional
/// additional metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeLifecycle {
/// Current lifecycle status.
#[serde(flatten)]
pub status: KnowledgeStatus,
/// When this lifecycle record was created.
///
/// This is the creation of the lifecycle tracking, not the pattern itself.
#[serde(default = "Utc::now")]
pub lifecycle_created_at: DateTime<Utc>,
/// When the status was last changed.
#[serde(default = "Utc::now")]
pub last_status_change: DateTime<Utc>,
}
impl Default for KnowledgeLifecycle {
fn default() -> Self {
let now = Utc::now();
Self {
status: KnowledgeStatus::default(),
lifecycle_created_at: now,
last_status_change: now,
}
}
}
impl KnowledgeLifecycle {
/// Create a new lifecycle with Active status.
pub fn new() -> Self {
Self::default()
}
/// Create a lifecycle with a specific initial status.
pub fn with_status(status: KnowledgeStatus) -> Self {
let now = Utc::now();
Self { status, lifecycle_created_at: now, last_status_change: now }
}
/// Update the status and record the change time.
pub fn update_status(&mut self, new_status: KnowledgeStatus) {
self.status = new_status;
self.last_status_change = Utc::now();
}
/// Check if this lifecycle is active (pattern should match during scans).
pub fn is_active(&self) -> bool {
self.status.is_active()
}
/// Check if this lifecycle is deprecated.
pub fn is_deprecated(&self) -> bool {
self.status.is_deprecated()
}
/// Check if this lifecycle is in a terminal state (superseded or archived).
pub fn is_terminal(&self) -> bool {
self.status.is_terminal()
}
}
/// Audit record for a status transition.
///
/// Every status change is recorded with full context for compliance
/// and debugging purposes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatusTransition {
/// Unique identifier for this transition.
pub id: Uuid,
/// ID of the pattern whose status changed.
pub pattern_id: Uuid,
/// Status before the transition.
pub from_status: KnowledgeStatus,
/// Status after the transition.
pub to_status: KnowledgeStatus,
/// Who initiated this transition (username or system).
pub initiated_by: String,
/// When the transition occurred.
pub timestamp: DateTime<Utc>,
/// Optional comment explaining the transition.
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
impl StatusTransition {
/// Create a new status transition.
pub fn new(
pattern_id: Uuid,
from_status: KnowledgeStatus,
to_status: KnowledgeStatus,
initiated_by: impl Into<String>,
comment: Option<String>,
) -> Self {
Self {
id: Uuid::new_v4(),
pattern_id,
from_status,
to_status,
initiated_by: initiated_by.into(),
timestamp: Utc::now(),
comment,
}
}
/// Get a human-readable description of this transition.
pub fn description(&self) -> String {
format!(
"{} transitioned from {} to {}",
self.pattern_id,
self.from_status.status_name(),
self.to_status.status_name()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_knowledge_status_default() {
assert!(matches!(KnowledgeStatus::default(), KnowledgeStatus::Active));
}
#[test]
fn test_knowledge_status_is_active() {
assert!(KnowledgeStatus::Active.is_active());
let deprecated = KnowledgeStatus::Deprecated {
reason: "Test".to_string(),
superseded_by: None,
sunset_date: None,
migration_guide: None,
};
assert!(deprecated.is_active()); // Deprecated patterns still match
let superseded =
KnowledgeStatus::Superseded { replaced_by: Uuid::new_v4(), superseded_at: Utc::now() };
assert!(!superseded.is_active());
let archived =
KnowledgeStatus::Archived { archived_at: Utc::now(), reason: "Test".to_string() };
assert!(!archived.is_active());
}
#[test]
fn test_knowledge_status_is_terminal() {
assert!(!KnowledgeStatus::Active.is_terminal());
let deprecated = KnowledgeStatus::Deprecated {
reason: "Test".to_string(),
superseded_by: None,
sunset_date: None,
migration_guide: None,
};
assert!(!deprecated.is_terminal());
let superseded =
KnowledgeStatus::Superseded { replaced_by: Uuid::new_v4(), superseded_at: Utc::now() };
assert!(superseded.is_terminal());
let archived =
KnowledgeStatus::Archived { archived_at: Utc::now(), reason: "Test".to_string() };
assert!(archived.is_terminal());
}
#[test]
fn test_knowledge_status_sunset_date() {
let future = Utc::now() + chrono::Duration::days(30);
let deprecated = KnowledgeStatus::Deprecated {
reason: "Test".to_string(),
superseded_by: None,
sunset_date: Some(future),
migration_guide: None,
};
assert_eq!(deprecated.sunset_date(), Some(future));
assert!(!deprecated.is_past_sunset());
assert!(deprecated.days_until_sunset().unwrap_or(0) > 0);
let past = Utc::now() - chrono::Duration::days(1);
let past_deprecated = KnowledgeStatus::Deprecated {
reason: "Test".to_string(),
superseded_by: None,
sunset_date: Some(past),
migration_guide: None,
};
assert!(past_deprecated.is_past_sunset());
assert!(past_deprecated.days_until_sunset().unwrap_or(1) < 0);
}
#[test]
fn test_knowledge_status_serde() {
let active = KnowledgeStatus::Active;
let json = serde_json::to_string(&active).expect("serialize");
assert!(json.contains("\"status\":\"active\""));
let deprecated = KnowledgeStatus::Deprecated {
reason: "Outdated".to_string(),
superseded_by: Some(Uuid::nil()),
sunset_date: None,
migration_guide: Some("https://example.com".to_string()),
};
let json = serde_json::to_string(&deprecated).expect("serialize");
assert!(json.contains("\"status\":\"deprecated\""));
assert!(json.contains("\"reason\":\"Outdated\""));
assert!(json.contains("\"superseded_by\":\"00000000-0000-0000-0000-000000000000\""));
}
#[test]
fn test_knowledge_lifecycle_default() {
let lifecycle = KnowledgeLifecycle::default();
assert!(lifecycle.is_active());
assert!(!lifecycle.is_deprecated());
assert!(!lifecycle.is_terminal());
}
#[test]
fn test_knowledge_lifecycle_update_status() {
let mut lifecycle = KnowledgeLifecycle::new();
assert!(lifecycle.is_active());
let deprecated = KnowledgeStatus::Deprecated {
reason: "Test deprecation".to_string(),
superseded_by: None,
sunset_date: None,
migration_guide: None,
};
lifecycle.update_status(deprecated);
assert!(lifecycle.is_deprecated());
assert!(lifecycle.is_active()); // Still matches
}
#[test]
fn test_status_transition_creation() {
let pattern_id = Uuid::new_v4();
let from = KnowledgeStatus::Active;
let to = KnowledgeStatus::Deprecated {
reason: "Outdated".to_string(),
superseded_by: None,
sunset_date: None,
migration_guide: None,
};
let transition = StatusTransition::new(
pattern_id,
from,
to,
"admin",
Some("Deprecating old pattern".to_string()),
);
assert_eq!(transition.pattern_id, pattern_id);
assert_eq!(transition.initiated_by, "admin");
assert!(transition.comment.is_some());
}
#[test]
fn test_status_transition_description() {
let pattern_id = Uuid::new_v4();
let transition = StatusTransition::new(
pattern_id,
KnowledgeStatus::Active,
KnowledgeStatus::Archived { archived_at: Utc::now(), reason: "Done".to_string() },
"system",
None,
);
let desc = transition.description();
assert!(desc.contains("active"));
assert!(desc.contains("archived"));
}
}

View File

@ -88,6 +88,12 @@ pub struct PackHeader {
pub issuer_id: [u8; 32],
/// Creation timestamp (Unix epoch).
pub timestamp: u64,
/// Human-readable name of the signer (e.g., "Platform Security Team").
/// Optional for backward compatibility with older packs.
pub signer_name: Option<String>,
/// Contact info for the signer (e.g., "#security-policy", "security@acme.com").
/// Optional for backward compatibility with older packs.
pub contact: Option<String>,
}
impl TrustPack {
@ -108,12 +114,15 @@ impl TrustPack {
aliases,
Vec::new(),
signing_key,
None,
None,
)
}
/// Create a new Trust Pack with predicate aliases.
///
/// Signs the content automatically.
#[allow(clippy::too_many_arguments)]
pub fn new_with_predicate_aliases(
name: String,
version: String,
@ -121,12 +130,14 @@ impl TrustPack {
aliases: Vec<ConceptAlias>,
predicate_aliases: Vec<PackPredicateAliasSet>,
signing_key: &SigningKey,
signer_name: Option<String>,
contact: Option<String>,
) -> Result<Self, AphoriaError> {
let timestamp = current_timestamp();
let issuer_id = signing_key.verifying_key().to_bytes();
let header = PackHeader { name, version, issuer_id, timestamp };
let header = PackHeader { name, version, issuer_id, timestamp, signer_name, contact };
// Create temporary pack with zeroed signature to compute hash
let temp_pack = TrustPack {
@ -224,6 +235,7 @@ impl TrustPack {
///
/// This is used for key rotation. The old signature is added to the
/// signature chain for audit purposes.
#[allow(clippy::too_many_arguments)]
pub fn resign(
name: String,
version: String,
@ -232,12 +244,14 @@ impl TrustPack {
predicate_aliases: Vec<PackPredicateAliasSet>,
signing_key: &SigningKey,
signature_chain: Vec<SignatureRecord>,
signer_name: Option<String>,
contact: Option<String>,
) -> Result<Self, AphoriaError> {
let timestamp = current_timestamp();
let issuer_id = signing_key.verifying_key().to_bytes();
let header = PackHeader { name, version, issuer_id, timestamp };
let header = PackHeader { name, version, issuer_id, timestamp, signer_name, contact };
// Create temporary pack with zeroed signature to compute hash
let temp_pack = TrustPack {

View File

@ -61,6 +61,8 @@ pub async fn export_policy(
aliases,
predicate_aliases,
&signing_key,
config.trust_pack.signer_name.clone(),
config.trust_pack.contact.clone(),
)?;
pack.save(&output)?;
@ -131,6 +133,8 @@ pub async fn import_policy(
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(),
};
// Update predicate indexes and store pack source for all assertions
@ -440,6 +444,7 @@ pub async fn resign_policy(
}
// 4. Create new pack with updated signature
// Preserve signer info from original pack
let new_pack = TrustPack::resign(
pack.header.name.clone(),
pack.header.version.clone(),
@ -448,6 +453,8 @@ pub async fn resign_policy(
pack.predicate_aliases.clone(),
&signing_key,
chain.clone(),
pack.header.signer_name.clone(),
pack.header.contact.clone(),
)?;
// 5. Save to output

View File

@ -25,7 +25,8 @@ use super::regex_gen::RegexGenerator;
use super::types::{PromotionCandidate, PromotionStats, ValidationResult};
use super::validator::ExtractorValidator;
use super::writer::YamlWriter;
use crate::config::{AutonomousConfig, PromotionConfig};
use crate::config::{AutonomousConfig, GovernanceConfig, PromotionConfig};
use crate::governance::{ApprovalStatus, GovernanceStateMachine, GovernanceStore};
use crate::learning::{LearnedPattern, PatternStore};
use crate::llm::GeminiClient;
use crate::AphoriaError;
@ -95,7 +96,21 @@ impl<'a, S: PatternStore> PromotionPipeline<'a, S> {
/// Promote a candidate by writing it to YAML and marking the pattern as promoted.
///
/// Returns the path to the written YAML file.
///
/// If governance is enabled, this will check for an approved governance request.
/// If no request exists or it's not approved, the promotion will be blocked.
pub fn promote(&self, candidate: &PromotionCandidate) -> Result<PathBuf, AphoriaError> {
self.promote_with_governance(candidate, None)
}
/// Promote with optional governance configuration.
///
/// When `governance_config` is provided and enabled, checks for governance approval.
pub fn promote_with_governance(
&self,
candidate: &PromotionCandidate,
governance_config: Option<&GovernanceConfig>,
) -> Result<PathBuf, AphoriaError> {
// Check if candidate is ready
if !candidate.is_ready() {
return Err(AphoriaError::Promotion(format!(
@ -106,6 +121,13 @@ impl<'a, S: PatternStore> PromotionPipeline<'a, S> {
)));
}
// Check governance if enabled
if let Some(gov_config) = governance_config {
if gov_config.enabled {
self.check_governance_approval(candidate, gov_config)?;
}
}
// Get or create writer
let writer = if let Some(ref w) = self.writer {
w
@ -137,6 +159,88 @@ impl<'a, S: PatternStore> PromotionPipeline<'a, S> {
Ok(path)
}
/// Check if a pattern has governance approval for promotion.
fn check_governance_approval(
&self,
candidate: &PromotionCandidate,
governance_config: &GovernanceConfig,
) -> Result<(), AphoriaError> {
let store = GovernanceStore::open_default()?;
let pattern_id = candidate.pattern_id();
match store.get_request_by_pattern(&pattern_id)? {
Some(request) => {
match &request.status {
ApprovalStatus::Approved => {
// Approved - can proceed with promotion
debug!(
pattern_id = %pattern_id,
request_id = %request.id,
"Governance approval verified"
);
Ok(())
}
ApprovalStatus::Pending { stage } => Err(AphoriaError::Promotion(format!(
"Pattern awaiting governance approval at stage '{}'. Request ID: {}",
stage, request.id
))),
ApprovalStatus::Rejected { stage, reason } => {
Err(AphoriaError::Promotion(format!(
"Pattern was rejected at stage '{}': {}. Request ID: {}",
stage, reason, request.id
)))
}
ApprovalStatus::Escalated { from_stage, to_stage } => {
Err(AphoriaError::Promotion(format!(
"Pattern was escalated from '{}' to '{}'. Request ID: {}",
from_stage, to_stage, request.id
)))
}
ApprovalStatus::Expired => Err(AphoriaError::Promotion(format!(
"Pattern approval request expired. Create a new request. Request ID: {}",
request.id
))),
}
}
None => {
// No approval request exists - create one
self.create_governance_request(candidate, governance_config)?;
Err(AphoriaError::Promotion(
"Approval request created. Pattern awaiting governance review.".to_string(),
))
}
}
}
/// Create a governance approval request for a pattern.
fn create_governance_request(
&self,
candidate: &PromotionCandidate,
governance_config: &GovernanceConfig,
) -> Result<(), AphoriaError> {
let sm = GovernanceStateMachine::open_default(governance_config.clone())?;
// Get the appropriate workflow
let workflow = sm.get_workflow_for_pattern(&candidate.pattern).ok_or_else(|| {
AphoriaError::Promotion(
"No governance workflow configured for this pattern".to_string(),
)
})?;
// Create the request
let creator = whoami::username();
let request = sm.create_request(&candidate.pattern, &workflow, &creator)?;
info!(
pattern_id = %candidate.pattern_id(),
request_id = %request.id,
workflow = %workflow.name,
"Created governance approval request"
);
Ok(())
}
/// Process all eligible patterns and return promotion candidates.
///
/// Generates and validates extractors for each eligible pattern.

View File

@ -33,11 +33,22 @@ impl ReportFormatter for JsonReport {
}
// Add policy source if this came from a Trust Pack
if let Some(ps) = &source.policy_source {
source_json["policy_source"] = serde_json::json!({
let mut policy_source = serde_json::json!({
"pack_name": ps.pack_name,
"pack_version": ps.pack_version,
"issuer_hex": ps.issuer_hex,
});
// Add signer_name if available
if let Some(signer) = &ps.signer_name {
policy_source["signer_name"] =
serde_json::Value::String(signer.clone());
}
// Add contact if available
if let Some(contact) = &ps.contact {
policy_source["contact"] =
serde_json::Value::String(contact.clone());
}
source_json["policy_source"] = policy_source;
}
source_json
})
@ -94,7 +105,35 @@ impl ReportFormatter for JsonReport {
})
.collect();
let report = serde_json::json!({
// Build deprecated_usages array
let deprecated_json: Vec<serde_json::Value> = result
.deprecated_usages
.iter()
.map(|usage| {
let mut json = serde_json::json!({
"pattern_id": usage.pattern_id.to_string(),
"pattern_name": usage.pattern_name,
"file_path": usage.file_path,
"line": usage.line,
"reason": usage.reason,
"severity": usage.severity(),
});
if let Some(ref s) = usage.superseded_by {
json["superseded_by"] = serde_json::Value::String(s.clone());
}
if let Some(ref g) = usage.migration_guide {
json["migration_guide"] = serde_json::Value::String(g.clone());
}
if let Some(days) = usage.days_until_sunset {
json["days_until_sunset"] = serde_json::json!(days);
}
json
})
.collect();
let mut report = serde_json::json!({
"project": result.project,
"scan_id": result.scan_id,
"summary": {
@ -106,12 +145,28 @@ impl ReportFormatter for JsonReport {
"acks": result.count_by_verdict(Verdict::Ack),
"passes": result.count_by_verdict(Verdict::Pass),
"drifts": result.drift_count(),
"deprecated_usages": result.deprecated_usage_count(),
"observations_recorded": result.observations_recorded,
},
"conflicts": conflicts_json,
"drifts": drifts_json,
"deprecated_usages": deprecated_json,
});
// Add timing if benchmark mode was enabled
if let Some(timing) = &result.timing {
let mut timing_json = serde_json::json!({
"walk_ms": timing.walk_ms,
"extraction_ms": timing.extraction_ms,
"conflict_ms": timing.conflict_ms,
"total_ms": timing.total_ms,
});
if let Some(loc) = timing.lines_of_code {
timing_json["lines_of_code"] = serde_json::json!(loc);
}
report["timing"] = timing_json;
}
// Pretty-print for readability
serde_json::to_string_pretty(&report).unwrap_or_else(|_| report.to_string())
}
@ -159,6 +214,8 @@ mod tests {
format: "json".to_string(),
debug: false,
observations_recorded: 0,
timing: None,
deprecated_usages: vec![],
};
let output = formatter.format(&result);
@ -166,6 +223,7 @@ mod tests {
assert_eq!(parsed["project"], "testproject");
assert_eq!(parsed["summary"]["conflicts"], 1);
assert_eq!(parsed["summary"]["deprecated_usages"], 0);
assert_eq!(parsed["summary"]["blocks"], 1);
assert_eq!(parsed["conflicts"][0]["verdict"], "BLOCK");
assert_eq!(parsed["conflicts"][0]["file"], "src/auth.rs");

View File

@ -113,6 +113,17 @@ impl ReportFormatter for MarkdownReport {
citation,
object_value_display(&source.value),
));
// Show policy source if this came from a Trust Pack
if let Some(policy) = &source.policy_source {
let signer = policy.signer_name.as_deref().unwrap_or(&policy.issuer_hex);
out.push_str(&format!(
" - *Source: {} v{} ({})*\n",
policy.pack_name, policy.pack_version, signer
));
if let Some(contact) = &policy.contact {
out.push_str(&format!(" - *Contact: {}*\n", contact));
}
}
}
out.push_str(&format!("- **Score:** {:.2}\n", conflict.conflict_score));
@ -178,6 +189,50 @@ impl ReportFormatter for MarkdownReport {
out.push_str("**Action:** Review these changes to ensure they were intentional.\n\n");
}
// Deprecated patterns section
if !result.deprecated_usages.is_empty() {
out.push_str("## Deprecated Patterns\n\n");
out.push_str("> ⚠️ **Migration Required:** The following patterns are deprecated and should be updated.\n\n");
out.push_str("| Pattern | File | Severity | Sunset |\n");
out.push_str("|---------|------|----------|--------|\n");
for usage in &result.deprecated_usages {
let sunset = usage
.days_until_sunset
.map(|d| if d < 0 { format!("OVERDUE ({}d)", -d) } else { format!("{}d", d) })
.unwrap_or_else(|| "-".to_string());
out.push_str(&format!(
"| `{}` | `{}:{}` | {} | {} |\n",
usage.pattern_name,
usage.file_path,
usage.line,
usage.severity(),
sunset,
));
}
out.push('\n');
// Details for each deprecated pattern
out.push_str("### Migration Details\n\n");
for usage in &result.deprecated_usages {
out.push_str(&format!("#### `{}`\n\n", usage.pattern_name));
out.push_str(&format!("- **Reason:** {}\n", usage.reason));
out.push_str(&format!("- **Location:** `{}:{}`\n", usage.file_path, usage.line));
if let Some(ref replacement) = usage.superseded_by {
out.push_str(&format!("- **Replacement:** `{}`\n", replacement));
}
if let Some(ref guide) = usage.migration_guide {
out.push_str(&format!("- **Migration Guide:** {}\n", guide));
}
out.push('\n');
}
}
out
}
}
@ -224,6 +279,8 @@ mod tests {
format: "markdown".to_string(),
debug: false,
observations_recorded: 0,
timing: None,
deprecated_usages: vec![],
};
let output = formatter.format(&result);

View File

@ -89,12 +89,25 @@ impl ReportFormatter for SarifReport {
.conflicts
.iter()
.map(|s| {
format!(
let mut detail = format!(
"{:?} (Tier {}): {}",
s.source_class,
s.source_class.tier(),
object_value_display(&s.value)
)
);
// Include policy source info if available
if let Some(ps) = &s.policy_source {
let signer = ps.signer_name.as_deref().unwrap_or(&ps.issuer_hex);
detail.push_str(&format!(
" [Source: {} v{} ({})",
ps.pack_name, ps.pack_version, signer
));
if let Some(contact) = &ps.contact {
detail.push_str(&format!(", Contact: {}", contact));
}
detail.push(']');
}
detail
})
.collect();
@ -195,9 +208,99 @@ impl ReportFormatter for SarifReport {
})
.collect();
// Combine conflict and drift results
// Add deprecated usage rules and results
for usage in &result.deprecated_usages {
let rule_id = format!("aphoria/deprecated/{}", usage.pattern_name);
if !rule_indices.contains_key(&rule_id) {
let idx = rules.len();
rule_indices.insert(rule_id.clone(), idx);
let level = match usage.severity() {
"OVERDUE" => "error",
"URGENT" => "warning",
_ => "note",
};
rules.push(serde_json::json!({
"id": rule_id,
"shortDescription": {
"text": format!("Deprecated pattern: {}", usage.pattern_name),
},
"fullDescription": {
"text": usage.reason.clone(),
},
"defaultConfiguration": {
"level": level,
},
"helpUri": usage.migration_guide.clone().unwrap_or_else(|| {
"https://github.com/orchard9/aphoria/docs/deprecation".to_string()
}),
}));
}
}
// Add deprecated usage results
let deprecated_results: Vec<serde_json::Value> = result
.deprecated_usages
.iter()
.map(|usage| {
let rule_id = format!("aphoria/deprecated/{}", usage.pattern_name);
let rule_index = rule_indices.get(&rule_id).copied().unwrap_or(0);
let level = match usage.severity() {
"OVERDUE" => "error",
"URGENT" => "warning",
_ => "note",
};
let mut message = format!(
"Deprecated pattern '{}' detected.\nReason: {}",
usage.pattern_name, usage.reason
);
if let Some(ref replacement) = usage.superseded_by {
message.push_str(&format!("\nReplace with: {}", replacement));
}
if let Some(days) = usage.days_until_sunset {
if days < 0 {
message.push_str(&format!("\nSunset: OVERDUE by {} days", -days));
} else {
message.push_str(&format!("\nSunset: {} days remaining", days));
}
}
serde_json::json!({
"ruleId": rule_id,
"ruleIndex": rule_index,
"level": level,
"message": {
"text": message,
},
"locations": [{
"physicalLocation": {
"artifactLocation": {
"uri": usage.file_path,
"uriBaseId": "%SRCROOT%",
},
"region": {
"startLine": usage.line,
}
}
}],
"properties": {
"pattern_id": usage.pattern_id.to_string(),
"severity": usage.severity(),
"days_until_sunset": usage.days_until_sunset,
}
})
})
.collect();
// Combine all results
let mut all_results = results;
all_results.extend(drift_results);
all_results.extend(deprecated_results);
let sarif = serde_json::json!({
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
@ -219,6 +322,7 @@ impl ReportFormatter for SarifReport {
"files_scanned": result.files_scanned,
"claims_extracted": result.claims_extracted,
"drifts_detected": result.drift_count(),
"deprecated_usages": result.deprecated_usage_count(),
}
}]
}]
@ -288,6 +392,8 @@ mod tests {
format: "sarif".to_string(),
debug: false,
observations_recorded: 0,
timing: None,
deprecated_usages: vec![],
};
let output = formatter.format(&result);

View File

@ -112,10 +112,16 @@ impl ReportFormatter for TableReport {
));
// Show policy source if this came from a Trust Pack
if let Some(policy) = &source.policy_source {
// Display signer name if available, fall back to issuer hex
let signer = policy.signer_name.as_deref().unwrap_or(&policy.issuer_hex);
output.push_str(&format!(
" Source: {} v{} ({})\n",
policy.pack_name, policy.pack_version, policy.issuer_hex
policy.pack_name, policy.pack_version, signer
));
// Display contact info if available
if let Some(contact) = &policy.contact {
output.push_str(&format!(" Contact: {}\n", contact));
}
}
}
@ -183,12 +189,44 @@ impl ReportFormatter for TableReport {
}
}
// Deprecated patterns section
if !result.deprecated_usages.is_empty() {
output.push_str("\nDeprecated Patterns:\n\n");
for usage in &result.deprecated_usages {
let severity = usage.severity();
output
.push_str(&format!(" {:<8} {} (deprecated)\n", severity, usage.pattern_name));
output
.push_str(&format!(" Location: {}:{}\n", usage.file_path, usage.line));
output.push_str(&format!(" Reason: {}\n", usage.reason));
if let Some(ref replacement) = usage.superseded_by {
output.push_str(&format!(" Replace: Use '{}'\n", replacement));
}
if let Some(ref guide) = usage.migration_guide {
output.push_str(&format!(" Guide: {}\n", guide));
}
if let Some(days) = usage.days_until_sunset {
if days < 0 {
output.push_str(&format!(" Sunset: OVERDUE by {} days\n", -days));
} else {
output.push_str(&format!(" Sunset: {} days remaining\n", days));
}
}
output.push('\n');
}
}
// Footer summary
let block_count = result.count_by_verdict(Verdict::Block);
let flag_count = result.count_by_verdict(Verdict::Flag);
let ack_count = result.count_by_verdict(Verdict::Ack);
let pass_count = result.count_by_verdict(Verdict::Pass);
let drift_count = result.drift_count();
let deprecated_count = result.deprecated_usage_count();
// Build summary parts, omitting zeros for cleaner output
let mut parts = Vec::new();
@ -201,6 +239,9 @@ impl ReportFormatter for TableReport {
if drift_count > 0 {
parts.push(format!("{} DRIFT", drift_count));
}
if deprecated_count > 0 {
parts.push(format!("{} DEPRECATED", deprecated_count));
}
if ack_count > 0 {
parts.push(format!("{} ACK", ack_count));
}
@ -221,10 +262,38 @@ impl ReportFormatter for TableReport {
));
}
// Show benchmark results if timing is available
if let Some(timing) = &result.timing {
output.push_str("\nBenchmark Results:\n");
output.push_str(&format!(" Files scanned: {}\n", result.files_scanned));
if let Some(loc) = timing.lines_of_code {
output.push_str(&format!(" Lines of code: {}\n", format_number(loc)));
}
output.push_str(&format!(" Claims extracted: {}\n", result.claims_extracted));
output.push_str(&format!(" Conflicts found: {}\n", result.conflicts.len()));
output.push_str(&format!(" Total time: {}ms\n", timing.total_ms));
output.push_str(&format!(" - File discovery: {}ms\n", timing.walk_ms));
output.push_str(&format!(" - Extraction: {}ms\n", timing.extraction_ms));
output.push_str(&format!(" - Conflict query: {}ms\n", timing.conflict_ms));
}
output
}
}
/// Format a number with thousands separators for readability.
fn format_number(n: usize) -> String {
let s = n.to_string();
let mut result = String::new();
for (i, c) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(c);
}
result.chars().rev().collect()
}
#[cfg(test)]
mod tests {
use super::*;
@ -265,6 +334,8 @@ mod tests {
format: "table".to_string(),
debug: false,
observations_recorded: 0,
timing: None,
deprecated_usages: vec![],
}
}

View File

@ -2,6 +2,7 @@
use std::collections::HashSet;
use std::path::Path;
use std::time::Instant;
use tracing::{info, instrument};
@ -16,6 +17,7 @@ use crate::hosted::HostedClient;
use crate::policy::PolicyManager;
use crate::types::{
ConflictResult, DriftResult, ExtractedClaim, FileSource, ScanArgs, ScanMode, ScanResult,
ScanTiming,
};
use crate::walker::{walk_project, walk_staged_files};
@ -46,27 +48,46 @@ pub(super) struct ConflictCheckResult {
///
/// - **Persistent**: Full scan with Episteme storage. Enables diff, baseline,
/// alias creation, and observation write-back (when `--sync` is enabled).
#[instrument(skip(config), fields(path = %args.path.display(), format = %args.format, mode = ?args.mode, sync = args.sync, file_source = ?args.file_source))]
#[instrument(skip(config), fields(path = %args.path.display(), format = %args.format, mode = ?args.mode, sync = args.sync, file_source = ?args.file_source, benchmark = args.benchmark))]
pub async fn run_scan(args: ScanArgs, config: &AphoriaConfig) -> Result<ScanResult, AphoriaError> {
info!("Starting scan");
let total_start = Instant::now();
let project_root = args.path.canonicalize().unwrap_or_else(|_| args.path.clone());
// 1. Walk the project to find files (or just staged files)
let walk_start = Instant::now();
let files = match args.file_source {
FileSource::All => walk_project(&project_root, config)?,
FileSource::Staged => walk_staged_files(&project_root, config)?,
};
info!(files_found = files.len(), file_source = ?args.file_source, "Project walk complete");
let walk_ms = walk_start.elapsed().as_millis() as u64;
info!(files_found = files.len(), file_source = ?args.file_source, walk_ms, "Project walk complete");
// 2. Extract claims from files (LLM extraction only in persistent mode)
let extraction_start = Instant::now();
let all_claims = extract_claims_from_files(&files, config, args.mode, &project_root)?;
info!(claims_extracted = all_claims.len(), "Extraction complete");
let extraction_ms = extraction_start.elapsed().as_millis() as u64;
info!(claims_extracted = all_claims.len(), extraction_ms, "Extraction complete");
// 3. Check for conflicts - mode determines which path
let conflict_start = Instant::now();
let result = check_conflicts(&args, &all_claims, &project_root, config).await?;
let conflict_ms = conflict_start.elapsed().as_millis() as u64;
// 4. Build result
let total_ms = total_start.elapsed().as_millis() as u64;
// 4. Calculate lines of code if benchmark mode
let lines_of_code = if args.benchmark { Some(count_lines_of_code(&files)) } else { None };
// 5. Build timing info if benchmark mode
let timing = if args.benchmark {
Some(ScanTiming { walk_ms, extraction_ms, conflict_ms, total_ms, lines_of_code })
} else {
None
};
// 6. Build result
let project_name =
project_root.file_name().and_then(|s| s.to_str()).unwrap_or("unknown").to_string();
@ -77,12 +98,28 @@ pub async fn run_scan(args: ScanArgs, config: &AphoriaConfig) -> Result<ScanResu
claims_extracted: all_claims.len(),
conflicts: result.conflicts,
drifts: result.drifts,
format: args.format,
format: args.format.clone(),
debug: args.debug,
observations_recorded: result.observations_recorded,
timing,
deprecated_usages: vec![], // TODO: Populate from lifecycle store during scan
})
}
/// Count lines of code in the scanned files.
///
/// Reads each file and counts non-empty lines. Used for benchmark reporting.
fn count_lines_of_code(files: &[crate::walker::WalkedFile]) -> usize {
files
.iter()
.map(|file| {
std::fs::read_to_string(&file.path)
.map(|content| content.lines().filter(|line| !line.trim().is_empty()).count())
.unwrap_or(0)
})
.sum()
}
/// Check claims for conflicts using either ephemeral or persistent mode.
async fn check_conflicts(
args: &ScanArgs,

View File

@ -0,0 +1,107 @@
//! Scope configuration for aphoria.toml.
//!
//! Allows projects to declare their position in the organization hierarchy.
use serde::Deserialize;
/// Scope configuration in aphoria.toml.
///
/// # Example
///
/// ```toml
/// [scope]
/// project = "api-gateway"
/// team = "platform-team"
/// organization = "acme-corp"
/// ```
///
/// All fields are optional. When not specified:
/// - `project` defaults to the directory name or git repo name
/// - `team` and `organization` are considered unset (no inheritance)
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct ScopeConfig {
/// The project name.
///
/// If not set, auto-detected from the project directory or git remote.
pub project: Option<String>,
/// The team this project belongs to.
///
/// Used for team-level pattern inheritance.
pub team: Option<String>,
/// The organization this project belongs to.
///
/// Used for organization-wide pattern inheritance.
pub organization: Option<String>,
}
impl ScopeConfig {
/// Create a new scope config with all levels set.
pub fn new(
project: Option<String>,
team: Option<String>,
organization: Option<String>,
) -> Self {
Self { project, team, organization }
}
/// Convert to a ScopeContext for resolution.
pub fn to_context(&self) -> super::ScopeContext {
super::ScopeContext::new(self.project.clone(), self.team.clone(), self.organization.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scope_config_default() {
let config = ScopeConfig::default();
assert!(config.project.is_none());
assert!(config.team.is_none());
assert!(config.organization.is_none());
}
#[test]
fn test_scope_config_deserialize() {
let toml = r#"
project = "my-project"
team = "my-team"
organization = "my-org"
"#;
let config: ScopeConfig = toml::from_str(toml).expect("deserialize");
assert_eq!(config.project, Some("my-project".to_string()));
assert_eq!(config.team, Some("my-team".to_string()));
assert_eq!(config.organization, Some("my-org".to_string()));
}
#[test]
fn test_scope_config_partial_deserialize() {
let toml = r#"
project = "only-project"
"#;
let config: ScopeConfig = toml::from_str(toml).expect("deserialize");
assert_eq!(config.project, Some("only-project".to_string()));
assert!(config.team.is_none());
assert!(config.organization.is_none());
}
#[test]
fn test_to_context() {
let config = ScopeConfig::new(
Some("proj".to_string()),
Some("team".to_string()),
Some("org".to_string()),
);
let ctx = config.to_context();
assert_eq!(ctx.project, Some("proj".to_string()));
assert_eq!(ctx.team, Some("team".to_string()));
assert_eq!(ctx.organization, Some("org".to_string()));
}
}

View File

@ -0,0 +1,355 @@
//! Knowledge scope hierarchy for pattern and policy resolution.
//!
//! Implements hierarchical scope levels (Organization, Team, Project) with
//! inheritance semantics. More specific scopes override inherited patterns,
//! with explicit override tracking for audit and governance.
mod config;
mod override_record;
mod resolver;
mod store;
pub use config::ScopeConfig;
pub use override_record::{OverrideValue, ScopeOverride};
pub use resolver::{OverridePolicy, ScopeResolver};
pub use store::{override_store_dir, OverrideStore};
use serde::{Deserialize, Serialize};
/// Hierarchical scope level for knowledge organization.
///
/// Scopes form an inheritance hierarchy where more specific scopes
/// can override patterns from broader scopes:
///
/// ```text
/// Organization (broadest)
/// └── Team
/// └── Project (most specific)
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ScopeLevel {
/// Organization-wide standards.
///
/// Applied across all teams and projects. Typically contains
/// security baselines and compliance requirements.
Organization,
/// Team-level patterns.
///
/// Shared across projects within a team. May override org
/// standards for team-specific needs.
Team,
/// Project-specific configurations.
///
/// Most specific scope level. Overrides inherited patterns
/// for this particular project.
#[default]
Project,
}
impl ScopeLevel {
/// Get precedence weight (higher = more specific).
///
/// Used for sorting and override resolution.
pub fn precedence(&self) -> u8 {
match self {
ScopeLevel::Organization => 1,
ScopeLevel::Team => 2,
ScopeLevel::Project => 3,
}
}
/// Returns true if this scope is more specific than another.
pub fn is_more_specific_than(&self, other: &ScopeLevel) -> bool {
self.precedence() > other.precedence()
}
}
impl std::fmt::Display for ScopeLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ScopeLevel::Organization => write!(f, "organization"),
ScopeLevel::Team => write!(f, "team"),
ScopeLevel::Project => write!(f, "project"),
}
}
}
impl std::str::FromStr for ScopeLevel {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"organization" | "org" => Ok(ScopeLevel::Organization),
"team" => Ok(ScopeLevel::Team),
"project" | "proj" => Ok(ScopeLevel::Project),
_ => Err(format!(
"Invalid scope level '{}'. Valid values: organization, team, project",
s
)),
}
}
}
/// Unique identifier for a scope.
///
/// Combines the scope level with a name to create a globally unique reference.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ScopeId {
/// The scope level (organization, team, or project).
pub level: ScopeLevel,
/// The scope name (e.g., "acme-corp", "platform-team", "api-gateway").
pub name: String,
}
/// Characters not allowed in scope names (path separators and special chars).
const INVALID_SCOPE_CHARS: &[char] = &['/', '\\', ':', '<', '>', '|', '*', '?', '"'];
impl ScopeId {
/// Create a new scope ID with validation.
///
/// # Errors
///
/// Returns an error if the name is empty, whitespace-only, or contains
/// invalid characters (path separators, special characters).
pub fn try_new(level: ScopeLevel, name: impl Into<String>) -> Result<Self, String> {
let name = name.into();
if name.is_empty() {
return Err("Scope name cannot be empty".to_string());
}
if name.trim().is_empty() {
return Err("Scope name cannot be whitespace-only".to_string());
}
if let Some(c) = name.chars().find(|c| INVALID_SCOPE_CHARS.contains(c)) {
return Err(format!(
"Scope name cannot contain '{}' (invalid characters: {:?})",
c, INVALID_SCOPE_CHARS
));
}
Ok(Self { level, name })
}
/// Create a new scope ID without validation.
///
/// Use this for internal code where the name is known to be valid,
/// or for deserialization where validation happened earlier.
pub fn new(level: ScopeLevel, name: impl Into<String>) -> Self {
Self { level, name: name.into() }
}
/// Create an organization scope.
pub fn organization(name: impl Into<String>) -> Self {
Self::new(ScopeLevel::Organization, name)
}
/// Create a team scope.
pub fn team(name: impl Into<String>) -> Self {
Self::new(ScopeLevel::Team, name)
}
/// Create a project scope.
pub fn project(name: impl Into<String>) -> Self {
Self::new(ScopeLevel::Project, name)
}
/// Validate a scope name.
///
/// Returns Ok(()) if valid, Err with message if invalid.
pub fn validate_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Scope name cannot be empty".to_string());
}
if name.trim().is_empty() {
return Err("Scope name cannot be whitespace-only".to_string());
}
if let Some(c) = name.chars().find(|c| INVALID_SCOPE_CHARS.contains(c)) {
return Err(format!(
"Scope name cannot contain '{}' (invalid characters: {:?})",
c, INVALID_SCOPE_CHARS
));
}
Ok(())
}
}
impl std::fmt::Display for ScopeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.level, self.name)
}
}
/// Context for scope resolution and inheritance.
///
/// Represents the current scope hierarchy for pattern resolution.
/// A project typically has all three levels set, while a team-level
/// scan might only have organization and team.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ScopeContext {
/// The current project name.
pub project: Option<String>,
/// The owning team name.
pub team: Option<String>,
/// The organization name.
pub organization: Option<String>,
}
impl ScopeContext {
/// Create a new scope context with all levels.
pub fn new(
project: Option<String>,
team: Option<String>,
organization: Option<String>,
) -> Self {
Self { project, team, organization }
}
/// Create a project-level context.
pub fn project_only(name: impl Into<String>) -> Self {
Self { project: Some(name.into()), team: None, organization: None }
}
/// Get the scope chain from most specific to least specific.
///
/// The chain always starts with the most specific scope (project)
/// and proceeds to broader scopes. This ordering is used for
/// override resolution where the first match wins.
pub fn inheritance_chain(&self) -> Vec<ScopeId> {
let mut chain = Vec::with_capacity(3);
if let Some(ref name) = self.project {
chain.push(ScopeId::project(name.clone()));
}
if let Some(ref name) = self.team {
chain.push(ScopeId::team(name.clone()));
}
if let Some(ref name) = self.organization {
chain.push(ScopeId::organization(name.clone()));
}
chain
}
/// Get the current (most specific) scope ID, if any.
pub fn current_scope(&self) -> Option<ScopeId> {
self.inheritance_chain().into_iter().next()
}
/// Check if this context is empty (no scopes defined).
pub fn is_empty(&self) -> bool {
self.project.is_none() && self.team.is_none() && self.organization.is_none()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scope_level_precedence() {
assert!(ScopeLevel::Project.is_more_specific_than(&ScopeLevel::Team));
assert!(ScopeLevel::Team.is_more_specific_than(&ScopeLevel::Organization));
assert!(!ScopeLevel::Organization.is_more_specific_than(&ScopeLevel::Project));
}
#[test]
fn test_scope_level_default() {
assert_eq!(ScopeLevel::default(), ScopeLevel::Project);
}
#[test]
fn test_scope_level_from_str() {
assert_eq!("organization".parse::<ScopeLevel>().ok(), Some(ScopeLevel::Organization));
assert_eq!("org".parse::<ScopeLevel>().ok(), Some(ScopeLevel::Organization));
assert_eq!("team".parse::<ScopeLevel>().ok(), Some(ScopeLevel::Team));
assert_eq!("project".parse::<ScopeLevel>().ok(), Some(ScopeLevel::Project));
assert_eq!("proj".parse::<ScopeLevel>().ok(), Some(ScopeLevel::Project));
assert!("invalid".parse::<ScopeLevel>().is_err());
}
#[test]
fn test_scope_id_display() {
let id = ScopeId::organization("acme-corp");
assert_eq!(id.to_string(), "organization:acme-corp");
}
#[test]
fn test_scope_context_inheritance_chain() {
let ctx = ScopeContext::new(
Some("api-gateway".to_string()),
Some("platform-team".to_string()),
Some("acme-corp".to_string()),
);
let chain = ctx.inheritance_chain();
assert_eq!(chain.len(), 3);
assert_eq!(chain[0], ScopeId::project("api-gateway"));
assert_eq!(chain[1], ScopeId::team("platform-team"));
assert_eq!(chain[2], ScopeId::organization("acme-corp"));
}
#[test]
fn test_scope_context_partial() {
let ctx = ScopeContext::new(Some("my-project".to_string()), None, None);
let chain = ctx.inheritance_chain();
assert_eq!(chain.len(), 1);
assert_eq!(chain[0], ScopeId::project("my-project"));
}
#[test]
fn test_scope_context_empty() {
let ctx = ScopeContext::default();
assert!(ctx.is_empty());
assert!(ctx.inheritance_chain().is_empty());
assert!(ctx.current_scope().is_none());
}
#[test]
fn test_scope_level_serde() {
let level = ScopeLevel::Team;
let json = serde_json::to_string(&level).expect("serialize");
assert_eq!(json, "\"team\"");
let parsed: ScopeLevel = serde_json::from_str("\"organization\"").expect("deserialize");
assert_eq!(parsed, ScopeLevel::Organization);
}
#[test]
fn test_scope_id_validation() {
// Valid names
assert!(ScopeId::try_new(ScopeLevel::Project, "my-project").is_ok());
assert!(ScopeId::try_new(ScopeLevel::Team, "platform_team").is_ok());
assert!(ScopeId::try_new(ScopeLevel::Organization, "Acme Corp").is_ok());
// Empty name
assert!(ScopeId::try_new(ScopeLevel::Project, "").is_err());
// Whitespace only
assert!(ScopeId::try_new(ScopeLevel::Project, " ").is_err());
// Invalid characters
assert!(ScopeId::try_new(ScopeLevel::Project, "my/project").is_err());
assert!(ScopeId::try_new(ScopeLevel::Project, "my\\project").is_err());
assert!(ScopeId::try_new(ScopeLevel::Project, "my:project").is_err());
assert!(ScopeId::try_new(ScopeLevel::Project, "my<project").is_err());
}
#[test]
fn test_validate_name() {
assert!(ScopeId::validate_name("valid-name").is_ok());
assert!(ScopeId::validate_name("").is_err());
assert!(ScopeId::validate_name("path/sep").is_err());
}
}

View File

@ -0,0 +1,368 @@
//! Scope override records with justification.
//!
//! When a team or project overrides an inherited pattern, the override
//! is recorded with reason, evidence, and expiration for audit purposes.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::ScopeId;
/// Value type for scope overrides.
///
/// Uses explicit tagging to avoid serde(untagged) ambiguity where
/// integers might deserialize as floats or strings as booleans.
///
/// # Serialization Format
///
/// ```json
/// { "type": "text", "value": "1.3" }
/// { "type": "number", "value": 3.14 }
/// { "type": "integer", "value": 42 }
/// { "type": "boolean", "value": true }
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
pub enum OverrideValue {
/// A text string value.
#[serde(rename = "text")]
Text(String),
/// A floating-point numeric value.
#[serde(rename = "number")]
Number(f64),
/// An integer value.
#[serde(rename = "integer")]
Integer(i64),
/// A boolean value.
#[serde(rename = "boolean")]
Boolean(bool),
}
impl OverrideValue {
/// Parse a value from a string, inferring the type.
///
/// Attempts to parse in order: boolean, integer, number, then falls back to text.
pub fn parse(s: &str) -> Self {
// Try boolean
if s.eq_ignore_ascii_case("true") {
return OverrideValue::Boolean(true);
}
if s.eq_ignore_ascii_case("false") {
return OverrideValue::Boolean(false);
}
// Try integer
if let Ok(n) = s.parse::<i64>() {
return OverrideValue::Integer(n);
}
// Try float
if let Ok(n) = s.parse::<f64>() {
return OverrideValue::Number(n);
}
// Default to text
OverrideValue::Text(s.to_string())
}
}
impl From<String> for OverrideValue {
fn from(s: String) -> Self {
OverrideValue::Text(s)
}
}
impl From<&str> for OverrideValue {
fn from(s: &str) -> Self {
OverrideValue::Text(s.to_string())
}
}
impl From<bool> for OverrideValue {
fn from(b: bool) -> Self {
OverrideValue::Boolean(b)
}
}
impl From<i64> for OverrideValue {
fn from(n: i64) -> Self {
OverrideValue::Integer(n)
}
}
impl From<f64> for OverrideValue {
fn from(n: f64) -> Self {
OverrideValue::Number(n)
}
}
impl std::fmt::Display for OverrideValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OverrideValue::Text(s) => write!(f, "{}", s),
OverrideValue::Number(n) => write!(f, "{}", n),
OverrideValue::Integer(n) => write!(f, "{}", n),
OverrideValue::Boolean(b) => write!(f, "{}", b),
}
}
}
/// A recorded scope override with justification.
///
/// Scope overrides provide explicit documentation for when a lower-level
/// scope (team or project) intentionally differs from an inherited pattern.
/// This is required for governance and audit purposes.
///
/// # Example
///
/// A project might override an organization's TLS minimum version:
///
/// ```text
/// Org pattern: tls/min_version = "1.2"
/// Project override: tls/min_version = "1.3"
/// Reason: "Project requires TLS 1.3 for PCI compliance"
/// Evidence: "docs/adr/042.md"
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScopeOverride {
/// The scope where this override is defined.
pub scope: ScopeId,
/// The concept path being overridden (e.g., "tls/min_version").
pub concept_path: String,
/// The predicate being overridden (e.g., "version", "enabled").
pub predicate: String,
/// The override value.
pub value: OverrideValue,
/// Justification for the override (required).
pub reason: String,
/// Optional evidence reference (ADR, ticket, spec).
pub evidence: Option<String>,
/// When this override was created.
#[serde(with = "chrono::serde::ts_seconds")]
pub created_at: DateTime<Utc>,
/// Who created this override (user or agent).
pub created_by: Option<String>,
/// Optional expiration for the override.
///
/// After expiration, the inherited pattern takes effect again.
#[serde(default)]
#[serde(with = "option_ts_seconds")]
pub expires_at: Option<DateTime<Utc>>,
}
impl ScopeOverride {
/// Create a new scope override.
pub fn new(
scope: ScopeId,
concept_path: impl Into<String>,
predicate: impl Into<String>,
value: OverrideValue,
reason: impl Into<String>,
) -> Self {
Self {
scope,
concept_path: concept_path.into(),
predicate: predicate.into(),
value,
reason: reason.into(),
evidence: None,
created_at: Utc::now(),
created_by: None,
expires_at: None,
}
}
/// Set evidence reference.
pub fn with_evidence(mut self, evidence: impl Into<String>) -> Self {
self.evidence = Some(evidence.into());
self
}
/// Set created_by.
pub fn with_created_by(mut self, created_by: impl Into<String>) -> Self {
self.created_by = Some(created_by.into());
self
}
/// Set expiration.
pub fn with_expires_at(mut self, expires_at: DateTime<Utc>) -> Self {
self.expires_at = Some(expires_at);
self
}
/// Check if this override has expired.
pub fn is_expired(&self) -> bool {
self.expires_at.is_some_and(|exp| Utc::now() > exp)
}
/// Check if this override is still active (not expired).
pub fn is_active(&self) -> bool {
!self.is_expired()
}
/// Get days until expiration, or None if no expiration set.
pub fn days_until_expiration(&self) -> Option<i64> {
self.expires_at.map(|exp| (exp - Utc::now()).num_days())
}
}
/// Custom serde for Option<DateTime<Utc>> using ts_seconds.
mod option_ts_seconds {
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S>(opt: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match opt {
Some(dt) => dt.timestamp().serialize(serializer),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<i64> = Option::deserialize(deserializer)?;
Ok(opt.and_then(|ts| DateTime::from_timestamp(ts, 0)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scope::ScopeLevel;
#[test]
fn test_scope_override_creation() {
let scope = ScopeId::project("api-gateway");
let override_record = ScopeOverride::new(
scope.clone(),
"tls/min_version",
"version",
OverrideValue::Text("1.3".to_string()),
"Project requires TLS 1.3",
);
assert_eq!(override_record.scope.level, ScopeLevel::Project);
assert_eq!(override_record.concept_path, "tls/min_version");
assert_eq!(override_record.reason, "Project requires TLS 1.3");
assert!(override_record.is_active());
}
#[test]
fn test_scope_override_with_evidence() {
let scope = ScopeId::team("platform");
let override_record = ScopeOverride::new(
scope,
"db/pool_size",
"size",
OverrideValue::Integer(50),
"Team needs larger pool",
)
.with_evidence("JIRA-1234")
.with_created_by("jane@example.com");
assert_eq!(override_record.evidence, Some("JIRA-1234".to_string()));
assert_eq!(override_record.created_by, Some("jane@example.com".to_string()));
}
#[test]
fn test_scope_override_expiration() {
use chrono::Duration;
let scope = ScopeId::project("test");
// Create an already expired override
let expired = ScopeOverride::new(
scope.clone(),
"test/path",
"predicate",
OverrideValue::Boolean(true),
"test",
)
.with_expires_at(Utc::now() - Duration::days(1));
assert!(expired.is_expired());
assert!(!expired.is_active());
// Create a future expiration
let future = ScopeOverride::new(
scope,
"test/path",
"predicate",
OverrideValue::Boolean(true),
"test",
)
.with_expires_at(Utc::now() + Duration::days(30));
assert!(!future.is_expired());
assert!(future.is_active());
assert!(future.days_until_expiration().unwrap_or(0) >= 29);
}
#[test]
fn test_scope_override_serde() {
let scope = ScopeId::organization("acme");
let override_record = ScopeOverride::new(
scope,
"security/mfa",
"required",
OverrideValue::Boolean(true),
"Org policy requires MFA",
);
let json = serde_json::to_string(&override_record).expect("serialize");
let parsed: ScopeOverride = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.concept_path, "security/mfa");
assert_eq!(parsed.reason, "Org policy requires MFA");
}
#[test]
fn test_override_value_parse() {
// Boolean parsing
assert_eq!(OverrideValue::parse("true"), OverrideValue::Boolean(true));
assert_eq!(OverrideValue::parse("TRUE"), OverrideValue::Boolean(true));
assert_eq!(OverrideValue::parse("false"), OverrideValue::Boolean(false));
// Integer parsing
assert_eq!(OverrideValue::parse("42"), OverrideValue::Integer(42));
assert_eq!(OverrideValue::parse("-10"), OverrideValue::Integer(-10));
// Float parsing
assert_eq!(OverrideValue::parse("3.14"), OverrideValue::Number(3.14_f64));
// Text fallback
assert_eq!(OverrideValue::parse("hello"), OverrideValue::Text("hello".to_string()));
assert_eq!(OverrideValue::parse("1.3"), OverrideValue::Number(1.3));
}
#[test]
fn test_override_value_serde_explicit_tag() {
// Test that explicit tagging works correctly
let text = OverrideValue::Text("hello".to_string());
let json = serde_json::to_string(&text).expect("serialize");
assert!(json.contains("\"type\":\"text\""));
assert!(json.contains("\"value\":\"hello\""));
let int = OverrideValue::Integer(42);
let json = serde_json::to_string(&int).expect("serialize");
assert!(json.contains("\"type\":\"integer\""));
assert!(json.contains("\"value\":42"));
// Roundtrip
let parsed: OverrideValue = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, OverrideValue::Integer(42));
}
}

View File

@ -0,0 +1,338 @@
//! Scope-aware pattern resolution with inheritance.
//!
//! Implements the inheritance logic for resolving patterns across
//! the scope hierarchy. Supports multiple override policies for
//! different use cases.
use std::collections::HashSet;
use super::{ScopeContext, ScopeId, ScopeLevel};
use crate::learning::LearnedPattern;
/// Policy for how scope overrides work.
///
/// Determines how patterns from different scope levels interact
/// when resolving the effective pattern for a concept.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OverridePolicy {
/// More specific scopes replace inherited patterns (default).
///
/// When a project defines a pattern, it completely overrides
/// any team or org patterns for that concept.
#[default]
Replace,
/// More specific scopes merge with inherited patterns.
///
/// All matching patterns from all scopes are returned,
/// deduplicated by normalized_pattern.
Merge,
/// Inheritance disabled; only local scope applies.
///
/// Only patterns from the current (most specific) scope
/// are returned, ignoring all inherited patterns.
NoInherit,
}
impl std::fmt::Display for OverridePolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OverridePolicy::Replace => write!(f, "replace"),
OverridePolicy::Merge => write!(f, "merge"),
OverridePolicy::NoInherit => write!(f, "no-inherit"),
}
}
}
impl std::str::FromStr for OverridePolicy {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"replace" => Ok(OverridePolicy::Replace),
"merge" => Ok(OverridePolicy::Merge),
"no-inherit" | "noinherit" | "none" => Ok(OverridePolicy::NoInherit),
_ => Err(format!(
"Invalid override policy '{}'. Valid values: replace, merge, no-inherit",
s
)),
}
}
}
/// Resolves patterns across scope hierarchy.
///
/// Provides scope-aware pattern resolution with configurable
/// inheritance policies.
pub struct ScopeResolver {
context: ScopeContext,
policy: OverridePolicy,
}
impl ScopeResolver {
/// Create a new resolver with default policy (Replace).
pub fn new(context: ScopeContext) -> Self {
Self { context, policy: OverridePolicy::default() }
}
/// Create a resolver with a specific override policy.
pub fn with_policy(context: ScopeContext, policy: OverridePolicy) -> Self {
Self { context, policy }
}
/// Get the current scope context.
pub fn context(&self) -> &ScopeContext {
&self.context
}
/// Get the current override policy.
pub fn policy(&self) -> OverridePolicy {
self.policy
}
/// Resolve patterns for a concept, respecting inheritance.
///
/// Returns patterns that match the given concept path, filtered
/// according to the scope hierarchy and override policy.
pub fn resolve_patterns<'a>(
&self,
patterns: &'a [LearnedPattern],
concept_path: &str,
) -> Vec<&'a LearnedPattern> {
let chain = self.context.inheritance_chain();
if chain.is_empty() {
// No scope context, return all patterns for concept
return patterns
.iter()
.filter(|p| p.claim_template.subject_template == concept_path)
.collect();
}
match self.policy {
OverridePolicy::NoInherit => {
// Only current (most specific) scope
self.filter_by_scope(patterns, chain.first(), concept_path)
}
OverridePolicy::Replace => {
// Most specific scope that has patterns wins
for scope in &chain {
let matches = self.filter_by_scope(patterns, Some(scope), concept_path);
if !matches.is_empty() {
return matches;
}
}
Vec::new()
}
OverridePolicy::Merge => {
// All scopes, deduplicated by normalized_pattern
let mut seen = HashSet::new();
chain
.iter()
.flat_map(|scope| self.filter_by_scope(patterns, Some(scope), concept_path))
.filter(|p| seen.insert(&p.normalized_pattern))
.collect()
}
}
}
/// Resolve all patterns, not filtered by concept.
///
/// Returns all patterns visible in the current scope hierarchy.
pub fn resolve_all_patterns<'a>(
&self,
patterns: &'a [LearnedPattern],
) -> Vec<&'a LearnedPattern> {
let chain = self.context.inheritance_chain();
if chain.is_empty() {
return patterns.iter().collect();
}
match self.policy {
OverridePolicy::NoInherit => {
// Only current scope
if let Some(scope) = chain.first() {
patterns.iter().filter(|p| self.pattern_matches_scope(p, scope)).collect()
} else {
Vec::new()
}
}
OverridePolicy::Replace | OverridePolicy::Merge => {
// All patterns from all scopes in hierarchy
let mut seen = HashSet::new();
chain
.iter()
.flat_map(|scope| {
patterns.iter().filter(|p| self.pattern_matches_scope(p, scope))
})
.filter(|p| seen.insert(&p.id))
.collect()
}
}
}
/// Filter patterns by scope and concept path.
fn filter_by_scope<'a>(
&self,
patterns: &'a [LearnedPattern],
scope: Option<&ScopeId>,
concept_path: &str,
) -> Vec<&'a LearnedPattern> {
patterns
.iter()
.filter(|p| {
let scope_matches = scope.is_some_and(|s| self.pattern_matches_scope(p, s));
scope_matches && p.claim_template.subject_template == concept_path
})
.collect()
}
/// Check if a pattern belongs to a specific scope.
fn pattern_matches_scope(&self, pattern: &LearnedPattern, scope: &ScopeId) -> bool {
pattern.scope_level == scope.level
&& pattern.scope_id.as_deref() == Some(scope.name.as_str())
}
/// Get patterns only at a specific scope level.
///
/// Ignores inheritance; returns only patterns explicitly at the given level.
pub fn patterns_at_level<'a>(
&self,
patterns: &'a [LearnedPattern],
level: ScopeLevel,
) -> Vec<&'a LearnedPattern> {
let scope_name = match level {
ScopeLevel::Project => self.context.project.as_deref(),
ScopeLevel::Team => self.context.team.as_deref(),
ScopeLevel::Organization => self.context.organization.as_deref(),
};
if let Some(name) = scope_name {
let scope = ScopeId::new(level, name);
patterns.iter().filter(|p| self.pattern_matches_scope(p, &scope)).collect()
} else {
Vec::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::learning::{ClaimTemplate, ValueType};
use crate::types::Language;
fn make_pattern(subject: &str, scope_level: ScopeLevel, scope_id: &str) -> LearnedPattern {
// Use unique normalized pattern including scope info to ensure merge mode works
let normalized = format!("normalized pattern for {}", scope_id);
let mut pattern = LearnedPattern::new(
"example code",
normalized,
ClaimTemplate::new(subject, "version", ValueType::Text, "description"),
Language::Rust,
"project-hash",
0.9,
);
pattern.scope_level = scope_level;
pattern.scope_id = Some(scope_id.to_string());
pattern
}
#[test]
fn test_resolve_replace_policy() {
let patterns = vec![
make_pattern("tls/min_version", ScopeLevel::Organization, "acme"),
make_pattern("tls/min_version", ScopeLevel::Project, "api-gateway"),
];
let ctx =
ScopeContext::new(Some("api-gateway".to_string()), None, Some("acme".to_string()));
let resolver = ScopeResolver::new(ctx);
let resolved = resolver.resolve_patterns(&patterns, "tls/min_version");
// Project-level pattern should win (replace policy)
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].scope_level, ScopeLevel::Project);
}
#[test]
fn test_resolve_merge_policy() {
let patterns = vec![
make_pattern("tls/min_version", ScopeLevel::Organization, "acme"),
make_pattern("tls/min_version", ScopeLevel::Project, "api-gateway"),
];
let ctx =
ScopeContext::new(Some("api-gateway".to_string()), None, Some("acme".to_string()));
let resolver = ScopeResolver::with_policy(ctx, OverridePolicy::Merge);
let resolved = resolver.resolve_patterns(&patterns, "tls/min_version");
// Both patterns should be returned
assert_eq!(resolved.len(), 2);
}
#[test]
fn test_resolve_no_inherit_policy() {
let patterns = vec![
make_pattern("tls/min_version", ScopeLevel::Organization, "acme"),
make_pattern("tls/min_version", ScopeLevel::Project, "api-gateway"),
];
let ctx =
ScopeContext::new(Some("api-gateway".to_string()), None, Some("acme".to_string()));
let resolver = ScopeResolver::with_policy(ctx, OverridePolicy::NoInherit);
let resolved = resolver.resolve_patterns(&patterns, "tls/min_version");
// Only project-level pattern (current scope)
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].scope_level, ScopeLevel::Project);
}
#[test]
fn test_resolve_inheritance_when_local_empty() {
let patterns = vec![make_pattern("tls/min_version", ScopeLevel::Organization, "acme")];
let ctx =
ScopeContext::new(Some("api-gateway".to_string()), None, Some("acme".to_string()));
let resolver = ScopeResolver::new(ctx);
let resolved = resolver.resolve_patterns(&patterns, "tls/min_version");
// Should inherit from org when project has no pattern
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].scope_level, ScopeLevel::Organization);
}
#[test]
fn test_patterns_at_level() {
let patterns = vec![
make_pattern("tls/min_version", ScopeLevel::Organization, "acme"),
make_pattern("db/pool_size", ScopeLevel::Project, "api-gateway"),
];
let ctx =
ScopeContext::new(Some("api-gateway".to_string()), None, Some("acme".to_string()));
let resolver = ScopeResolver::new(ctx);
let org_patterns = resolver.patterns_at_level(&patterns, ScopeLevel::Organization);
assert_eq!(org_patterns.len(), 1);
assert_eq!(org_patterns[0].claim_template.subject_template, "tls/min_version");
let proj_patterns = resolver.patterns_at_level(&patterns, ScopeLevel::Project);
assert_eq!(proj_patterns.len(), 1);
assert_eq!(proj_patterns[0].claim_template.subject_template, "db/pool_size");
}
#[test]
fn test_override_policy_from_str() {
assert_eq!("replace".parse::<OverridePolicy>().ok(), Some(OverridePolicy::Replace));
assert_eq!("merge".parse::<OverridePolicy>().ok(), Some(OverridePolicy::Merge));
assert_eq!("no-inherit".parse::<OverridePolicy>().ok(), Some(OverridePolicy::NoInherit));
assert!("invalid".parse::<OverridePolicy>().is_err());
}
}

View File

@ -0,0 +1,294 @@
//! Persistent storage for scope overrides.
//!
//! Stores scope overrides in a JSON file for persistence across sessions.
use std::path::{Path, PathBuf};
use crate::error::AphoriaError;
use crate::scope::{ScopeId, ScopeOverride};
/// Directory name for scope data within .aphoria.
const SCOPE_DIR: &str = "scope";
/// Filename for overrides storage.
const OVERRIDES_FILE: &str = "overrides.json";
/// Persistent storage for scope overrides.
///
/// Stores overrides in `.aphoria/scope/overrides.json`.
pub struct OverrideStore {
path: PathBuf,
overrides: Vec<ScopeOverride>,
}
impl OverrideStore {
/// Open or create the override store.
///
/// # Arguments
///
/// * `base_dir` - Base directory (typically `.aphoria/`)
pub fn new(base_dir: &Path) -> Result<Self, AphoriaError> {
let scope_dir = base_dir.join(SCOPE_DIR);
std::fs::create_dir_all(&scope_dir).map_err(|e| {
AphoriaError::Config(format!("Failed to create scope directory: {}", e))
})?;
let path = scope_dir.join(OVERRIDES_FILE);
let overrides = if path.exists() {
let content = std::fs::read_to_string(&path).map_err(|e| {
AphoriaError::Config(format!("Failed to read overrides file: {}", e))
})?;
serde_json::from_str(&content)
.map_err(|e| AphoriaError::Config(format!("Failed to parse overrides: {}", e)))?
} else {
Vec::new()
};
Ok(Self { path, overrides })
}
/// Add a new override.
///
/// If an override for the same scope/concept/predicate exists, it is replaced.
pub fn add(&mut self, override_record: ScopeOverride) -> Result<(), AphoriaError> {
// Remove any existing override for the same key
self.overrides.retain(|o| {
!(o.scope == override_record.scope
&& o.concept_path == override_record.concept_path
&& o.predicate == override_record.predicate)
});
self.overrides.push(override_record);
self.persist()
}
/// Remove an override by concept path.
///
/// Returns true if an override was removed.
pub fn remove(&mut self, scope: &ScopeId, concept_path: &str) -> Result<bool, AphoriaError> {
let initial_len = self.overrides.len();
self.overrides.retain(|o| !(&o.scope == scope && o.concept_path == concept_path));
if self.overrides.len() < initial_len {
self.persist()?;
Ok(true)
} else {
Ok(false)
}
}
/// List overrides, optionally filtered by scope.
///
/// # Arguments
///
/// * `scope` - If Some, only return overrides for this scope
/// * `include_expired` - If true, include expired overrides
pub fn list(&self, scope: Option<&ScopeId>, include_expired: bool) -> Vec<&ScopeOverride> {
self.overrides
.iter()
.filter(|o| scope.map_or(true, |s| &o.scope == s))
.filter(|o| include_expired || o.is_active())
.collect()
}
/// List overrides for a scope and its ancestors (inheritance chain).
pub fn list_with_inheritance(
&self,
chain: &[ScopeId],
include_expired: bool,
) -> Vec<&ScopeOverride> {
self.overrides
.iter()
.filter(|o| chain.contains(&o.scope))
.filter(|o| include_expired || o.is_active())
.collect()
}
/// Get a specific override by scope and concept path.
pub fn get(&self, scope: &ScopeId, concept_path: &str) -> Option<&ScopeOverride> {
self.overrides
.iter()
.find(|o| &o.scope == scope && o.concept_path == concept_path && o.is_active())
}
/// Get the count of active overrides.
pub fn active_count(&self) -> usize {
self.overrides.iter().filter(|o| o.is_active()).count()
}
/// Get the count of expired overrides.
pub fn expired_count(&self) -> usize {
self.overrides.iter().filter(|o| o.is_expired()).count()
}
/// Persist overrides to disk.
fn persist(&self) -> Result<(), AphoriaError> {
let json = serde_json::to_string_pretty(&self.overrides)
.map_err(|e| AphoriaError::Config(format!("Failed to serialize overrides: {}", e)))?;
std::fs::write(&self.path, json)
.map_err(|e| AphoriaError::Config(format!("Failed to write overrides file: {}", e)))?;
Ok(())
}
/// Get the path to the overrides file.
pub fn path(&self) -> &Path {
&self.path
}
}
/// Get the default override store directory.
pub fn override_store_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".aphoria")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scope::OverrideValue;
use chrono::{Duration, Utc};
use tempfile::TempDir;
fn create_test_store() -> (TempDir, OverrideStore) {
let temp_dir = TempDir::new().expect("create temp dir");
let store = OverrideStore::new(temp_dir.path()).expect("create store");
(temp_dir, store)
}
#[test]
fn test_add_and_list() {
let (_temp, mut store) = create_test_store();
let scope = ScopeId::project("test-project");
let override_record = ScopeOverride::new(
scope.clone(),
"tls/min_version",
"version",
OverrideValue::Text("1.3".to_string()),
"Security requirement",
);
store.add(override_record).expect("add override");
let overrides = store.list(Some(&scope), false);
assert_eq!(overrides.len(), 1);
assert_eq!(overrides[0].concept_path, "tls/min_version");
}
#[test]
fn test_remove() {
let (_temp, mut store) = create_test_store();
let scope = ScopeId::project("test-project");
let override_record = ScopeOverride::new(
scope.clone(),
"tls/min_version",
"version",
OverrideValue::Text("1.3".to_string()),
"Security requirement",
);
store.add(override_record).expect("add override");
assert_eq!(store.active_count(), 1);
let removed = store.remove(&scope, "tls/min_version").expect("remove");
assert!(removed);
assert_eq!(store.active_count(), 0);
}
#[test]
fn test_expired_filtering() {
let (_temp, mut store) = create_test_store();
let scope = ScopeId::project("test-project");
// Add expired override
let expired = ScopeOverride::new(
scope.clone(),
"old/setting",
"value",
OverrideValue::Boolean(true),
"Temporary",
)
.with_expires_at(Utc::now() - Duration::days(1));
store.add(expired).expect("add expired");
// Add active override
let active = ScopeOverride::new(
scope.clone(),
"current/setting",
"value",
OverrideValue::Boolean(true),
"Current",
);
store.add(active).expect("add active");
// Without expired
let active_only = store.list(Some(&scope), false);
assert_eq!(active_only.len(), 1);
// With expired
let all = store.list(Some(&scope), true);
assert_eq!(all.len(), 2);
}
#[test]
fn test_persistence() {
let temp_dir = TempDir::new().expect("create temp dir");
// Create store and add override
{
let mut store = OverrideStore::new(temp_dir.path()).expect("create store");
let scope = ScopeId::project("test-project");
let override_record = ScopeOverride::new(
scope,
"test/path",
"predicate",
OverrideValue::Integer(42),
"Test",
);
store.add(override_record).expect("add override");
}
// Reopen store and verify persistence
{
let store = OverrideStore::new(temp_dir.path()).expect("reopen store");
assert_eq!(store.active_count(), 1);
}
}
#[test]
fn test_replace_existing() {
let (_temp, mut store) = create_test_store();
let scope = ScopeId::project("test-project");
// Add initial override
let initial = ScopeOverride::new(
scope.clone(),
"tls/min_version",
"version",
OverrideValue::Text("1.2".to_string()),
"Initial",
);
store.add(initial).expect("add initial");
// Add replacement
let replacement = ScopeOverride::new(
scope.clone(),
"tls/min_version",
"version",
OverrideValue::Text("1.3".to_string()),
"Updated",
);
store.add(replacement).expect("add replacement");
// Should only have one override
let overrides = store.list(Some(&scope), false);
assert_eq!(overrides.len(), 1);
assert_eq!(overrides[0].reason, "Updated");
}
}

View File

@ -67,6 +67,28 @@ impl<'a> GraduationManager<'a> {
Self { registry, config, production_dir: production_dir.as_ref().to_path_buf() }
}
/// Get effective graduation threshold for a test, scaled by evidence level.
///
/// Higher evidence levels require fewer scans to graduate:
/// - ProductSpec: 10% of base (0.1x)
/// - Standard: 30% of base (0.3x)
/// - Research: 50% of base (0.5x)
/// - Commit: 100% of base (1.0x)
pub fn effective_min_scans(&self, test: &ShadowTest) -> usize {
let multiplier = test.effective_evidence_level().threshold_multiplier();
let scaled = (self.config.min_scans as f32 * multiplier).ceil() as usize;
// Minimum of 1 scan required
scaled.max(1)
}
/// Check if a test meets evidence-aware graduation criteria.
pub fn meets_evidence_aware_criteria(&self, test: &ShadowTest) -> bool {
test.status == super::types::ShadowStatus::Active
&& test.metrics.total_scans >= self.effective_min_scans(test)
&& test.metrics.fp_rate() <= self.config.max_fp_rate
&& test.metrics.total_reviewed() > 0
}
/// Check if a test is ready for graduation.
pub fn is_ready(&self, test_id: &Uuid) -> Result<bool, AphoriaError> {
let test = self
@ -74,7 +96,7 @@ impl<'a> GraduationManager<'a> {
.get_test(test_id)?
.ok_or_else(|| AphoriaError::Shadow(format!("Shadow test {} not found", test_id)))?;
Ok(test.meets_graduation_criteria(self.config))
Ok(self.meets_evidence_aware_criteria(&test))
}
/// Check if a test is ready for graduation by name.
@ -84,7 +106,7 @@ impl<'a> GraduationManager<'a> {
.get_test_by_name(name)?
.ok_or_else(|| AphoriaError::Shadow(format!("Shadow test '{}' not found", name)))?;
Ok(test.meets_graduation_criteria(self.config))
Ok(self.meets_evidence_aware_criteria(&test))
}
/// Graduate a shadow extractor to production.
@ -97,13 +119,16 @@ impl<'a> GraduationManager<'a> {
.get_test(test_id)?
.ok_or_else(|| AphoriaError::Shadow(format!("Shadow test {} not found", test_id)))?;
// Check if ready
if !test.meets_graduation_criteria(self.config) {
// Check if ready (using evidence-aware criteria)
if !self.meets_evidence_aware_criteria(&test) {
let metrics = &test.metrics;
let effective_min = self.effective_min_scans(&test);
let evidence_level = test.effective_evidence_level();
let reason = format!(
"Not ready: {} scans (need {}), {:.1}% FP rate (max {:.1}%), {} reviewed",
"Not ready: {} scans (need {}, {} level), {:.1}% FP rate (max {:.1}%), {} reviewed",
metrics.total_scans,
self.config.min_scans,
effective_min,
evidence_level.badge(),
metrics.fp_rate() * 100.0,
self.config.max_fp_rate * 100.0,
metrics.total_reviewed()
@ -279,16 +304,20 @@ impl<'a> GraduationManager<'a> {
let mut candidates = Vec::new();
for test in tests {
let is_ready = test.meets_graduation_criteria(self.config);
let is_ready = self.meets_evidence_aware_criteria(&test);
let not_ready_reason = if is_ready {
None
} else {
let metrics = &test.metrics;
let effective_min = self.effective_min_scans(&test);
let mut reasons = Vec::new();
if metrics.total_scans < self.config.min_scans {
reasons
.push(format!("{}/{} scans", metrics.total_scans, self.config.min_scans));
if metrics.total_scans < effective_min {
let evidence_badge = test.effective_evidence_level().badge();
reasons.push(format!(
"{}/{} scans {}",
metrics.total_scans, effective_min, evidence_badge
));
}
if metrics.total_reviewed() == 0 {
@ -505,4 +534,128 @@ confidence: 0.9
assert!(candidates[0].is_ready);
assert!(candidates[0].not_ready_reason.is_none());
}
#[test]
fn test_evidence_aware_graduation_standard_level() {
// Standard evidence level should require 30% of base scans (3 instead of 10)
let (registry, config, _shadow_temp, learned_temp, production_temp) = setup_environment();
// Create extractor and test
let extractor = create_test_extractor();
let extractor_path = learned_temp.path().join("evidence_test.yaml");
let yaml = r#"name: evidence_test_extractor
description: Test extractor with evidence
languages:
- python
pattern: "verify_ssl\\s*=\\s*(true|false)"
claim:
subject: ssl/verify
predicate: enabled
value_from_match: true
confidence: 0.9
"#;
fs::write(&extractor_path, yaml).expect("write yaml");
// Create test with Standard evidence level
let mut test = ShadowTest::with_evidence(
extractor.name.clone(),
extractor_path,
Uuid::new_v4(),
Some(crate::evidence::EvidenceLevel::Standard),
vec![crate::evidence::EvidenceSource::Rfc { number: 7519, section: None }],
);
// Add only 3 scans (30% of 10) - should be enough for Standard level
for _ in 0..3 {
test.record_scan();
}
// Add good feedback (1 TP, 0 FP)
test.record_feedback(MatchFeedback::TruePositive);
registry.store().save_test(&test).expect("save test");
let manager = GraduationManager::new(&registry, &config, production_temp.path());
// Calculate expected min scans for Standard level
let expected_min = manager.effective_min_scans(&test);
assert_eq!(expected_min, 3, "Standard level should require 3 scans (30% of 10)");
// Should be ready with only 3 scans at Standard evidence level
assert!(
manager.is_ready(&test.id).expect("is_ready"),
"Standard evidence level should be ready with 3 scans"
);
}
#[test]
fn test_evidence_aware_graduation_product_spec_level() {
// ProductSpec evidence level should require 10% of base scans (1 instead of 10)
let (registry, config, _shadow_temp, learned_temp, production_temp) = setup_environment();
let extractor = create_test_extractor();
let extractor_path = learned_temp.path().join("spec_test.yaml");
let yaml = r#"name: spec_test_extractor
description: Test extractor with spec evidence
languages:
- python
pattern: "verify_ssl\\s*=\\s*(true|false)"
claim:
subject: ssl/verify
predicate: enabled
value_from_match: true
confidence: 0.9
"#;
fs::write(&extractor_path, yaml).expect("write yaml");
// Create test with ProductSpec evidence level
let mut test = ShadowTest::with_evidence(
extractor.name.clone(),
extractor_path,
Uuid::new_v4(),
Some(crate::evidence::EvidenceLevel::ProductSpec),
vec![crate::evidence::EvidenceSource::Spec {
path: "specs/security.md".to_string(),
requirement_id: Some("REQ-SEC-001".to_string()),
}],
);
// Add only 1 scan - should be enough for ProductSpec level
test.record_scan();
test.record_feedback(MatchFeedback::TruePositive);
registry.store().save_test(&test).expect("save test");
let manager = GraduationManager::new(&registry, &config, production_temp.path());
// Calculate expected min scans for ProductSpec level
let expected_min = manager.effective_min_scans(&test);
assert_eq!(expected_min, 1, "ProductSpec level should require 1 scan (10% of 10, min 1)");
// Should be ready with only 1 scan at ProductSpec evidence level
assert!(
manager.is_ready(&test.id).expect("is_ready"),
"ProductSpec evidence level should be ready with 1 scan"
);
}
#[test]
fn test_commit_level_requires_full_scans() {
// Commit (default) evidence level should require 100% of base scans (10)
let (registry, config, _shadow_temp, learned_temp, production_temp) = setup_environment();
// Create test with no evidence (defaults to Commit level)
let test = create_test_with_metrics(&registry, &learned_temp, 5, 10, 0);
let manager = GraduationManager::new(&registry, &config, production_temp.path());
// Calculate expected min scans for Commit level
let expected_min = manager.effective_min_scans(&test);
assert_eq!(expected_min, 10, "Commit level should require 10 scans (100%)");
// Should NOT be ready with only 5 scans at Commit evidence level
assert!(
!manager.is_ready(&test.id).expect("is_ready"),
"Commit evidence level should NOT be ready with only 5 scans"
);
}
}

View File

@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::config::ShadowConfig;
use crate::evidence::{EvidenceLevel, EvidenceSource};
/// State for an extractor under shadow testing.
///
@ -49,6 +50,14 @@ pub struct ShadowTest {
/// Reason for rollback (if applicable).
pub rollback_reason: Option<String>,
/// Evidence level for this pattern (determines graduation threshold).
#[serde(default)]
pub evidence_level: Option<EvidenceLevel>,
/// Evidence sources backing this pattern.
#[serde(default)]
pub evidence_sources: Vec<EvidenceSource>,
}
impl ShadowTest {
@ -67,9 +76,34 @@ impl ShadowTest {
graduated_at: None,
rolled_back_at: None,
rollback_reason: None,
evidence_level: None,
evidence_sources: Vec::new(),
}
}
/// Create a new shadow test with evidence.
pub fn with_evidence(
extractor_name: String,
extractor_path: PathBuf,
source_pattern_id: Uuid,
evidence_level: Option<EvidenceLevel>,
evidence_sources: Vec<EvidenceSource>,
) -> Self {
let mut test = Self::new(extractor_name, extractor_path, source_pattern_id);
test.evidence_level = evidence_level;
test.evidence_sources = evidence_sources;
test
}
/// Get the effective evidence level.
///
/// Returns the cached level or computes from sources.
pub fn effective_evidence_level(&self) -> EvidenceLevel {
self.evidence_level.unwrap_or_else(|| {
self.evidence_sources.iter().map(|s| s.level()).max().unwrap_or(EvidenceLevel::Commit)
})
}
/// Check if this test meets graduation criteria.
pub fn meets_graduation_criteria(&self, config: &ShadowConfig) -> bool {
self.status == ShadowStatus::Active

View File

@ -44,6 +44,7 @@ async fn test_conflict_detection_tls_disabled() {
debug: false,
sync: false,
file_source: FileSource::All,
benchmark: false,
};
let mut config = AphoriaConfig::default();
@ -110,6 +111,7 @@ async fn test_conflict_detection_jwt_audience_disabled() {
debug: false,
sync: false,
file_source: FileSource::All,
benchmark: false,
};
let mut config = AphoriaConfig::default();
@ -178,6 +180,7 @@ async fn test_no_conflicts_when_compliant() {
debug: false,
sync: false,
file_source: FileSource::All,
benchmark: false,
};
let mut config = AphoriaConfig::default();

View File

@ -66,6 +66,8 @@ fn test_scan_result_has_drifts() {
format: "table".to_string(),
debug: false,
observations_recorded: 0,
timing: None,
deprecated_usages: vec![],
};
assert!(result.has_drifts());
@ -97,6 +99,8 @@ fn test_drift_json_output_format() {
format: "json".to_string(),
debug: false,
observations_recorded: 0,
timing: None,
deprecated_usages: vec![],
};
let formatter = JsonReport;
@ -130,6 +134,8 @@ fn test_drift_sarif_output_format() {
format: "sarif".to_string(),
debug: false,
observations_recorded: 0,
timing: None,
deprecated_usages: vec![],
};
let formatter = SarifReport;
@ -165,6 +171,8 @@ fn test_drift_table_output_format() {
format: "table".to_string(),
debug: false,
observations_recorded: 0,
timing: None,
deprecated_usages: vec![],
};
let formatter = TableReport;

View File

@ -127,6 +127,7 @@ version = "0.1.0"
debug: false,
sync: false,
file_source: FileSource::All,
benchmark: false,
};
let result = run_scan(args, &config_b).await.expect("scan should succeed");

View File

@ -0,0 +1,582 @@
//! Tests for governance workflows.
use tempfile::TempDir;
use uuid::Uuid;
use crate::config::GovernanceConfig;
use crate::evidence::{EvidenceLevel, EvidenceSource, PatternEvidence};
use crate::governance::{
ApprovalDecision, ApprovalRequest, ApprovalStage, ApprovalStatus, ApprovalWorkflow, AuditEvent,
AuditEventType, AuditTrail, Decision, GovernanceStateMachine, GovernanceStore,
};
use crate::learning::{ClaimTemplate, LearnedPattern, ValueType};
use crate::types::Language;
/// Create a test governance config with a simple workflow.
fn create_test_config() -> GovernanceConfig {
GovernanceConfig {
enabled: true,
governance_dir: None,
default_workflow: Some("test_workflow".to_string()),
workflows: vec![ApprovalWorkflow::new("test_workflow", "Test workflow")
.add_stage(ApprovalStage::new("security_review", "Security Review"))
.add_stage(ApprovalStage::new("arch_review", "Architecture Review"))],
check_timeouts_on_access: false,
}
}
/// Create a test pattern for governance tests.
fn create_test_pattern() -> LearnedPattern {
LearnedPattern::new(
"verify_ssl = false",
"verify_ssl = <boolean>",
ClaimTemplate::new("ssl/verify", "enabled", ValueType::Boolean, "SSL verification"),
Language::Python,
"project_hash",
0.9,
)
}
/// Create a test pattern with evidence.
fn create_pattern_with_evidence(evidence_level: EvidenceLevel) -> LearnedPattern {
let mut pattern = create_test_pattern();
let source = match evidence_level {
EvidenceLevel::ProductSpec => {
EvidenceSource::Spec { path: "spec.md".into(), requirement_id: Some("REQ-001".into()) }
}
EvidenceLevel::Standard => EvidenceSource::Rfc { number: 7519, section: None },
EvidenceLevel::Research => EvidenceSource::Adr { id: "042".into(), path: None },
EvidenceLevel::Commit => {
EvidenceSource::Commit { hash: "abc123".into(), message_excerpt: None }
}
};
pattern.evidence = PatternEvidence::from_sources(vec![source]);
pattern
}
// ==================== ApprovalRequest Tests ====================
#[test]
fn test_approval_request_creation() {
let pattern_id = Uuid::new_v4();
let request = ApprovalRequest::new(
pattern_id,
"test_pattern",
"standard_review",
"security_review",
"test_user",
);
assert_eq!(request.pattern_id, pattern_id);
assert_eq!(request.pattern_name, "test_pattern");
assert_eq!(request.workflow_name, "standard_review");
assert!(request.status.is_pending());
assert_eq!(request.status.current_stage(), Some("security_review"));
assert_eq!(request.current_stage_index, 0);
assert!(request.decisions.is_empty());
}
#[test]
fn test_approval_request_advance_stage() {
let mut request =
ApprovalRequest::new(Uuid::new_v4(), "test_pattern", "workflow", "stage1", "user");
request.advance_to_stage("stage2");
assert_eq!(request.current_stage_index, 1);
assert_eq!(request.status.current_stage(), Some("stage2"));
assert!(request.updated_at > request.created_at);
}
#[test]
fn test_approval_request_mark_approved() {
let mut request =
ApprovalRequest::new(Uuid::new_v4(), "test_pattern", "workflow", "stage1", "user");
request.mark_approved();
assert!(request.status.is_approved());
assert!(request.status.is_terminal());
}
#[test]
fn test_approval_request_mark_rejected() {
let mut request =
ApprovalRequest::new(Uuid::new_v4(), "test_pattern", "workflow", "stage1", "user");
request.mark_rejected("stage1", "Too broad");
assert!(request.status.is_rejected());
if let ApprovalStatus::Rejected { stage, reason } = &request.status {
assert_eq!(stage, "stage1");
assert_eq!(reason, "Too broad");
} else {
panic!("Expected Rejected status");
}
}
#[test]
fn test_approval_request_add_decision() {
let mut request =
ApprovalRequest::new(Uuid::new_v4(), "test_pattern", "workflow", "stage1", "user");
let decision = ApprovalDecision::new(
request.id,
"stage1",
Decision::Approved,
"approver",
Some("LGTM".to_string()),
);
request.add_decision(decision);
assert_eq!(request.decisions.len(), 1);
assert_eq!(request.decisions[0].approver, "approver");
}
#[test]
fn test_approval_request_current_stage_approval_count() {
let mut request =
ApprovalRequest::new(Uuid::new_v4(), "test_pattern", "workflow", "stage1", "user");
// Add approvals for current stage
request.add_decision(ApprovalDecision::new(
request.id,
"stage1",
Decision::Approved,
"approver1",
None,
));
request.add_decision(ApprovalDecision::new(
request.id,
"stage1",
Decision::Approved,
"approver2",
None,
));
assert_eq!(request.current_stage_approval_count(), 2);
// Advance to next stage - count should reset
request.advance_to_stage("stage2");
assert_eq!(request.current_stage_approval_count(), 0);
}
// ==================== ApprovalWorkflow Tests ====================
#[test]
fn test_workflow_stage_navigation() {
let workflow = ApprovalWorkflow::new("test", "Test")
.add_stage(ApprovalStage::new("s1", "Stage 1"))
.add_stage(ApprovalStage::new("s2", "Stage 2"))
.add_stage(ApprovalStage::new("s3", "Stage 3"));
assert_eq!(workflow.first_stage().unwrap().name, "s1");
assert_eq!(workflow.next_stage(0).unwrap().name, "s2");
assert_eq!(workflow.next_stage(1).unwrap().name, "s3");
assert!(workflow.next_stage(2).is_none());
assert!(!workflow.is_last_stage(0));
assert!(!workflow.is_last_stage(1));
assert!(workflow.is_last_stage(2));
}
#[test]
fn test_workflow_get_stage_by_name() {
let workflow = ApprovalWorkflow::new("test", "Test")
.add_stage(ApprovalStage::new("security", "Security"))
.add_stage(ApprovalStage::new("arch", "Architecture"));
let (index, stage) = workflow.get_stage_by_name("arch").unwrap();
assert_eq!(index, 1);
assert_eq!(stage.name, "arch");
assert!(workflow.get_stage_by_name("nonexistent").is_none());
}
#[test]
fn test_workflow_auto_approve() {
let workflow = ApprovalWorkflow::new("test", "Test").add_stage(
ApprovalStage::new("review", "Review").with_auto_approve_level(EvidenceLevel::Standard),
);
assert!(workflow.should_auto_approve(0, EvidenceLevel::Standard));
assert!(workflow.should_auto_approve(0, EvidenceLevel::ProductSpec));
assert!(!workflow.should_auto_approve(0, EvidenceLevel::Research));
assert!(!workflow.should_auto_approve(0, EvidenceLevel::Commit));
}
#[test]
fn test_workflow_validation() {
// Empty stages - should fail
let workflow = ApprovalWorkflow { name: "test".into(), stages: vec![], ..Default::default() };
assert!(workflow.validate().is_err());
// Empty name - should fail
let workflow = ApprovalWorkflow::new("", "").add_stage(ApprovalStage::new("s1", "S1"));
assert!(workflow.validate().is_err());
// Invalid escalation target - should fail
let workflow = ApprovalWorkflow::new("test", "Test")
.add_stage(ApprovalStage::new("s1", "S1").with_escalation("nonexistent"));
assert!(workflow.validate().is_err());
// Valid workflow
let workflow = ApprovalWorkflow::new("test", "Test").add_stage(ApprovalStage::new("s1", "S1"));
assert!(workflow.validate().is_ok());
}
// ==================== GovernanceStore Tests ====================
#[test]
fn test_store_save_and_get_request() {
let temp = TempDir::new().expect("temp dir");
let store = GovernanceStore::new(temp.path()).expect("create store");
let request =
ApprovalRequest::new(Uuid::new_v4(), "test_pattern", "workflow", "stage1", "user");
store.save_request(&request).expect("save");
let loaded = store.get_request(&request.id).expect("get").unwrap();
assert_eq!(loaded.id, request.id);
assert_eq!(loaded.pattern_name, "test_pattern");
}
#[test]
fn test_store_update_request() {
let temp = TempDir::new().expect("temp dir");
let store = GovernanceStore::new(temp.path()).expect("create store");
let mut request =
ApprovalRequest::new(Uuid::new_v4(), "test_pattern", "workflow", "stage1", "user");
store.save_request(&request).expect("save initial");
// Update the request
request.advance_to_stage("stage2");
store.save_request(&request).expect("save updated");
// Verify update
let loaded = store.get_request(&request.id).expect("get").unwrap();
assert_eq!(loaded.current_stage_index, 1);
assert_eq!(loaded.status.current_stage(), Some("stage2"));
// Verify only one request exists
let all = store.list_all().expect("list all");
assert_eq!(all.len(), 1);
}
#[test]
fn test_store_list_pending() {
let temp = TempDir::new().expect("temp dir");
let store = GovernanceStore::new(temp.path()).expect("create store");
// Create pending request
let pending = ApprovalRequest::new(Uuid::new_v4(), "pending", "workflow", "stage1", "user");
store.save_request(&pending).expect("save pending");
// Create approved request
let mut approved =
ApprovalRequest::new(Uuid::new_v4(), "approved", "workflow", "stage1", "user");
approved.mark_approved();
store.save_request(&approved).expect("save approved");
let pending_list = store.list_pending().expect("list pending");
assert_eq!(pending_list.len(), 1);
assert_eq!(pending_list[0].pattern_name, "pending");
}
#[test]
fn test_store_log_and_get_decisions() {
let temp = TempDir::new().expect("temp dir");
let store = GovernanceStore::new(temp.path()).expect("create store");
let request_id = Uuid::new_v4();
let decision = ApprovalDecision::new(
request_id,
"security_review",
Decision::Approved,
"alice",
Some("LGTM".to_string()),
);
store.log_decision(&decision).expect("log");
let decisions = store.get_decisions(&request_id).expect("get");
assert_eq!(decisions.len(), 1);
assert_eq!(decisions[0].approver, "alice");
assert_eq!(decisions[0].comment, Some("LGTM".to_string()));
}
#[test]
fn test_store_get_request_by_pattern() {
let temp = TempDir::new().expect("temp dir");
let store = GovernanceStore::new(temp.path()).expect("create store");
let pattern_id = Uuid::new_v4();
let request = ApprovalRequest::new(pattern_id, "test", "workflow", "stage1", "user");
store.save_request(&request).expect("save");
let loaded = store.get_request_by_pattern(&pattern_id).expect("get").unwrap();
assert_eq!(loaded.pattern_id, pattern_id);
}
// ==================== GovernanceStateMachine Tests ====================
#[test]
fn test_state_machine_create_request() {
let temp = TempDir::new().expect("temp dir");
let store = GovernanceStore::new(temp.path()).expect("create store");
let config = create_test_config();
let sm = GovernanceStateMachine::new(store, config);
let pattern = create_test_pattern();
let workflow = sm.config.workflows.first().unwrap();
let request = sm.create_request(&pattern, workflow, "test_user").expect("create");
assert_eq!(request.pattern_id, pattern.id);
assert_eq!(request.workflow_name, "test_workflow");
assert!(request.status.is_pending());
assert_eq!(request.status.current_stage(), Some("security_review"));
}
#[test]
fn test_state_machine_approve_single_stage() {
let temp = TempDir::new().expect("temp dir");
let store = GovernanceStore::new(temp.path()).expect("create store");
let config = create_test_config();
let sm = GovernanceStateMachine::new(store, config);
let pattern = create_test_pattern();
let workflow = sm.config.workflows.first().unwrap();
let request = sm.create_request(&pattern, workflow, "test_user").expect("create");
// Approve first stage
let updated = sm.approve(request.id, "approver", Some("LGTM".to_string())).expect("approve");
// Should advance to second stage
assert!(updated.status.is_pending());
assert_eq!(updated.status.current_stage(), Some("arch_review"));
assert_eq!(updated.decisions.len(), 1);
}
#[test]
fn test_state_machine_full_approval() {
let temp = TempDir::new().expect("temp dir");
let store = GovernanceStore::new(temp.path()).expect("create store");
let config = create_test_config();
let sm = GovernanceStateMachine::new(store, config);
let pattern = create_test_pattern();
let workflow = sm.config.workflows.first().unwrap();
let request = sm.create_request(&pattern, workflow, "test_user").expect("create");
// Approve first stage
let updated = sm.approve(request.id, "approver1", None).expect("approve 1");
assert_eq!(updated.status.current_stage(), Some("arch_review"));
// Approve second stage
let final_req = sm.approve(request.id, "approver2", None).expect("approve 2");
assert!(final_req.status.is_approved());
}
#[test]
fn test_state_machine_reject() {
let temp = TempDir::new().expect("temp dir");
let store = GovernanceStore::new(temp.path()).expect("create store");
let config = create_test_config();
let sm = GovernanceStateMachine::new(store, config);
let pattern = create_test_pattern();
let workflow = sm.config.workflows.first().unwrap();
let request = sm.create_request(&pattern, workflow, "test_user").expect("create");
let rejected = sm.reject(request.id, "reviewer", "Too broad".to_string()).expect("reject");
assert!(rejected.status.is_rejected());
if let ApprovalStatus::Rejected { stage, reason } = &rejected.status {
assert_eq!(stage, "security_review");
assert_eq!(reason, "Too broad");
}
}
#[test]
fn test_state_machine_list_pending() {
let temp = TempDir::new().expect("temp dir");
let store = GovernanceStore::new(temp.path()).expect("create store");
let config = create_test_config();
let sm = GovernanceStateMachine::new(store, config);
let pattern = create_test_pattern();
let workflow = sm.config.workflows.first().unwrap();
sm.create_request(&pattern, workflow, "test_user").expect("create");
let pending = sm.list_pending().expect("list");
assert_eq!(pending.len(), 1);
}
// ==================== AuditTrail Tests ====================
#[test]
fn test_audit_log_and_retrieve() {
let temp = TempDir::new().expect("temp dir");
let trail = AuditTrail::new(temp.path()).expect("create trail");
let event = AuditEvent::new(
AuditEventType::RequestCreated,
Uuid::new_v4(),
Uuid::new_v4(),
"test_user".to_string(),
serde_json::json!({"workflow": "test"}),
);
trail.log_event(event.clone()).expect("log");
let events = trail.get_all_events().expect("get all");
assert_eq!(events.len(), 1);
assert_eq!(events[0].id, event.id);
}
#[test]
fn test_audit_pattern_timeline() {
let temp = TempDir::new().expect("temp dir");
let trail = AuditTrail::new(temp.path()).expect("create trail");
let pattern_id = Uuid::new_v4();
let other_pattern = Uuid::new_v4();
// Log events for our pattern
trail
.log_event(AuditEvent::new(
AuditEventType::RequestCreated,
Uuid::new_v4(),
pattern_id,
"user".to_string(),
serde_json::json!({}),
))
.expect("log 1");
trail
.log_event(AuditEvent::new(
AuditEventType::StageApproved,
Uuid::new_v4(),
pattern_id,
"user".to_string(),
serde_json::json!({}),
))
.expect("log 2");
// Log event for other pattern
trail
.log_event(AuditEvent::new(
AuditEventType::RequestCreated,
Uuid::new_v4(),
other_pattern,
"user".to_string(),
serde_json::json!({}),
))
.expect("log 3");
let timeline = trail.get_pattern_timeline(&pattern_id).expect("get timeline");
assert_eq!(timeline.len(), 2);
}
// ==================== GovernanceConfig Tests ====================
#[test]
fn test_governance_config_validation() {
// Enabled with no workflows - should fail
let config = GovernanceConfig { enabled: true, ..Default::default() };
assert!(config.validate().is_err());
// Enabled with valid workflow - should pass
let config = create_test_config();
assert!(config.validate().is_ok());
// Invalid default workflow - should fail
let config = GovernanceConfig {
enabled: true,
default_workflow: Some("nonexistent".to_string()),
workflows: vec![
ApprovalWorkflow::new("other", "Other").add_stage(ApprovalStage::new("s1", "S1"))
],
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_governance_config_get_workflow() {
let config = create_test_config();
assert!(config.get_workflow("test_workflow").is_some());
assert!(config.get_workflow("nonexistent").is_none());
assert!(config.get_default_workflow().is_some());
}
// ==================== Integration Tests ====================
#[test]
fn test_full_governance_workflow() {
let temp = TempDir::new().expect("temp dir");
let store = GovernanceStore::new(temp.path()).expect("create store");
let config = create_test_config();
let sm = GovernanceStateMachine::new(store, config);
// Create pattern and request
let pattern = create_test_pattern();
let workflow = sm.config.workflows.first().unwrap();
let request = sm.create_request(&pattern, workflow, "developer").expect("create");
// Verify initial state
assert!(request.status.is_pending());
assert_eq!(request.status.current_stage(), Some("security_review"));
// Security review approves
let after_security = sm
.approve(request.id, "security_lead", Some("Looks secure".to_string()))
.expect("approve security");
assert_eq!(after_security.status.current_stage(), Some("arch_review"));
// Architecture review approves
let final_req = sm
.approve(request.id, "architect", Some("Good pattern".to_string()))
.expect("approve arch");
assert!(final_req.status.is_approved());
assert_eq!(final_req.decisions.len(), 2);
// Verify persisted state
let loaded = sm.get_request(&request.id).expect("get").unwrap();
assert!(loaded.status.is_approved());
}
#[test]
fn test_auto_approve_with_evidence() {
let temp = TempDir::new().expect("temp dir");
let store = GovernanceStore::new(temp.path()).expect("create store");
// Create config with auto-approve at Standard evidence level
let config = GovernanceConfig {
enabled: true,
default_workflow: Some("fast_track".to_string()),
workflows: vec![ApprovalWorkflow::new("fast_track", "Fast track").add_stage(
ApprovalStage::new("review", "Review").with_auto_approve_level(EvidenceLevel::Standard),
)],
..Default::default()
};
let sm = GovernanceStateMachine::new(store, config);
// Create pattern with Standard evidence
let pattern = create_pattern_with_evidence(EvidenceLevel::Standard);
let workflow = sm.config.workflows.first().unwrap();
// Request should auto-approve at creation
let request = sm.create_request(&pattern, workflow, "developer").expect("create");
assert!(request.status.is_approved());
}

View File

@ -0,0 +1,381 @@
//! Integration tests for Knowledge Lifecycle Management (Phase 13).
//!
//! Tests cover:
//! - Pattern deprecation with sunset dates
//! - Status transitions and audit trail
//! - Migration tracking across projects
//! - Scan integration with deprecated pattern detection
use chrono::{Duration, Utc};
use tempfile::tempdir;
use uuid::Uuid;
use crate::learning::{ClaimTemplate, LearnedPattern, ValueType};
use crate::lifecycle::{KnowledgeStatus, LifecycleStore, MigrationStore, StatusTransition};
use crate::scope::ScopeId;
use crate::types::Language;
/// Test basic lifecycle status transitions.
#[test]
fn test_lifecycle_status_transitions() {
let dir = tempdir().expect("create temp dir");
let store = LifecycleStore::new(dir.path()).expect("create lifecycle store");
let pattern_id = Uuid::new_v4();
// Initial state should be Active (implicit)
assert!(store.get_current_status(&pattern_id).is_none());
// Deprecate the pattern
let deprecation = KnowledgeStatus::Deprecated {
reason: "Security vulnerability discovered".to_string(),
superseded_by: None,
sunset_date: Some(Utc::now() + Duration::days(90)),
migration_guide: Some("https://example.com/migration".to_string()),
};
let transition1 = StatusTransition::new(
pattern_id,
KnowledgeStatus::Active,
deprecation.clone(),
"security-team",
Some("CVE-2024-1234 requires deprecation".to_string()),
);
store.record_transition(transition1).expect("record transition");
// Verify status changed
let status = store.get_current_status(&pattern_id).expect("has status");
assert!(status.is_deprecated());
// Archive the pattern after sunset
let archived = KnowledgeStatus::Archived {
archived_at: Utc::now(),
reason: "Pattern removed after sunset".to_string(),
};
let transition2 = StatusTransition::new(
pattern_id,
deprecation,
archived,
"system",
Some("Auto-archived after sunset".to_string()),
);
store.record_transition(transition2).expect("record transition");
// Verify final status
let status = store.get_current_status(&pattern_id).expect("has status");
assert!(status.is_terminal());
// Check history
let history = store.get_history(&pattern_id);
assert_eq!(history.len(), 2);
assert_eq!(history[0].initiated_by, "security-team");
assert_eq!(history[1].initiated_by, "system");
}
/// Test sunset date tracking.
#[test]
fn test_sunset_date_tracking() {
// Future sunset date
let future_date = Utc::now() + Duration::days(30);
let status = KnowledgeStatus::Deprecated {
reason: "Will be removed".to_string(),
superseded_by: None,
sunset_date: Some(future_date),
migration_guide: None,
};
assert!(!status.is_past_sunset());
let days = status.days_until_sunset().expect("has days");
assert!((29..=31).contains(&days)); // Account for timing
// Past sunset date
let past_date = Utc::now() - Duration::days(10);
let overdue = KnowledgeStatus::Deprecated {
reason: "Should have been removed".to_string(),
superseded_by: None,
sunset_date: Some(past_date),
migration_guide: None,
};
assert!(overdue.is_past_sunset());
let days = overdue.days_until_sunset().expect("has days");
assert!(days < 0);
}
/// Test pattern with replacement (superseded_by).
#[test]
fn test_pattern_supersession() {
let new_pattern_id = Uuid::new_v4();
let deprecated = KnowledgeStatus::Deprecated {
reason: "Replaced by improved pattern".to_string(),
superseded_by: Some(new_pattern_id),
sunset_date: Some(Utc::now() + Duration::days(60)),
migration_guide: Some("Run: aphoria migrate <pattern-id>".to_string()),
};
assert_eq!(deprecated.superseded_by(), Some(new_pattern_id));
assert!(deprecated.is_active()); // Deprecated patterns still match
// Later mark as fully superseded (no longer active)
let superseded =
KnowledgeStatus::Superseded { replaced_by: new_pattern_id, superseded_at: Utc::now() };
assert!(!superseded.is_active()); // Superseded patterns don't match
assert!(superseded.is_terminal());
assert_eq!(superseded.superseded_by(), Some(new_pattern_id));
}
/// Test migration store tracking.
#[test]
fn test_migration_tracking() {
let dir = tempdir().expect("create temp dir");
let store = MigrationStore::new(dir.path()).expect("create migration store");
let pattern_id = Uuid::new_v4();
// Record usages across projects
for i in 0..5 {
let usage = crate::lifecycle::DeprecatedUsage::new(
pattern_id,
"tls_min_version",
format!("project{}/src/config.rs", i),
42 + i,
format!("project-hash-{}", i),
);
store.record_usage(usage).expect("record usage");
}
// Check progress
let progress = store.get_progress(&pattern_id, "tls_min_version");
assert_eq!(progress.total_usages, 5);
assert_eq!(progress.resolved_usages, 0);
assert_eq!(progress.completion_percent(), 0.0);
assert!(!progress.is_complete());
// Resolve some usages
store.resolve_usage(&pattern_id, "project0/src/config.rs", 42).expect("resolve");
store.resolve_usage(&pattern_id, "project1/src/config.rs", 43).expect("resolve");
// Check updated progress
let progress = store.get_progress(&pattern_id, "tls_min_version");
assert_eq!(progress.total_usages, 5);
assert_eq!(progress.resolved_usages, 2);
assert_eq!(progress.completion_percent(), 40.0);
assert_eq!(progress.pending_usages(), 3);
}
/// Test migration with scope tracking.
#[test]
fn test_migration_with_scopes() {
let dir = tempdir().expect("create temp dir");
let store = MigrationStore::new(dir.path()).expect("create migration store");
let pattern_id = Uuid::new_v4();
let team_scope = ScopeId::team("platform");
// Record usage with scope
let usage = crate::lifecycle::DeprecatedUsage::new(
pattern_id,
"jwt_validation",
"src/auth.rs",
100,
"api-gateway-hash",
)
.with_scope(team_scope.clone());
store.record_usage(usage).expect("record usage");
// Get usages by scope
let team_usages = store.get_usages_by_scope(&team_scope);
assert_eq!(team_usages.len(), 1);
assert_eq!(team_usages[0].pattern_name, "jwt_validation");
}
/// Test lifecycle field in LearnedPattern.
#[test]
fn test_learned_pattern_lifecycle() {
let template =
ClaimTemplate::new("tls/min_version", "version", ValueType::Text, "TLS minimum version");
let mut pattern = LearnedPattern::new(
"const TLS_MIN = \"1.0\"",
"const TLS_MIN = <string:version>",
template,
Language::Rust,
"project-hash",
0.95,
);
// Default lifecycle is Active
assert!(pattern.lifecycle.is_active());
assert!(!pattern.lifecycle.is_deprecated());
// Update to deprecated
let deprecated = KnowledgeStatus::Deprecated {
reason: "TLS 1.0 is insecure".to_string(),
superseded_by: None,
sunset_date: Some(Utc::now() + Duration::days(30)),
migration_guide: Some("Use TLS 1.2 or higher".to_string()),
};
pattern.lifecycle.update_status(deprecated);
assert!(pattern.lifecycle.is_deprecated());
assert!(pattern.lifecycle.is_active()); // Deprecated patterns still match
}
/// Test lifecycle store persistence.
#[test]
fn test_lifecycle_store_persistence() {
let dir = tempdir().expect("create temp dir");
let pattern_id = Uuid::new_v4();
// Create store and record transition
{
let store = LifecycleStore::new(dir.path()).expect("create store");
let transition = StatusTransition::new(
pattern_id,
KnowledgeStatus::Active,
KnowledgeStatus::Deprecated {
reason: "Persistence test".to_string(),
superseded_by: None,
sunset_date: None,
migration_guide: None,
},
"test-user",
None,
);
store.record_transition(transition).expect("record");
}
// Reopen store and verify
{
let store = LifecycleStore::new(dir.path()).expect("reopen store");
let status = store.get_current_status(&pattern_id).expect("has status");
assert!(status.is_deprecated());
let history = store.get_history(&pattern_id);
assert_eq!(history.len(), 1);
assert_eq!(history[0].initiated_by, "test-user");
}
}
/// Test getting overdue patterns.
#[test]
fn test_get_overdue_patterns() {
let dir = tempdir().expect("create temp dir");
let store = LifecycleStore::new(dir.path()).expect("create store");
let pattern1 = Uuid::new_v4();
let pattern2 = Uuid::new_v4();
let pattern3 = Uuid::new_v4();
// Pattern 1: deprecated with past sunset
store
.record_transition(StatusTransition::new(
pattern1,
KnowledgeStatus::Active,
KnowledgeStatus::Deprecated {
reason: "Old".to_string(),
superseded_by: None,
sunset_date: Some(Utc::now() - Duration::days(10)),
migration_guide: None,
},
"test",
None,
))
.expect("record");
// Pattern 2: deprecated with future sunset
store
.record_transition(StatusTransition::new(
pattern2,
KnowledgeStatus::Active,
KnowledgeStatus::Deprecated {
reason: "Soon".to_string(),
superseded_by: None,
sunset_date: Some(Utc::now() + Duration::days(30)),
migration_guide: None,
},
"test",
None,
))
.expect("record");
// Pattern 3: deprecated with no sunset
store
.record_transition(StatusTransition::new(
pattern3,
KnowledgeStatus::Active,
KnowledgeStatus::Deprecated {
reason: "Eventually".to_string(),
superseded_by: None,
sunset_date: None,
migration_guide: None,
},
"test",
None,
))
.expect("record");
// Only pattern1 should be overdue
let overdue = store.get_overdue_patterns();
assert_eq!(overdue.len(), 1);
assert_eq!(overdue[0].0, pattern1);
}
/// Test CSV export of migration data.
#[test]
fn test_migration_export_csv() {
let dir = tempdir().expect("create temp dir");
let store = MigrationStore::new(dir.path()).expect("create store");
let pattern_id = Uuid::new_v4();
let usage = crate::lifecycle::DeprecatedUsage::new(
pattern_id,
"test_pattern",
"src/main.rs",
50,
"hash-abc",
);
store.record_usage(usage).expect("record");
let csv = store.export_csv(true);
assert!(csv.contains("pattern_id"));
assert!(csv.contains("test_pattern"));
assert!(csv.contains("src/main.rs"));
assert!(csv.contains("50"));
}
/// Test status transition audit trail.
#[test]
fn test_status_transition_audit() {
let pattern_id = Uuid::new_v4();
let transition = StatusTransition::new(
pattern_id,
KnowledgeStatus::Active,
KnowledgeStatus::Deprecated {
reason: "Audit test".to_string(),
superseded_by: None,
sunset_date: None,
migration_guide: None,
},
"admin@example.com",
Some("Approved in security review #123".to_string()),
);
assert_eq!(transition.pattern_id, pattern_id);
assert_eq!(transition.initiated_by, "admin@example.com");
assert_eq!(transition.comment, Some("Approved in security review #123".to_string()));
let desc = transition.description();
assert!(desc.contains("active"));
assert!(desc.contains("deprecated"));
}

View File

@ -10,11 +10,15 @@
//! - `drift_detection`: Drift detection tests (Phase 4B)
//! - `ack_expiry`: Acknowledgment expiry tests (Phase 10.1)
//! - `predicate_alias_persistence`: Predicate alias persistence tests (Phase 6.5.3)
//! - `lifecycle_tests`: Knowledge Lifecycle Management tests (Phase 13)
//! - `governance_tests`: Governance Workflow tests (Phase 14)
mod ack_expiry;
mod conflict_detection;
mod drift_detection;
mod golden_path;
mod governance_tests;
mod lifecycle_tests;
mod policy_source;
mod predicate_alias_persistence;
mod scan_basic;

View File

@ -53,6 +53,8 @@ async fn test_predicate_alias_persistence_during_import() {
vec![],
predicate_aliases,
&signing_key,
None,
None,
)
.expect("create pack");
@ -125,6 +127,8 @@ async fn test_predicate_alias_survives_restart() {
vec![],
predicate_aliases,
&signing_key,
None,
None,
)
.expect("create pack");
@ -191,6 +195,8 @@ async fn test_predicate_alias_merge_from_multiple_packs() {
aliases: vec!["required".to_string()],
}],
&signing_key,
None,
None,
)
.expect("create pack 1");
@ -220,6 +226,8 @@ async fn test_predicate_alias_merge_from_multiple_packs() {
aliases: vec!["mandatory".to_string()],
}],
&signing_key,
None,
None,
)
.expect("create pack 2");

View File

@ -38,6 +38,7 @@ async fn test_scan_returns_result() {
debug: false,
sync: false,
file_source: FileSource::All,
benchmark: false,
};
let mut config = AphoriaConfig::default();

View File

@ -31,6 +31,7 @@ version = "0.1.0"
debug: false,
sync: false,
file_source: FileSource::All,
benchmark: false,
};
let mut config = AphoriaConfig::default();
@ -85,6 +86,7 @@ version = "0.1.0"
debug: false,
sync: false,
file_source: FileSource::All,
benchmark: false,
};
let mut config = AphoriaConfig::default();
@ -148,6 +150,7 @@ version = "0.1.0"
debug: false,
sync: false,
file_source: FileSource::All,
benchmark: false,
};
let ephemeral_result = run_scan(ephemeral_args, &config).await.expect("ephemeral scan");
@ -161,6 +164,7 @@ version = "0.1.0"
debug: false,
sync: false,
file_source: FileSource::All,
benchmark: false,
};
let persistent_result = run_scan(persistent_args, &config).await.expect("persistent scan");
@ -236,6 +240,7 @@ version = "0.1.0"
debug: false,
sync: true, // Enable observation write-back
file_source: FileSource::All,
benchmark: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");
@ -285,6 +290,7 @@ version = "0.1.0"
debug: false,
sync: false, // Disabled
file_source: FileSource::All,
benchmark: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");
@ -328,6 +334,7 @@ version = "0.1.0"
debug: false,
sync: false, // Would be ignored anyway in ephemeral mode
file_source: FileSource::All,
benchmark: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");
@ -380,6 +387,7 @@ version = "0.1.0"
debug: false,
sync: true, // Record observations
file_source: FileSource::All,
benchmark: false,
};
let result1 = run_scan(args1, &config).await.expect("first scan should succeed");
@ -406,6 +414,7 @@ version = "0.1.0"
debug: false,
sync: false, // Don't need to sync on drift detection
file_source: FileSource::All,
benchmark: false,
};
let result2 = run_scan(args2, &config).await.expect("second scan should succeed");
@ -460,6 +469,7 @@ version = "0.1.0"
debug: false,
sync: false,
file_source: FileSource::All,
benchmark: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");

View File

@ -237,6 +237,7 @@ async fn test_staged_with_persist_and_sync() {
debug: false,
sync: false,
file_source: FileSource::Staged,
benchmark: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");

View File

@ -56,7 +56,7 @@ pub struct ConflictingSource {
/// Information about a Trust Pack that provided a policy assertion.
///
/// Used to show provenance in conflict reports, e.g.:
/// "Source: Acme Security Standard (a1b2c3d4)"
/// "Source: Acme Security Standard v0.1.0 (Platform Security Team)"
#[derive(Debug, Clone)]
pub struct PolicySourceInfo {
/// Name of the Trust Pack (e.g., "Acme Security Standard").
@ -67,6 +67,13 @@ pub struct PolicySourceInfo {
/// First 8 hex characters of the issuer's public key.
pub issuer_hex: String,
/// Human-readable name of the signer (e.g., "Platform Security Team").
/// Falls back to issuer_hex for display if None.
pub signer_name: Option<String>,
/// Contact info for the signer (e.g., "#security-policy", "security@acme.com").
pub contact: Option<String>,
}
impl ConflictingSource {

View File

@ -55,6 +55,11 @@ pub struct ScanArgs {
/// File source: All (default) or Staged (for pre-commit hooks).
pub file_source: FileSource,
/// Enable benchmark mode with timing breakdown.
/// When enabled, timing measurements are captured for each scan phase
/// and included in the output.
pub benchmark: bool,
}
/// Arguments for the acknowledge command.

View File

@ -10,7 +10,10 @@ mod verdict;
pub use claim::{ConflictingSource, ExtractedClaim, PolicySourceInfo};
pub use command::{AcknowledgeArgs, BlessArgs, FileSource, ScanArgs, ScanMode, UpdateArgs};
pub use language::Language;
pub use result::{ConflictResult, ConflictTrace, DriftResult, PriorObservation, ScanResult};
pub use result::{
ConflictResult, ConflictTrace, DeprecatedUsageResult, DriftResult, PriorObservation,
ScanResult, ScanTiming,
};
pub use result::AcknowledgmentInfo;
pub use verdict::Verdict;

View File

@ -40,6 +40,37 @@ pub struct ScanResult {
/// Number of Tier 4 observations recorded (when --sync is enabled).
/// These are claims with no authority conflict that become "project memory".
pub observations_recorded: usize,
/// Benchmark timing breakdown (only populated when --benchmark is set).
pub timing: Option<ScanTiming>,
/// Deprecated pattern usages detected.
///
/// Populated when deprecated patterns are matched during scan.
/// These generate FLAG warnings with migration guidance.
pub deprecated_usages: Vec<DeprecatedUsageResult>,
}
/// Timing breakdown for benchmark mode.
///
/// Captures timing for each phase of the scan process to help identify
/// performance bottlenecks and measure scan speed on large codebases.
#[derive(Debug, Clone)]
pub struct ScanTiming {
/// Time spent walking the project (discovering files) in milliseconds.
pub walk_ms: u64,
/// Time spent extracting claims from files in milliseconds.
pub extraction_ms: u64,
/// Time spent checking conflicts against authority in milliseconds.
pub conflict_ms: u64,
/// Total elapsed time in milliseconds.
pub total_ms: u64,
/// Lines of code scanned (if available).
pub lines_of_code: Option<usize>,
}
impl ScanResult {
@ -55,6 +86,8 @@ impl ScanResult {
format: format.to_string(),
debug: false,
observations_recorded: 0,
timing: None,
deprecated_usages: vec![],
}
}
@ -80,6 +113,24 @@ impl ScanResult {
self.drifts.len()
}
/// Check if any deprecated pattern usages were detected.
#[must_use]
pub fn has_deprecated_usages(&self) -> bool {
!self.deprecated_usages.is_empty()
}
/// Count of deprecated pattern usages.
#[must_use]
pub fn deprecated_usage_count(&self) -> usize {
self.deprecated_usages.len()
}
/// Count of overdue (past sunset) deprecated usages.
#[must_use]
pub fn overdue_deprecated_count(&self) -> usize {
self.deprecated_usages.iter().filter(|u| u.is_past_sunset()).count()
}
/// Count conflicts by verdict.
pub fn count_by_verdict(&self, verdict: Verdict) -> usize {
self.conflicts.iter().filter(|c| c.verdict == verdict).count()
@ -285,6 +336,81 @@ pub struct PriorObservation {
pub line: usize,
}
/// Result of a deprecated pattern usage.
///
/// Generated when a deprecated pattern matches during scan. The pattern
/// continues to FLAG (not BLOCK) but includes migration guidance.
#[derive(Debug, Clone)]
pub struct DeprecatedUsageResult {
/// ID of the deprecated pattern.
pub pattern_id: uuid::Uuid,
/// Human-readable pattern name.
pub pattern_name: String,
/// File where the pattern was used.
pub file_path: String,
/// Line number in the file.
pub line: usize,
/// Reason for deprecation.
pub reason: String,
/// Name of the pattern that supersedes this one (if any).
pub superseded_by: Option<String>,
/// URL or text with migration guidance.
pub migration_guide: Option<String>,
/// Days until sunset (negative if past due).
pub days_until_sunset: Option<i64>,
}
impl DeprecatedUsageResult {
/// Check if this usage is past its sunset date.
pub fn is_past_sunset(&self) -> bool {
self.days_until_sunset.map(|d| d < 0).unwrap_or(false)
}
/// Get severity based on sunset date.
pub fn severity(&self) -> &'static str {
match self.days_until_sunset {
Some(d) if d < 0 => "OVERDUE",
Some(d) if d < 30 => "URGENT",
Some(_) => "WARNING",
None => "INFO",
}
}
}
impl fmt::Display for DeprecatedUsageResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let severity = self.severity();
writeln!(f, " {} (deprecated) {}", severity, self.pattern_name)?;
writeln!(f, " Location: {}:{}", self.file_path, self.line)?;
writeln!(f, " Reason: {}", self.reason)?;
if let Some(ref replacement) = self.superseded_by {
writeln!(f, " Replace: Use '{}'", replacement)?;
}
if let Some(ref guide) = self.migration_guide {
writeln!(f, " Guide: {}", guide)?;
}
if let Some(days) = self.days_until_sunset {
if days < 0 {
writeln!(f, " Sunset: OVERDUE by {} days", -days)?;
} else {
writeln!(f, " Sunset: {} days remaining", days)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -301,11 +427,15 @@ mod tests {
format: "table".to_string(),
debug: false,
observations_recorded: 0,
timing: None,
deprecated_usages: vec![],
};
assert!(!result.has_blocks());
assert!(!result.has_flags());
assert!(!result.has_drifts());
assert_eq!(result.drift_count(), 0);
assert!(!result.has_deprecated_usages());
assert_eq!(result.deprecated_usage_count(), 0);
}
}

View File

@ -9,12 +9,14 @@ Aphoria transforms your organization's implicit decisions into explicit, auditab
## The Problem
Every organization has institutional knowledge. It lives in:
- The senior engineer's head ("we always validate JWT audience")
- The config file nobody reads ("verify=false was a hotfix in 2019")
- The Confluence page with 3 views ("our timeout policy is 30s max")
- The Stack Overflow answer the intern copied ("this worked for someone")
This knowledge is **invisible, inconsistent, and fragile**:
- When Sarah leaves, her context leaves with her
- New hires copy patterns from 2019 code that predates current standards
- Team A's conventions contradict Team B's - neither knows
@ -33,7 +35,7 @@ Aphoria is a **knowledge compounding system** that learns from your organization
```
┌─────────────────────────────────────────────────────────────────┐
│ TIER 1: POLICIES (Explicit, Authoritative) │
│ ───────────────────────────────────────────────────────────── │
│ ─────────────────────────────────────────────────────────────-
│ • RFC 7519: JWT audience validation required │
│ • OWASP A03:2021: No SQL string concatenation │
│ • Internal Policy: TLS 1.3 minimum (signed: CISO, 2024-01) │
@ -44,7 +46,7 @@ Aphoria is a **knowledge compounding system** that learns from your organization
┌─────────────────────────────────────────────────────────────────┐
│ TIER 2: CONVENTIONS (Emergent, Team-Approved) │
│ ───────────────────────────────────────────────────────────── │
│ ─────────────────────────────────────────────────────────────-
│ • API versioning: /api/v{major}/{resource} │
│ • Error format: {"error": {"code": X, "message": Y}} │
│ • Retry pattern: exponential backoff with jitter │
@ -55,7 +57,7 @@ Aphoria is a **knowledge compounding system** that learns from your organization
┌─────────────────────────────────────────────────────────────────┐
│ TIER 3: OBSERVATIONS (Learning, Not Enforced) │
│ ───────────────────────────────────────────────────────────── │
│ ─────────────────────────────────────────────────────────────-
│ • @alex's logging format (3 usages) │
│ • @jordan's config pattern (1 usage) │
│ → Silent capture, potential future convention │
@ -65,6 +67,7 @@ Aphoria is a **knowledge compounding system** that learns from your organization
### The Workflow
**Day 1: Install Aphoria**
```bash
$ aphoria init --org acme --team platform
Connected to Acme Engineering knowledge graph
@ -72,6 +75,7 @@ Loaded: 12 policies, 47 conventions, 156 observations
```
**Every Commit: Learn and Guide**
```bash
$ git commit -m "Add payment processing endpoint"
@ -85,6 +89,7 @@ Aphoria scan:
```
**New Developer Joins:**
```bash
$ git commit -m "Add user profile endpoint"
@ -102,6 +107,7 @@ Aphoria guidance:
```
**Knowledge Compounds:**
```
Acme Engineering (6 months)
├── 12 Policies (explicit, CISO-signed)
@ -130,6 +136,7 @@ aphoria scan --persist --sync
```
Every scan:
- **Detects** security patterns (TLS, JWT, SQL injection, XSS)
- **Extracts** configuration decisions (timeouts, pool sizes, retry policies)
- **Captures** new patterns as observations
@ -141,7 +148,7 @@ Every scan:
Not every observation becomes a convention. Graduation requires:
| Criteria | Threshold | Why |
|----------|-----------|-----|
| ------------ | --------------------- | ------------------------- |
| Frequency | 5+ usages | Not a one-off hack |
| Consistency | Same pattern | Not random variation |
| Authority | Senior contributor | Not a junior's experiment |
@ -260,6 +267,7 @@ New code using deprecated patterns gets guidance toward the replacement.
## Integration Points
### Claude Code Skill
```
/aphoria scan # Run scan on current project
/aphoria explain <conflict> # Explain why this is flagged
@ -268,6 +276,7 @@ New code using deprecated patterns gets guidance toward the replacement.
```
### Pre-commit Hook
```yaml
# .pre-commit-config.yaml
repos:
@ -280,6 +289,7 @@ repos:
```
### CI/CD Pipeline
```yaml
# GitHub Actions
- name: Aphoria Scan
@ -292,6 +302,7 @@ repos:
```
### Central Knowledge Server
```bash
# Deploy org-wide knowledge graph
aphoria server --org acme --port 18187
@ -310,7 +321,7 @@ aphoria init --server https://aphoria.acme.internal
**Not a policy wiki.** Wikis are written once and forgotten. Aphoria captures decisions as they happen and surfaces them at the moment they're relevant.
**Not AI autocomplete.** Copilot suggests code from the internet. Aphoria surfaces *your org's* decisions at the moment you're about to contradict them.
**Not AI autocomplete.** Copilot suggests code from the internet. Aphoria surfaces _your org's_ decisions at the moment you're about to contradict them.
---

View File

@ -21,7 +21,7 @@ use tracing::{debug, instrument};
/// Information about a Trust Pack that provided a policy assertion.
///
/// Used to show provenance in conflict reports, e.g.:
/// "Source: Acme Security Standard (a1b2c3d4)"
/// "Source: Acme Security Standard v0.1.0 (Platform Security Team)"
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackSourceInfo {
/// Name of the Trust Pack (e.g., "Acme Security Standard").
@ -32,6 +32,15 @@ pub struct PackSourceInfo {
/// First 8 hex characters of the issuer's public key.
pub issuer_hex: String,
/// Human-readable name of the signer (e.g., "Platform Security Team").
/// Falls back to issuer_hex for display if None.
#[serde(default)]
pub signer_name: Option<String>,
/// Contact info for the signer (e.g., "#security-policy", "security@acme.com").
#[serde(default)]
pub contact: Option<String>,
}
/// Specialized storage trait for pack source tracking.
@ -150,6 +159,8 @@ mod tests {
pack_name: "Acme Security Standard".to_string(),
pack_version: "1.0.0".to_string(),
issuer_hex: "a1b2c3d4".to_string(),
signer_name: Some("Platform Security Team".to_string()),
contact: Some("#security-policy".to_string()),
};
// Store pack source
@ -184,11 +195,15 @@ mod tests {
pack_name: "Pack V1".to_string(),
pack_version: "1.0.0".to_string(),
issuer_hex: "aaaabbbb".to_string(),
signer_name: None,
contact: None,
};
let info2 = PackSourceInfo {
pack_name: "Pack V2".to_string(),
pack_version: "2.0.0".to_string(),
issuer_hex: "ccccdddd".to_string(),
signer_name: Some("New Team".to_string()),
contact: Some("new@example.com".to_string()),
};
// Store first version
@ -213,11 +228,15 @@ mod tests {
pack_name: "TLS Policy".to_string(),
pack_version: "1.0.0".to_string(),
issuer_hex: "11112222".to_string(),
signer_name: None,
contact: None,
};
let info2 = PackSourceInfo {
pack_name: "JWT Policy".to_string(),
pack_version: "2.0.0".to_string(),
issuer_hex: "33334444".to_string(),
signer_name: None,
contact: None,
};
pack_source_store.set_pack_source("rfc://5246/tls", &info1).await.expect("set 1");

View File

@ -6,9 +6,10 @@
## The Problem: Databases Force False Certainty
Current databases (Postgres, Neo4j, Vector DBs) suffer from **The Tower of Babel** problem: they store *Data*, not *Evidence*. They are deterministic, stateless, and brittle.
Current databases (Postgres, Neo4j, Vector DBs) suffer from **The Tower of Babel** problem: they store _Data_, not _Evidence_. They are deterministic, stateless, and brittle.
When multiple agents observe the world and report different things, traditional databases force you to:
- **Pick a winner** (losing the disagreement)
- **Version-table chaos** (complexity explodes)
- **Application logic everywhere** (authority weighting, decay, cascades)
@ -29,7 +30,7 @@ Episteme rejects the idea of a single, static "database state." Instead, it mode
Every use case must demonstrate at least one pillar. If Postgres could do it, it's not a compelling use case.
| Pillar | What It Enables | Postgres Gap |
|--------|-----------------|--------------|
| ----------------------------- | -------------------------------------------------- | ---------------------------------------------- |
| **First-Class Contradiction** | Hold conflicting facts without forcing resolution | Must pick one value or version-table chaos |
| **Invalidation Cascades** | Retracted evidence flags all downstream decisions | Recursive CTEs don't scale, app logic drifts |
| **Multi-Signature Consensus** | Weighted trust via cryptographic co-signatures | Join tables have no cryptographic proof |
@ -69,7 +70,7 @@ struct Assertion {
Every assertion has a source class that structurally affects resolution weight and decay:
| Tier | Class | Example | Decay Half-Life | Authority Weight |
|------|-------|---------|-----------------|------------------|
| ---- | ----------------- | --------------------- | --------------- | ---------------- |
| 0 | **Regulatory** | FDA label, SEC filing | Never | 1.0 |
| 1 | **Clinical** | Peer-reviewed RCTs | 2 years | 0.9 |
| 2 | **Observational** | Real-world evidence | 1 year | 0.7 |
@ -86,7 +87,7 @@ Reading applies a **Lens** to collapse the probabilistic field into a concrete a
### Resolution Lenses (Pick a Winner)
| Lens | Behavior |
|------|----------|
| -------------- | ---------------------------------------- |
| **Recency** | Last writer wins |
| **Consensus** | Highest cluster density of object values |
| **Authority** | Filter by TrustRank reputation |
@ -96,7 +97,7 @@ Reading applies a **Lens** to collapse the probabilistic field into a concrete a
### Analysis Lenses (Surface Disagreement)
| Lens | Behavior |
|------|----------|
| --------------------- | ------------------------------------------------------- |
| **Skeptic** | Return all claims with conflict score and weight shares |
| **Layered Consensus** | Per-source-class resolution (tier-by-tier visibility) |
| **Constraints** | Pre-flight check for must_use/forbidden predicates |
@ -106,6 +107,7 @@ The **Skeptic** and **Layered Consensus** lenses are key differentiators: they a
## Key Capabilities
### Time-Travel Queries
"What was the known risk profile when I started Semaglutide in June 2023?"
```http
@ -115,6 +117,7 @@ GET /query?subject=semaglutide&predicate=side_effects&as_of=1687000000
The append-only DAG preserves every historical state. Time travel is a hash lookup, not a reconstruction.
### Semantic Decay
Confidence decays based on source class. Old Reddit posts fade; regulatory filings persist:
```http
@ -122,6 +125,7 @@ GET /query?subject=semaglutide&predicate=efficacy&source_class_decay=true
```
### Conflict Analysis
Instead of "here is the answer," show "here is the shape of disagreement":
```http
@ -131,6 +135,7 @@ GET /skeptic?subject=semaglutide&predicate=gastroparesis_risk
Returns: which tiers agree, which disagree, emerging signals without clinical evidence.
### Query Audit Trail
Every query is logged with full provenance. "Why did you believe that?" is answerable:
```http
@ -156,8 +161,8 @@ Votes are append-only. A background Materializer aggregates votes to update O(1)
Users subscribe to "Trust Packs" (curated lists of trusted agents) to filter reality:
- *"The Skeptical Cardio Pack"* filters out low-quality cardiac studies
- *"Mayo Clinic Curated"* only shows assertions from verified Mayo researchers
- _"The Skeptical Cardio Pack"_ filters out low-quality cardiac studies
- _"Mayo Clinic Curated"_ only shows assertions from verified Mayo researchers
Trust Packs are BitSet overlays that filter the Consensus Lens efficiently.
@ -173,13 +178,14 @@ Deep Research is computationally expensive. Episteme enforces token-bucket quota
## Architecture: The Biological Stack
| Layer | Crate | Role |
|-------|-------|------|
| --------------- | ------------------------------- | ---------------------------------------- |
| **The Spine** | `stemedb-wal` | Append-only WAL for durability |
| **The Lattice** | `stemedb-storage` | KV store, indexes, vector/visual indices |
| **The Cortex** | `stemedb-query`, `stemedb-lens` | Query engine, Lenses, Materializer |
| **The Surface** | `stemedb-api` | HTTP API with OpenAPI docs |
The biological metaphor:
- **Spine:** Raw persistence. Never loses a claim.
- **Lattice:** Connectivity. O(1) lookups via compound indexes.
- **Cortex:** Reasoning. Collapse probability into answers.
@ -187,18 +193,21 @@ The biological metaphor:
## Future Vision
### Forking Reality (Planned)
Agents simulate futures without polluting the main branch via **Copy-on-Write Branching** using Sparse Merkle Trees.
### The Super Curator (Planned)
A specialized swarm of reviewer agents that audits high-variance facts and escalates emerging signals.
### The Simulator (Planned)
A pipeline that converts high-confidence failure logs into synthetic training trajectories.
## The Git Analogy
| Git Concept | Episteme Equivalent |
|-------------|-------------------|
| ----------- | ---------------------------------------- |
| Commit | Assertion (immutable, content-addressed) |
| Branch | Epoch (paradigm context) |
| Merge | Lens resolution |
@ -209,6 +218,7 @@ A pipeline that converts high-confidence failure logs into synthetic training tr
## When to Use Episteme
**Use Episteme when:**
- Multiple sources report conflicting information
- You need to weight sources by authority, not just timestamp
- You need to surface disagreement, not hide it
@ -217,6 +227,7 @@ A pipeline that converts high-confidence failure logs into synthetic training tr
- You need historical snapshots ("what was true on this date?")
**Use Postgres when:**
- You have a single source of truth
- Data never conflicts
- Temporal validity doesn't matter