//! Claim storage interface and implementations. //! //! Provides persistence for human-authored claims via StemeDB assertions. //! Claims are stored as content-addressed assertions in the append-only log, //! indexed under the `AUTHORED_CLAIM` predicate for efficient queries. use std::sync::Arc; use tokio::runtime::Handle; use crate::episteme::LocalEpisteme; use crate::types::{AuthoredClaim, ClaimStatus}; use crate::AphoriaError; /// Filter criteria for querying claims. #[derive(Debug, Clone, Default)] pub struct ClaimFilter { /// Filter by concept path (exact match) pub concept_path: Option, /// Filter by predicate (exact match) pub predicate: Option, /// Filter by authority tier (e.g., "expert", "regulatory"). pub authority_tier: Option, } impl ClaimFilter { /// Check if a claim matches this filter. pub fn matches(&self, claim: &AuthoredClaim) -> bool { if let Some(ref cp) = self.concept_path { if claim.concept_path != *cp { return false; } } if let Some(ref p) = self.predicate { if claim.predicate != *p { return false; } } if let Some(ref at) = self.authority_tier { if claim.authority_tier != *at { return false; } } true } } /// Statistics from bulk import operations. #[derive(Debug, Default)] pub struct ImportStats { /// Number of claims successfully imported pub imported: usize, /// Number of claims skipped (duplicates) pub skipped: usize, /// Number of claims that failed to import pub errors: usize, } /// Trait for claim storage backends. /// /// Implementations provide persistence for `AuthoredClaim` instances. /// The primary implementation is `EpistemeClaimStore` which stores claims /// as StemeDB assertions in the append-only knowledge graph. pub trait ClaimStore: Send + Sync { /// Save a new claim or update an existing one. /// /// In append-only mode, "updating" means appending a new assertion /// with the same concept_path + predicate. The most recent assertion /// wins at query time. fn save_claim(&self, claim: &AuthoredClaim) -> Result<(), AphoriaError>; /// Load a specific claim by concept path and predicate. fn load_claim( &self, concept_path: &str, predicate: &str, ) -> Result, AphoriaError>; /// List all claims matching the filter criteria. /// /// If filter is empty (all fields None), returns all claims. fn list_claims(&self, filter: &ClaimFilter) -> Result, AphoriaError>; /// Delete a claim by concept path and predicate. /// /// In append-only mode, this creates a "deprecated" assertion. /// Returns `true` if a claim was deprecated, `false` if not found. fn delete_claim(&self, concept_path: &str, predicate: &str) -> Result; /// Import multiple claims in bulk. /// /// Duplicates (same concept_path + predicate) are skipped. fn import_claims(&self, claims: &[AuthoredClaim]) -> Result { let mut stats = ImportStats::default(); for claim in claims { match self.save_claim(claim) { Ok(()) => stats.imported += 1, Err(_) => stats.errors += 1, } } Ok(stats) } /// Export claims matching filter criteria. fn export_claims(&self, filter: &ClaimFilter) -> Result, AphoriaError> { self.list_claims(filter) } } /// StemeDB-backed claim storage. /// /// Claims are stored as assertions in the local StemeDB instance. /// Uses the `AUTHORED_CLAIM` predicate index for efficient queries. /// /// This is the primary implementation of `ClaimStore`, replacing direct /// TOML file access. Claims are append-only: updates create new assertions, /// deletes create retired assertions. pub struct EpistemeClaimStore { episteme: Arc, handle: Handle, } impl EpistemeClaimStore { /// Create a new StemeDB-backed claim store. pub fn new(episteme: Arc, handle: Handle) -> Self { Self { episteme, handle } } } impl ClaimStore for EpistemeClaimStore { fn save_claim(&self, claim: &AuthoredClaim) -> Result<(), AphoriaError> { self.handle.block_on(self.episteme.ingest_authored_claim(claim))?; Ok(()) } fn load_claim( &self, concept_path: &str, predicate: &str, ) -> Result, AphoriaError> { self.handle.block_on(self.episteme.fetch_authored_claim(concept_path, predicate)) } fn list_claims(&self, filter: &ClaimFilter) -> Result, AphoriaError> { self.handle.block_on(self.episteme.fetch_authored_claims_filtered(filter)) } fn delete_claim(&self, concept_path: &str, predicate: &str) -> Result { // Append-only: create a "deprecated" assertion instead of deleting if let Some(mut claim) = self.load_claim(concept_path, predicate)? { claim.status = ClaimStatus::Deprecated; self.save_claim(&claim)?; Ok(true) } else { Ok(false) } } } #[cfg(test)] mod tests { use super::*; use crate::types::{AuthoredClaim, AuthoredValue}; #[test] fn test_claim_filter_empty_matches_all() { let filter = ClaimFilter::default(); let claim = make_test_claim("test/path", "enabled", "expert"); assert!(filter.matches(&claim)); } #[test] fn test_claim_filter_concept_path() { let filter = ClaimFilter { concept_path: Some("test/path".to_string()), ..Default::default() }; let matching = make_test_claim("test/path", "enabled", "expert"); let non_matching = make_test_claim("other/path", "enabled", "expert"); assert!(filter.matches(&matching)); assert!(!filter.matches(&non_matching)); } #[test] fn test_claim_filter_predicate() { let filter = ClaimFilter { predicate: Some("enabled".to_string()), ..Default::default() }; let matching = make_test_claim("test/path", "enabled", "expert"); let non_matching = make_test_claim("test/path", "disabled", "expert"); assert!(filter.matches(&matching)); assert!(!filter.matches(&non_matching)); } #[test] fn test_claim_filter_authority_tier() { let filter = ClaimFilter { authority_tier: Some("regulatory".to_string()), ..Default::default() }; let matching = make_test_claim("test/path", "enabled", "regulatory"); let non_matching = make_test_claim("test/path", "enabled", "expert"); assert!(filter.matches(&matching)); assert!(!filter.matches(&non_matching)); } #[test] fn test_claim_filter_combined() { let filter = ClaimFilter { concept_path: Some("test/path".to_string()), predicate: Some("enabled".to_string()), authority_tier: Some("expert".to_string()), }; let matching = make_test_claim("test/path", "enabled", "expert"); let wrong_path = make_test_claim("other/path", "enabled", "expert"); let wrong_pred = make_test_claim("test/path", "disabled", "expert"); let wrong_tier = make_test_claim("test/path", "enabled", "community"); assert!(filter.matches(&matching)); assert!(!filter.matches(&wrong_path)); assert!(!filter.matches(&wrong_pred)); assert!(!filter.matches(&wrong_tier)); } fn make_test_claim(concept_path: &str, predicate: &str, tier: &str) -> AuthoredClaim { AuthoredClaim { id: "test-001".to_string(), concept_path: concept_path.to_string(), predicate: predicate.to_string(), value: AuthoredValue::Bool(true), comparison: Default::default(), provenance: "test".to_string(), invariant: "test".to_string(), consequence: "test".to_string(), authority_tier: tier.to_string(), evidence: vec![], category: "test".to_string(), status: ClaimStatus::Active, supersedes: None, created_by: "test".to_string(), created_at: "2026-02-12T00:00:00Z".to_string(), updated_at: None, } } }