stemedb/applications/aphoria/src/claims_file.rs
jml e95c978481 feat(aphoria): add inline claim markers and claim enrichment infrastructure
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>
2026-02-08 20:18:20 +00:00

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);
}
}