stemedb/applications/aphoria/src/claim_store.rs
jordan 422e2d4416 feat(aphoria): wire claims through StemeDB — Gap Closure Phase 1
Claims now flow through StemeDB's append-only knowledge graph instead of
mutable TOML files. This resolves all 6 critical claim-bypass code paths:

- Bridge: lossless AuthoredClaim ↔ Assertion round-trip (comparison, status, lifecycle mapping)
- LocalEpisteme: ingest_authored_claim() and fetch_authored_claims() with AUTHORED_CLAIM predicate index
- EpistemeClaimStore: ClaimStore trait backed by StemeDB (append-only delete via deprecation)
- CLI handlers: all claim commands read/write through StemeDB
- Scanner: loads claims from StemeDB with auto-migration fallback to TOML
- Export: new `aphoria claims export` serializes StemeDB claims to TOML/JSON

Also cleans up dead code (EpistemeConfig.url), renames ingest_claims→ingest_observations,
fixes ClaimFilter.authority_tier type, adds Draft variant to ClaimStatus, and fixes
pre-existing clippy warnings (too_many_arguments, filter_next→rfind).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 02:02:51 -07:00

246 lines
8.3 KiB
Rust

//! 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<String>,
/// Filter by predicate (exact match)
pub predicate: Option<String>,
/// Filter by authority tier (e.g., "expert", "regulatory").
pub authority_tier: Option<String>,
}
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<Option<AuthoredClaim>, 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<Vec<AuthoredClaim>, 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<bool, AphoriaError>;
/// Import multiple claims in bulk.
///
/// Duplicates (same concept_path + predicate) are skipped.
fn import_claims(&self, claims: &[AuthoredClaim]) -> Result<ImportStats, AphoriaError> {
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<Vec<AuthoredClaim>, 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<LocalEpisteme>,
handle: Handle,
}
impl EpistemeClaimStore {
/// Create a new StemeDB-backed claim store.
pub fn new(episteme: Arc<LocalEpisteme>, 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<Option<AuthoredClaim>, AphoriaError> {
self.handle.block_on(self.episteme.fetch_authored_claim(concept_path, predicate))
}
fn list_claims(&self, filter: &ClaimFilter) -> Result<Vec<AuthoredClaim>, AphoriaError> {
self.handle.block_on(self.episteme.fetch_authored_claims_filtered(filter))
}
fn delete_claim(&self, concept_path: &str, predicate: &str) -> Result<bool, AphoriaError> {
// 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,
}
}
}