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>
246 lines
8.3 KiB
Rust
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,
|
|
}
|
|
}
|
|
}
|