This commit implements Phase 17 of the Aphoria roadmap, adding: **Inline Claim Markers (@aphoria:claim):** - New extractor for detecting inline markers in comments - Pending markers tracked in .aphoria/pending_markers.toml - CLI commands: list-markers, formalize-marker, reject-marker - Support for all major comment styles (Rust, Python, SQL, etc.) - Auto-sync during scan (configurable) **Claim Enrichment:** - ClaimEnrichment type with source attribution (inline, extractor, manual) - EnrichedClaimInfo with full enrichment metadata - Extended AuthoredClaim with optional enrichment field - API endpoints for enriched claim queries - Dashboard UI components (enrichment badge, verdict badge) **Enhanced Extractor Trait:** - verifiable_predicates() method for declaring (tail_path, predicate) pairs - 10 security extractors now implement verifiable_predicates - Enables claim suggester skill to find unclaimed patterns **Documentation:** - Phase 17 summary with complete implementation details - Gap fixes summary documenting 8 closed vision gaps - Updated CLI reference with new commands - New aphoria-docs skill for documentation maintenance - Updated roadmap with Phase 17 completion **Integration:** - ClaimsFile support for claim enrichment persistence - Pattern aggregate store support for enrichment queries - Dashboard filters and display for enrichment metadata - API handlers for list-markers and enrichment queries **Tests:** - New gap_fixes_integration test suite - Corpus enricher module with best practices ingestion Closes: VG-005, VG-017, VG-018, VG-019, VG-020, VG-021, VG-022, VG-023 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
393 lines
12 KiB
Rust
393 lines
12 KiB
Rust
//! Claims file persistence (TOML).
|
|
//!
|
|
//! Stores human-authored claims in `.aphoria/claims.toml`, following
|
|
//! the same pattern as `ack_file.rs` for acknowledgments.
|
|
//!
|
|
//! ## File Format
|
|
//!
|
|
//! ```toml
|
|
//! # Aphoria Claims - version controlled
|
|
//! #
|
|
//! # Human-authored claims with provenance, invariants, and consequences.
|
|
//! # Manage with: aphoria claims create|list|explain|update|supersede|deprecate
|
|
//!
|
|
//! [[claim]]
|
|
//! id = "wallet-seqcst-001"
|
|
//! concept_path = "maxwell/wallet/atomics/ordering"
|
|
//! predicate = "required_ordering"
|
|
//! value = "SeqCst"
|
|
//! provenance = "Safety analysis by lead developer"
|
|
//! invariant = "All wallet atomics MUST use SeqCst"
|
|
//! consequence = "Double-spend race condition"
|
|
//! authority_tier = "expert"
|
|
//! evidence = ["wallet ADR-003", "Intel SDM Vol 4"]
|
|
//! category = "safety"
|
|
//! status = "active"
|
|
//! created_by = "jml"
|
|
//! created_at = "2026-02-08T12:00:00Z"
|
|
//! ```
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::types::authored_claim::{AuthoredClaim, ClaimStatus};
|
|
use crate::AphoriaError;
|
|
|
|
/// Default path for the claims file relative to project root.
|
|
pub const CLAIMS_FILE_PATH: &str = ".aphoria/claims.toml";
|
|
|
|
/// Container for all authored claims in the file.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct ClaimsFile {
|
|
/// List of authored claims.
|
|
#[serde(default, rename = "claim")]
|
|
pub claims: Vec<AuthoredClaim>,
|
|
}
|
|
|
|
impl ClaimsFile {
|
|
/// Create an empty claims file.
|
|
pub fn new() -> Self {
|
|
Self { claims: Vec::new() }
|
|
}
|
|
|
|
/// Add a claim entry, deduplicating by ID.
|
|
///
|
|
/// Warns if an active claim already exists for the same concept_path/predicate.
|
|
pub fn add(&mut self, claim: AuthoredClaim) {
|
|
// Check for duplicate ID
|
|
if self.claims.iter().any(|c| c.id == claim.id) {
|
|
return; // Skip duplicate ID
|
|
}
|
|
|
|
// Check for duplicate active claims (same concept_path + predicate)
|
|
if claim.status == ClaimStatus::Active {
|
|
let duplicates: Vec<_> = self.claims.iter()
|
|
.filter(|c| c.status == ClaimStatus::Active)
|
|
.filter(|c| c.concept_path == claim.concept_path)
|
|
.filter(|c| c.predicate == claim.predicate)
|
|
.filter(|c| c.id != claim.id)
|
|
.collect();
|
|
|
|
if !duplicates.is_empty() {
|
|
#[allow(clippy::print_stderr)]
|
|
{
|
|
eprintln!("⚠️ Warning: Active claim(s) already exist for {}::{}", claim.concept_path, claim.predicate);
|
|
for dup in &duplicates {
|
|
eprintln!(" - {} ({})", dup.id, dup.invariant);
|
|
}
|
|
eprintln!("Consider using 'aphoria claims supersede {}' instead", duplicates[0].id);
|
|
}
|
|
}
|
|
}
|
|
|
|
self.claims.push(claim);
|
|
}
|
|
|
|
/// Load from a TOML file.
|
|
pub fn load(path: &Path) -> Result<Self, AphoriaError> {
|
|
if !path.exists() {
|
|
return Ok(Self::new());
|
|
}
|
|
|
|
let content = std::fs::read_to_string(path)
|
|
.map_err(|e| AphoriaError::Io(std::io::Error::new(e.kind(), format!("{e}"))))?;
|
|
|
|
toml::from_str(&content)
|
|
.map_err(|e| AphoriaError::Claims(format!("Failed to parse claims file: {e}")))
|
|
}
|
|
|
|
/// Save to a TOML file.
|
|
pub fn save(&self, path: &Path) -> Result<(), AphoriaError> {
|
|
if let Some(parent) = path.parent() {
|
|
if !parent.exists() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
}
|
|
|
|
let header = r#"# Aphoria Claims - version controlled
|
|
#
|
|
# Human-authored claims with provenance, invariants, and consequences.
|
|
# Each claim represents a deliberate architectural decision or safety invariant.
|
|
#
|
|
# Manage with: aphoria claims create|list|explain|update|supersede|deprecate
|
|
|
|
"#;
|
|
|
|
let content =
|
|
toml::to_string_pretty(self).map_err(|e| AphoriaError::Claims(e.to_string()))?;
|
|
|
|
std::fs::write(path, format!("{header}{content}"))
|
|
.map_err(|e| AphoriaError::Io(std::io::Error::new(e.kind(), format!("{e}"))))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get the default path for the claims file.
|
|
pub fn default_path(project_root: &Path) -> PathBuf {
|
|
project_root.join(CLAIMS_FILE_PATH)
|
|
}
|
|
|
|
/// Check if a claims file exists at the default location.
|
|
pub fn exists(project_root: &Path) -> bool {
|
|
Self::default_path(project_root).exists()
|
|
}
|
|
|
|
/// Get the number of claims.
|
|
pub fn len(&self) -> usize {
|
|
self.claims.len()
|
|
}
|
|
|
|
/// Check if empty.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.claims.is_empty()
|
|
}
|
|
|
|
/// Find a claim by ID.
|
|
pub fn find_by_id(&self, id: &str) -> Option<&AuthoredClaim> {
|
|
self.claims.iter().find(|c| c.id == id)
|
|
}
|
|
|
|
/// Find a claim by ID (mutable).
|
|
pub fn find_by_id_mut(&mut self, id: &str) -> Option<&mut AuthoredClaim> {
|
|
self.claims.iter_mut().find(|c| c.id == id)
|
|
}
|
|
|
|
/// Find claims by category.
|
|
pub fn find_by_category(&self, category: &str) -> Vec<&AuthoredClaim> {
|
|
self.claims.iter().filter(|c| c.category == category).collect()
|
|
}
|
|
|
|
/// Find claims by status.
|
|
pub fn find_by_status(&self, status: &ClaimStatus) -> Vec<&AuthoredClaim> {
|
|
self.claims.iter().filter(|c| &c.status == status).collect()
|
|
}
|
|
|
|
/// Update a claim's fields. Returns error if claim not found.
|
|
pub fn update<F>(&mut self, id: &str, updater: F) -> Result<(), AphoriaError>
|
|
where
|
|
F: FnOnce(&mut AuthoredClaim),
|
|
{
|
|
let claim = self
|
|
.find_by_id_mut(id)
|
|
.ok_or_else(|| AphoriaError::Claims(format!("Claim not found: {id}")))?;
|
|
updater(claim);
|
|
Ok(())
|
|
}
|
|
|
|
/// Mark a claim as superseded and add the superseding claim.
|
|
pub fn supersede(
|
|
&mut self,
|
|
old_id: &str,
|
|
new_claim: AuthoredClaim,
|
|
) -> Result<(), AphoriaError> {
|
|
// Mark old claim as superseded
|
|
let now = new_claim.created_at.clone();
|
|
self.update(old_id, |c| {
|
|
c.status = ClaimStatus::Superseded;
|
|
c.updated_at = Some(now);
|
|
})?;
|
|
|
|
// Add new claim
|
|
self.add(new_claim);
|
|
Ok(())
|
|
}
|
|
|
|
/// Mark a claim as deprecated.
|
|
pub fn deprecate(&mut self, id: &str, timestamp: &str) -> Result<(), AphoriaError> {
|
|
self.update(id, |c| {
|
|
c.status = ClaimStatus::Deprecated;
|
|
c.updated_at = Some(timestamp.to_string());
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::types::authored_claim::AuthoredValue;
|
|
use tempfile::TempDir;
|
|
|
|
fn sample_claim(id: &str) -> AuthoredClaim {
|
|
AuthoredClaim {
|
|
id: id.to_string(),
|
|
concept_path: "test/concept".to_string(),
|
|
predicate: "test_pred".to_string(),
|
|
value: AuthoredValue::Text("test_value".to_string()),
|
|
comparison: Default::default(),
|
|
provenance: "Test provenance".to_string(),
|
|
invariant: "Test invariant".to_string(),
|
|
consequence: "Test consequence".to_string(),
|
|
authority_tier: "expert".to_string(),
|
|
evidence: vec!["evidence-1".to_string()],
|
|
category: "safety".to_string(),
|
|
status: ClaimStatus::Active,
|
|
supersedes: None,
|
|
created_by: "tester".to_string(),
|
|
created_at: "2026-02-08T12:00:00Z".to_string(),
|
|
updated_at: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_claims_file_roundtrip() {
|
|
let temp_dir = TempDir::new().expect("create temp dir");
|
|
let path = temp_dir.path().join(".aphoria/claims.toml");
|
|
|
|
let mut file = ClaimsFile::new();
|
|
file.add(sample_claim("claim-001"));
|
|
file.add(sample_claim("claim-002"));
|
|
|
|
file.save(&path).expect("save claims file");
|
|
|
|
let loaded = ClaimsFile::load(&path).expect("load claims file");
|
|
assert_eq!(loaded.len(), 2);
|
|
assert_eq!(loaded.claims[0].id, "claim-001");
|
|
assert_eq!(loaded.claims[1].id, "claim-002");
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_duplicates() {
|
|
let mut file = ClaimsFile::new();
|
|
file.add(sample_claim("claim-001"));
|
|
file.add(sample_claim("claim-001"));
|
|
assert_eq!(file.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_by_id() {
|
|
let mut file = ClaimsFile::new();
|
|
file.add(sample_claim("claim-001"));
|
|
file.add(sample_claim("claim-002"));
|
|
|
|
assert!(file.find_by_id("claim-001").is_some());
|
|
assert!(file.find_by_id("nonexistent").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_by_category() {
|
|
let mut file = ClaimsFile::new();
|
|
let mut arch_claim = sample_claim("arch-001");
|
|
arch_claim.category = "architecture".to_string();
|
|
|
|
file.add(sample_claim("safety-001"));
|
|
file.add(arch_claim);
|
|
|
|
assert_eq!(file.find_by_category("safety").len(), 1);
|
|
assert_eq!(file.find_by_category("architecture").len(), 1);
|
|
assert_eq!(file.find_by_category("imports").len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_by_status() {
|
|
let mut file = ClaimsFile::new();
|
|
let mut dep_claim = sample_claim("dep-001");
|
|
dep_claim.status = ClaimStatus::Deprecated;
|
|
|
|
file.add(sample_claim("active-001"));
|
|
file.add(dep_claim);
|
|
|
|
assert_eq!(file.find_by_status(&ClaimStatus::Active).len(), 1);
|
|
assert_eq!(file.find_by_status(&ClaimStatus::Deprecated).len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_update() {
|
|
let mut file = ClaimsFile::new();
|
|
file.add(sample_claim("claim-001"));
|
|
|
|
file.update("claim-001", |c| {
|
|
c.provenance = "Updated provenance".to_string();
|
|
c.updated_at = Some("2026-02-08T13:00:00Z".to_string());
|
|
})
|
|
.expect("update claim");
|
|
|
|
assert_eq!(
|
|
file.find_by_id("claim-001").map(|c| c.provenance.as_str()),
|
|
Some("Updated provenance")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_not_found() {
|
|
let mut file = ClaimsFile::new();
|
|
assert!(file.update("nonexistent", |_| {}).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_supersede() {
|
|
let mut file = ClaimsFile::new();
|
|
file.add(sample_claim("claim-001"));
|
|
|
|
let mut new_claim = sample_claim("claim-002");
|
|
new_claim.supersedes = Some("claim-001".to_string());
|
|
new_claim.provenance = "Updated analysis".to_string();
|
|
|
|
file.supersede("claim-001", new_claim).expect("supersede");
|
|
|
|
assert_eq!(file.find_by_id("claim-001").map(|c| &c.status), Some(&ClaimStatus::Superseded));
|
|
assert_eq!(
|
|
file.find_by_id("claim-002").map(|c| c.supersedes.as_deref()),
|
|
Some(Some("claim-001"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_deprecate() {
|
|
let mut file = ClaimsFile::new();
|
|
file.add(sample_claim("claim-001"));
|
|
|
|
file.deprecate("claim-001", "2026-02-08T14:00:00Z").expect("deprecate");
|
|
|
|
let claim = file.find_by_id("claim-001").expect("find");
|
|
assert_eq!(claim.status, ClaimStatus::Deprecated);
|
|
assert_eq!(claim.updated_at.as_deref(), Some("2026-02-08T14:00:00Z"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_nonexistent() {
|
|
let temp_dir = TempDir::new().expect("create temp dir");
|
|
let path = temp_dir.path().join("nonexistent.toml");
|
|
|
|
let file = ClaimsFile::load(&path).expect("load should succeed");
|
|
assert!(file.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_duplicate_active_warning() {
|
|
let mut file = ClaimsFile::new();
|
|
|
|
// Add first claim
|
|
file.add(sample_claim("claim-001"));
|
|
assert_eq!(file.len(), 1);
|
|
|
|
// Add duplicate with same concept_path/predicate
|
|
// This should print a warning but still add the claim
|
|
let mut dup_claim = sample_claim("claim-002");
|
|
dup_claim.concept_path = "test/concept".to_string();
|
|
dup_claim.predicate = "test_pred".to_string();
|
|
|
|
file.add(dup_claim);
|
|
assert_eq!(file.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_warning_for_deprecated_duplicates() {
|
|
let mut file = ClaimsFile::new();
|
|
|
|
// Add first claim and deprecate it
|
|
file.add(sample_claim("claim-001"));
|
|
file.deprecate("claim-001", "2026-02-08T14:00:00Z").expect("deprecate");
|
|
|
|
// Add another claim with same concept_path/predicate
|
|
// Should NOT warn because the first is deprecated
|
|
let mut new_claim = sample_claim("claim-002");
|
|
new_claim.concept_path = "test/concept".to_string();
|
|
new_claim.predicate = "test_pred".to_string();
|
|
|
|
file.add(new_claim);
|
|
assert_eq!(file.len(), 2);
|
|
assert_eq!(file.find_by_status(&ClaimStatus::Active).len(), 1);
|
|
}
|
|
}
|