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:
parent
bbeee18b68
commit
8af9b48ac7
@ -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"] }
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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?;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
163
applications/aphoria/src/cli/extractors.rs
Normal file
163
applications/aphoria/src/cli/extractors.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
135
applications/aphoria/src/cli/governance.rs
Normal file
135
applications/aphoria/src/cli/governance.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
135
applications/aphoria/src/cli/lifecycle.rs
Normal file
135
applications/aphoria/src/cli/lifecycle.rs
Normal 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>,
|
||||
},
|
||||
}
|
||||
310
applications/aphoria/src/cli/mod.rs
Normal file
310
applications/aphoria/src/cli/mod.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
148
applications/aphoria/src/cli/patterns.rs
Normal file
148
applications/aphoria/src/cli/patterns.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
62
applications/aphoria/src/cli/scope.rs
Normal file
62
applications/aphoria/src/cli/scope.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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.
|
||||
|
||||
155
applications/aphoria/src/config/types/governance.rs
Normal file
155
applications/aphoria/src/config/types/governance.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
23
applications/aphoria/src/config/types/trust_pack.rs
Normal file
23
applications/aphoria/src/config/types/trust_pack.rs
Normal 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>,
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
417
applications/aphoria/src/evidence/detection.rs
Normal file
417
applications/aphoria/src/evidence/detection.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
32
applications/aphoria/src/evidence/mod.rs
Normal file
32
applications/aphoria/src/evidence/mod.rs
Normal 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};
|
||||
508
applications/aphoria/src/evidence/types.rs
Normal file
508
applications/aphoria/src/evidence/types.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
487
applications/aphoria/src/governance/audit.rs
Normal file
487
applications/aphoria/src/governance/audit.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
81
applications/aphoria/src/governance/mod.rs
Normal file
81
applications/aphoria/src/governance/mod.rs
Normal 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,
|
||||
};
|
||||
667
applications/aphoria/src/governance/state_machine.rs
Normal file
667
applications/aphoria/src/governance/state_machine.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
398
applications/aphoria/src/governance/store.rs
Normal file
398
applications/aphoria/src/governance/store.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
392
applications/aphoria/src/governance/types.rs
Normal file
392
applications/aphoria/src/governance/types.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
387
applications/aphoria/src/governance/workflow.rs
Normal file
387
applications/aphoria/src/governance/workflow.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
696
applications/aphoria/src/handlers/governance.rs
Normal file
696
applications/aphoria/src/handlers/governance.rs
Normal 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)])
|
||||
}
|
||||
}
|
||||
694
applications/aphoria/src/handlers/lifecycle.rs
Normal file
694
applications/aphoria/src/handlers/lifecycle.rs
Normal 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])
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
393
applications/aphoria/src/handlers/scope.rs
Normal file
393
applications/aphoria/src/handlers/scope.rs
Normal 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(¤t_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(¤t_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());
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)]
|
||||
|
||||
536
applications/aphoria/src/lifecycle/migration.rs
Normal file
536
applications/aphoria/src/lifecycle/migration.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
52
applications/aphoria/src/lifecycle/mod.rs
Normal file
52
applications/aphoria/src/lifecycle/mod.rs
Normal 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")
|
||||
}
|
||||
}
|
||||
392
applications/aphoria/src/lifecycle/store.rs
Normal file
392
applications/aphoria/src/lifecycle/store.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
433
applications/aphoria/src/lifecycle/types.rs
Normal file
433
applications/aphoria/src/lifecycle/types.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
107
applications/aphoria/src/scope/config.rs
Normal file
107
applications/aphoria/src/scope/config.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
355
applications/aphoria/src/scope/mod.rs
Normal file
355
applications/aphoria/src/scope/mod.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
368
applications/aphoria/src/scope/override_record.rs
Normal file
368
applications/aphoria/src/scope/override_record.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
338
applications/aphoria/src/scope/resolver.rs
Normal file
338
applications/aphoria/src/scope/resolver.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
294
applications/aphoria/src/scope/store.rs
Normal file
294
applications/aphoria/src/scope/store.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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(®istry, &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(®istry, &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(®istry, &learned_temp, 5, 10, 0);
|
||||
|
||||
let manager = GraduationManager::new(®istry, &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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
582
applications/aphoria/src/tests/governance_tests.rs
Normal file
582
applications/aphoria/src/tests/governance_tests.rs
Normal 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());
|
||||
}
|
||||
381
applications/aphoria/src/tests/lifecycle_tests.rs
Normal file
381
applications/aphoria/src/tests/lifecycle_tests.rs
Normal 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"));
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -32,11 +34,11 @@ Aphoria is a **knowledge compounding system** that learns from your organization
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ TIER 1: POLICIES (Explicit, Authoritative) │
|
||||
│ ───────────────────────────────────────────────────────────── │
|
||||
│ 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) │
|
||||
│ • Internal Policy: TLS 1.3 minimum (signed: CISO, 2024-01) │
|
||||
│ → BLOCK on violation, clear remediation path │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
@ -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
|
||||
@ -140,13 +147,13 @@ 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 |
|
||||
| Time | 30+ days | Not a temporary fix |
|
||||
| No conflicts | No FPs in shadow mode | Actually works |
|
||||
| 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 |
|
||||
| Time | 30+ days | Not a temporary fix |
|
||||
| No conflicts | No FPs in shadow mode | Actually works |
|
||||
|
||||
Patterns meeting criteria enter **shadow mode** - running alongside production, collecting feedback, before promotion.
|
||||
|
||||
@ -183,7 +190,7 @@ Authority Ladder (lowest to highest):
|
||||
│ ───────────────────────────────────────────────────────────── │
|
||||
│ Pattern linked to explicit product requirement or task. │
|
||||
│ "This is what we decided to build." │
|
||||
│ Authority: 0.95 │
|
||||
│ Authority: 0.95 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
@ -191,7 +198,7 @@ Authority Ladder (lowest to highest):
|
||||
│ ───────────────────────────────────────────────────────────── │
|
||||
│ Pattern references authoritative external source. │
|
||||
│ "This is what the spec says." │
|
||||
│ Authority: 0.85 │
|
||||
│ Authority: 0.85 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
@ -199,7 +206,7 @@ Authority Ladder (lowest to highest):
|
||||
│ ───────────────────────────────────────────────────────────── │
|
||||
│ Pattern accompanied by investigation or reasoning. │
|
||||
│ "Here's why we chose this." │
|
||||
│ Authority: 0.70 │
|
||||
│ Authority: 0.70 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
@ -207,7 +214,7 @@ Authority Ladder (lowest to highest):
|
||||
│ ───────────────────────────────────────────────────────────── │
|
||||
│ Pattern observed in code, no supporting evidence. │
|
||||
│ "Someone did this." │
|
||||
│ Authority: 0.40 │
|
||||
│ Authority: 0.40 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@ -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,18 +276,20 @@ New code using deprecated patterns gets guidance toward the replacement.
|
||||
```
|
||||
|
||||
### Pre-commit Hook
|
||||
|
||||
```yaml
|
||||
# .pre-commit-config.yaml
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: aphoria
|
||||
name: Aphoria knowledge check
|
||||
entry: aphoria scan --exit-code
|
||||
language: system
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: aphoria
|
||||
name: Aphoria knowledge check
|
||||
entry: aphoria scan --exit-code
|
||||
language: system
|
||||
```
|
||||
|
||||
### CI/CD Pipeline
|
||||
|
||||
```yaml
|
||||
# GitHub Actions
|
||||
- name: Aphoria Scan
|
||||
@ -288,10 +298,11 @@ repos:
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
sarif_file: results.sarif
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
97
vision.md
97
vision.md
@ -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)
|
||||
@ -28,12 +29,12 @@ 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 |
|
||||
| **Semantic Decay** | Old data fades from hot path but remains auditable | Manual WHERE clauses, inconsistent decay rates |
|
||||
| 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 |
|
||||
| **Semantic Decay** | Old data fades from hot path but remains auditable | Manual WHERE clauses, inconsistent decay rates |
|
||||
|
||||
## The Core Data Model: The Signed Assertion
|
||||
|
||||
@ -68,14 +69,14 @@ 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 |
|
||||
| 3 | **Expert** | Physician guidelines | 6 months | 0.5 |
|
||||
| 4 | **Community** | Patient registries | 3 months | 0.2 |
|
||||
| 5 | **Anecdotal** | Reddit posts, social | 30 days | 0.1 |
|
||||
| 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 |
|
||||
| 3 | **Expert** | Physician guidelines | 6 months | 0.5 |
|
||||
| 4 | **Community** | Patient registries | 3 months | 0.2 |
|
||||
| 5 | **Anecdotal** | Reddit posts, social | 30 days | 0.1 |
|
||||
|
||||
A million Tier-5 anecdotal assertions cannot outvote a single Tier-0 regulatory assertion. But the million anecdotes can signal "something is happening here" via cluster escalation.
|
||||
|
||||
@ -85,27 +86,28 @@ 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 |
|
||||
| **Vote-Aware** | Weight by Ballot Box votes |
|
||||
| **EpochAware** | Filter out superseded paradigms |
|
||||
| Lens | Behavior |
|
||||
| -------------- | ---------------------------------------- |
|
||||
| **Recency** | Last writer wins |
|
||||
| **Consensus** | Highest cluster density of object values |
|
||||
| **Authority** | Filter by TrustRank reputation |
|
||||
| **Vote-Aware** | Weight by Ballot Box votes |
|
||||
| **EpochAware** | Filter out superseded paradigms |
|
||||
|
||||
### 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 |
|
||||
| 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 |
|
||||
|
||||
The **Skeptic** and **Layered Consensus** lenses are key differentiators: they answer "where do sources agree and disagree?" rather than hiding the variance.
|
||||
|
||||
## 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.
|
||||
|
||||
@ -172,14 +177,15 @@ 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 |
|
||||
| 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,28 +193,32 @@ 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 |
|
||||
| Revert | Epoch supersession cascade |
|
||||
| Blame | Signature/agent audit trail |
|
||||
| History | Append-only DAG preserved forever |
|
||||
| Git Concept | Episteme Equivalent |
|
||||
| ----------- | ---------------------------------------- |
|
||||
| Commit | Assertion (immutable, content-addressed) |
|
||||
| Branch | Epoch (paradigm context) |
|
||||
| Merge | Lens resolution |
|
||||
| Revert | Epoch supersession cascade |
|
||||
| Blame | Signature/agent audit trail |
|
||||
| History | Append-only DAG preserved forever |
|
||||
|
||||
## 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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user