diff --git a/.claude/guides/services/aphoria-hosted-mode.md b/.claude/guides/services/aphoria-hosted-mode.md index c73bfb4..198b457 100644 --- a/.claude/guides/services/aphoria-hosted-mode.md +++ b/.claude/guides/services/aphoria-hosted-mode.md @@ -2,6 +2,14 @@ **When to use:** Setting up Aphoria for team-wide observation aggregation via a central StemeDB server. +> **What syncs today vs what doesn't:** +> - **Observations** -- synced to hosted StemeDB via `push_observations()`. Working. +> - **Patterns** -- synced to hosted StemeDB via `push_patterns()`. Working. +> - **Claims** -- stored locally in `claims.toml` only. No `push_claims()` exists. Claims never leave the local machine. +> - **Extractors** -- stored locally in `.aphoria/extractors/*.toml` only. No `push_extractors()` exists. +> +> Claim and extractor sync are tracked in the gap closure roadmap (Phases 1-3). + ## Prerequisites - Aphoria installed (`cargo install --path applications/aphoria`) diff --git a/.claude/skills/extract-claims/SKILL.md b/.claude/skills/extract-claims/SKILL.md index 3558b2a..bc918db 100644 --- a/.claude/skills/extract-claims/SKILL.md +++ b/.claude/skills/extract-claims/SKILL.md @@ -1,6 +1,6 @@ --- name: extract-claims -description: Extract entity-level claims from prose text for StemeDB ingestion. Use when parsing documents, articles, or text into structured assertions. +description: Extract entity-level claims from prose text as structured JSON. Use when parsing documents, articles, or text into structured assertions. NOTE -- outputs JSON matching the schema below, but no automated ingestion pathway into StemeDB exists. The bridge from this JSON output to StemeDB assertions (via `authored_claim_to_assertion()` or similar) is not wired up. --- # Entity-Level Claim Extraction diff --git a/CLAUDE.md b/CLAUDE.md index a30e0e3..fa72e85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,8 @@ Developer commits code **Knowledge Compounding:** Each commit benefits from all previous commits' learning - not through ML training, but through accumulated structured decisions. +> **What syncs today:** In hosted mode, observations and patterns sync to a central StemeDB instance. Claims and extractors do NOT sync -- they stay in local TOML files (`.aphoria/claims.toml`, `.aphoria/extractors/*.toml`). Multi-agent claim convergence is planned but not implemented. See `tmp/aphoria-stemedb-gap-closure.md`. + ### LLM Workflows ARE the Core Product **CRITICAL:** Aphoria's autonomous operation REQUIRES LLM-driven automation: @@ -210,6 +212,8 @@ ls DAY3-SUMMARY.md # Must exist (daily summary) A **claim** is a human-authored statement about what code MUST do and WHY, with provenance and consequences. +> **Storage today:** Claims live in `.aphoria/claims.toml` (a flat mutable file), NOT in StemeDB. Observations flow through StemeDB (WAL, append-only, content-addressed). Claims do not. Closing this gap is tracked in `tmp/aphoria-stemedb-gap-closure.md`. + ### Claims vs Observations | Type | What it is | Who creates it | Example | diff --git a/ai-lookup/features/aphoria-flywheel.md b/ai-lookup/features/aphoria-flywheel.md index efadc54..4687ae6 100644 --- a/ai-lookup/features/aphoria-flywheel.md +++ b/ai-lookup/features/aphoria-flywheel.md @@ -26,7 +26,7 @@ 4. **Create extractors** → Dynamically generate extractors for uncovered existing claims 5. **Suggest claims** → LLM identifies new patterns not yet in corpus 6. **Create more extractors** → Generate extractors for new claims -7. **Aggregate patterns** → High-adoption patterns auto-promote to community corpus +7. **Aggregate patterns** → High-adoption patterns auto-promote to community corpus (**LOCAL ONLY** -- no community aggregation server exists; promotion operates on the local machine only) 8. **Better corpus** → Next scan catches more violations 9. **Loop** diff --git a/applications/aphoria/docs/advanced/eap-protocol.md b/applications/aphoria/docs/advanced/eap-protocol.md index f85f54b..33d4fee 100644 --- a/applications/aphoria/docs/advanced/eap-protocol.md +++ b/applications/aphoria/docs/advanced/eap-protocol.md @@ -1,5 +1,7 @@ # The Open Vision: The Epistemic Assertion Protocol (EAP) +> **STATUS: ASPIRATIONAL / PLANNED** -- Nothing described in this document is implemented. There is no EAP protocol, no manifest format, no "DNS for Truth" server, and no global ingestion network. This is a long-term vision document. For what Aphoria does today, see the [CLI Reference](../cli-reference.md) and [Aphoria README](../../README.md). + > **Protocol Vision:** This document describes the Epistemic Assertion Protocol (EAP) - an open standard for publishing authoritative technical knowledge. For Aphoria's product vision, see [Vision](vision.md). **From "Reading the Manual" to "Querying the Truth."** diff --git a/applications/aphoria/examples/scale_adaptive_demo.rs b/applications/aphoria/examples/scale_adaptive_demo.rs index 5e9d585..fd3e28e 100644 --- a/applications/aphoria/examples/scale_adaptive_demo.rs +++ b/applications/aphoria/examples/scale_adaptive_demo.rs @@ -58,13 +58,9 @@ fn main() { println!("| Tier | Projects | Emerging Floor | Regulatory Floor |"); println!("|------------|----------|----------------|------------------|"); - for (name, total) in [ - ("Micro", 3), - ("Small", 10), - ("Medium", 50), - ("Large", 200), - ("Enterprise", 1000), - ] { + for (name, total) in + [("Micro", 3), ("Small", 10), ("Medium", 50), ("Large", 200), ("Enterprise", 1000)] + { let tier = ScaleTier::from_total_projects(total); let tier_thresholds = thresholds.for_tier(tier); @@ -76,10 +72,7 @@ fn main() { "N/A".to_string() }; - println!( - "| {:10} | {:8} | {:14} | {:16} |", - name, total, emerging_min, regulatory_min - ); + println!("| {:10} | {:8} | {:14} | {:16} |", name, total, emerging_min, regulatory_min); } println!("\n✅ Small teams see value immediately!"); diff --git a/applications/aphoria/src/bridge.rs b/applications/aphoria/src/bridge.rs index f81ff23..0ae3d6e 100644 --- a/applications/aphoria/src/bridge.rs +++ b/applications/aphoria/src/bridge.rs @@ -24,7 +24,8 @@ use stemedb_core::types::{ }; use tracing::instrument; -use crate::types::{parse_authority_tier, AuthoredClaim, Observation}; +use crate::types::authored_claim::ComparisonMode; +use crate::types::{parse_authority_tier, AuthoredClaim, AuthoredValue, ClaimStatus, Observation}; /// Convert an Observation to an Episteme Assertion. /// @@ -178,6 +179,8 @@ pub fn authored_claim_to_assertion( "consequence": claim.consequence, "evidence": claim.evidence, "category": claim.category, + "comparison": claim.comparison.to_string(), + "status": claim.status.to_string(), "created_by": claim.created_by, "created_at": claim.created_at, "tool": "aphoria", @@ -189,6 +192,16 @@ pub fn authored_claim_to_assertion( metadata["git_commit"] = serde_json::json!(hash); } + // Add supersedes if present + if let Some(ref supersedes) = claim.supersedes { + metadata["supersedes"] = serde_json::json!(supersedes); + } + + // Add updated_at if present + if let Some(ref updated_at) = claim.updated_at { + metadata["updated_at"] = serde_json::json!(updated_at); + } + let source_metadata = metadata; // Source hash from claim ID (stable, deterministic) @@ -197,6 +210,9 @@ pub fn authored_claim_to_assertion( // Compute parent hash from superseded claim ID if present let parent_hash = claim.supersedes.as_ref().map(|sid| compute_authored_claim_hash(sid)); + // Map ClaimStatus → LifecycleStage + let lifecycle = claim_status_to_lifecycle(&claim.status); + // Sign subject:predicate let message = format!("{}:{}", claim.concept_path, claim.predicate); let signature = signing_key.sign(message.as_bytes()); @@ -219,7 +235,7 @@ pub fn authored_claim_to_assertion( visual_hash: None, epoch: None, source_metadata: serde_json::to_vec(&source_metadata).ok(), - lifecycle: LifecycleStage::Approved, + lifecycle, signatures: vec![signature_entry], confidence: 1.0, // Authored claims have full confidence timestamp, @@ -228,6 +244,149 @@ pub fn authored_claim_to_assertion( }) } +/// Map `ClaimStatus` to `LifecycleStage`. +fn claim_status_to_lifecycle(status: &ClaimStatus) -> LifecycleStage { + match status { + ClaimStatus::Draft => LifecycleStage::Proposed, + ClaimStatus::Active => LifecycleStage::Approved, + ClaimStatus::Deprecated => LifecycleStage::Deprecated, + ClaimStatus::Superseded => LifecycleStage::Deprecated, + } +} + +/// Map `LifecycleStage` back to `ClaimStatus`. +/// +/// Uses `has_parent` to distinguish `Superseded` from `Deprecated` +/// (both map to `LifecycleStage::Deprecated`). +fn lifecycle_to_claim_status(lifecycle: LifecycleStage, has_parent: bool) -> ClaimStatus { + match lifecycle { + LifecycleStage::Proposed => ClaimStatus::Draft, + LifecycleStage::UnderReview => ClaimStatus::Draft, + LifecycleStage::Approved => ClaimStatus::Active, + LifecycleStage::Deprecated => { + if has_parent { + ClaimStatus::Superseded + } else { + ClaimStatus::Deprecated + } + } + LifecycleStage::Rejected => ClaimStatus::Deprecated, + } +} + +/// Map a `SourceClass` back to the authority tier string. +fn source_class_to_tier_string(source_class: SourceClass) -> String { + match source_class { + SourceClass::Regulatory => "regulatory".to_string(), + SourceClass::Clinical => "clinical".to_string(), + SourceClass::Observational => "observational".to_string(), + SourceClass::TeamPolicy => "team_policy".to_string(), + SourceClass::Expert => "expert".to_string(), + SourceClass::Community => "community".to_string(), + SourceClass::Anecdotal => "anecdotal".to_string(), + } +} + +/// Convert an Episteme Assertion back to an `AuthoredClaim`. +/// +/// Recovers all fields from `source_metadata` JSON. Returns an error if the +/// assertion was not created from an authored claim (missing `authored: true` +/// in metadata). +pub fn assertion_to_authored_claim( + assertion: &Assertion, +) -> Result { + let metadata_bytes = assertion.source_metadata.as_ref().ok_or_else(|| { + crate::AphoriaError::Claims("Assertion has no source_metadata".to_string()) + })?; + + let metadata: serde_json::Value = serde_json::from_slice(metadata_bytes).map_err(|e| { + crate::AphoriaError::Claims(format!("Failed to parse source_metadata JSON: {e}")) + })?; + + // Verify this is an authored claim assertion + if metadata.get("authored") != Some(&serde_json::Value::Bool(true)) { + return Err(crate::AphoriaError::Claims( + "Assertion is not from an authored claim (missing authored: true)".to_string(), + )); + } + + let claim_id = metadata["claim_id"].as_str().unwrap_or("").to_string(); + + let provenance = metadata["provenance"].as_str().unwrap_or("").to_string(); + + let invariant = metadata["invariant"].as_str().unwrap_or("").to_string(); + + let consequence = metadata["consequence"].as_str().unwrap_or("").to_string(); + + let evidence: Vec = metadata["evidence"] + .as_array() + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + + let category = metadata["category"].as_str().unwrap_or("").to_string(); + + let comparison = metadata + .get("comparison") + .and_then(|v| v.as_str()) + .map(|s| match s { + "equals" => ComparisonMode::Equals, + "not_equals" => ComparisonMode::NotEquals, + "present" => ComparisonMode::Present, + "absent" => ComparisonMode::Absent, + "contains" => ComparisonMode::Contains, + "not_contains" => ComparisonMode::NotContains, + _ => ComparisonMode::default(), + }) + .unwrap_or_default(); + + // Recover status from metadata, falling back to lifecycle mapping + let status = metadata + .get("status") + .and_then(|v| v.as_str()) + .and_then(|s| ClaimStatus::parse(s).ok()) + .unwrap_or_else(|| { + lifecycle_to_claim_status(assertion.lifecycle, assertion.parent_hash.is_some()) + }); + + let created_by = metadata["created_by"].as_str().unwrap_or("").to_string(); + + let created_at = metadata["created_at"].as_str().unwrap_or("").to_string(); + + let updated_at = metadata.get("updated_at").and_then(|v| v.as_str()).map(|s| s.to_string()); + + let supersedes = metadata.get("supersedes").and_then(|v| v.as_str()).map(|s| s.to_string()); + + // Convert ObjectValue back to AuthoredValue + let value = match &assertion.object { + stemedb_core::types::ObjectValue::Boolean(b) => AuthoredValue::Bool(*b), + stemedb_core::types::ObjectValue::Number(n) => AuthoredValue::Number(*n), + stemedb_core::types::ObjectValue::Text(s) => AuthoredValue::Text(s.clone()), + stemedb_core::types::ObjectValue::Reference(r) => AuthoredValue::Text(r.clone()), + }; + + // Map source_class back to authority tier string + let authority_tier = source_class_to_tier_string(assertion.source_class); + + Ok(AuthoredClaim { + id: claim_id, + concept_path: assertion.subject.clone(), + predicate: assertion.predicate.clone(), + value, + comparison, + provenance, + invariant, + consequence, + authority_tier, + evidence, + category, + status, + supersedes, + created_by, + created_at, + updated_at, + }) +} + /// Compute a deterministic hash from an authored claim ID. fn compute_authored_claim_hash(claim_id: &str) -> Hash { let mut hasher = Hasher::new(); @@ -494,7 +653,7 @@ mod tests { concept_path: "maxwell/wallet/atomics/ordering".to_string(), predicate: "required_ordering".to_string(), value: AuthoredValue::Text("SeqCst".to_string()), - comparison: Default::default(), + comparison: ComparisonMode::Equals, provenance: "Safety analysis".to_string(), invariant: "All wallet atomics MUST use SeqCst".to_string(), consequence: "Double-spend race condition".to_string(), @@ -520,7 +679,7 @@ mod tests { assert!(assertion.parent_hash.is_none()); assert_eq!(assertion.lifecycle, LifecycleStage::Approved); - // Verify metadata includes provenance fields + // Verify metadata includes provenance fields + comparison + status let metadata: serde_json::Value = serde_json::from_slice(assertion.source_metadata.as_ref().expect("metadata")) .expect("parse"); @@ -528,6 +687,8 @@ mod tests { assert_eq!(metadata["claim_id"], "wallet-seqcst-001"); assert_eq!(metadata["provenance"], "Safety analysis"); assert_eq!(metadata["invariant"], "All wallet atomics MUST use SeqCst"); + assert_eq!(metadata["comparison"], "equals"); + assert_eq!(metadata["status"], "active"); } #[test] @@ -676,4 +837,234 @@ mod tests { let key = generate_signing_key(); assert!(authored_claim_to_assertion(&claim, &key, 1706832000, None).is_err()); } + + #[test] + fn test_claim_status_to_lifecycle() { + assert_eq!(claim_status_to_lifecycle(&ClaimStatus::Draft), LifecycleStage::Proposed); + assert_eq!(claim_status_to_lifecycle(&ClaimStatus::Active), LifecycleStage::Approved); + assert_eq!(claim_status_to_lifecycle(&ClaimStatus::Deprecated), LifecycleStage::Deprecated); + assert_eq!(claim_status_to_lifecycle(&ClaimStatus::Superseded), LifecycleStage::Deprecated); + } + + #[test] + fn test_lifecycle_to_claim_status() { + assert_eq!(lifecycle_to_claim_status(LifecycleStage::Proposed, false), ClaimStatus::Draft); + assert_eq!( + lifecycle_to_claim_status(LifecycleStage::UnderReview, false), + ClaimStatus::Draft + ); + assert_eq!(lifecycle_to_claim_status(LifecycleStage::Approved, false), ClaimStatus::Active); + assert_eq!( + lifecycle_to_claim_status(LifecycleStage::Deprecated, false), + ClaimStatus::Deprecated + ); + assert_eq!( + lifecycle_to_claim_status(LifecycleStage::Deprecated, true), + ClaimStatus::Superseded + ); + assert_eq!( + lifecycle_to_claim_status(LifecycleStage::Rejected, false), + ClaimStatus::Deprecated + ); + } + + #[test] + fn test_authored_claim_draft_lifecycle() { + use crate::types::authored_claim::AuthoredValue; + + let claim = AuthoredClaim { + id: "draft-001".to_string(), + concept_path: "test/draft".to_string(), + predicate: "enabled".to_string(), + value: AuthoredValue::Bool(true), + comparison: Default::default(), + provenance: "test".to_string(), + invariant: "test".to_string(), + consequence: "test".to_string(), + authority_tier: "expert".to_string(), + evidence: vec![], + category: "test".to_string(), + status: ClaimStatus::Draft, + supersedes: None, + created_by: "test".to_string(), + created_at: "2026-02-12T00:00:00Z".to_string(), + updated_at: None, + }; + + let key = generate_signing_key(); + let assertion = + authored_claim_to_assertion(&claim, &key, 1706832000, None).expect("convert"); + assert_eq!(assertion.lifecycle, LifecycleStage::Proposed); + } + + #[test] + fn test_authored_claim_deprecated_lifecycle() { + use crate::types::authored_claim::AuthoredValue; + + let claim = AuthoredClaim { + id: "dep-001".to_string(), + concept_path: "test/deprecated".to_string(), + predicate: "enabled".to_string(), + value: AuthoredValue::Bool(true), + comparison: Default::default(), + provenance: "test".to_string(), + invariant: "test".to_string(), + consequence: "test".to_string(), + authority_tier: "expert".to_string(), + evidence: vec![], + category: "test".to_string(), + status: ClaimStatus::Deprecated, + supersedes: None, + created_by: "test".to_string(), + created_at: "2026-02-12T00:00:00Z".to_string(), + updated_at: None, + }; + + let key = generate_signing_key(); + let assertion = + authored_claim_to_assertion(&claim, &key, 1706832000, None).expect("convert"); + assert_eq!(assertion.lifecycle, LifecycleStage::Deprecated); + } + + #[test] + fn test_assertion_to_authored_claim_round_trip() { + use crate::types::authored_claim::AuthoredValue; + + let original = AuthoredClaim { + id: "round-trip-001".to_string(), + concept_path: "maxwell/wallet/atomics/ordering".to_string(), + predicate: "required_ordering".to_string(), + value: AuthoredValue::Text("SeqCst".to_string()), + comparison: ComparisonMode::Absent, + provenance: "Safety analysis".to_string(), + invariant: "All wallet atomics MUST use SeqCst".to_string(), + consequence: "Double-spend race condition".to_string(), + authority_tier: "expert".to_string(), + evidence: vec!["ADR-003".to_string(), "Intel SDM".to_string()], + category: "safety".to_string(), + status: ClaimStatus::Active, + supersedes: None, + created_by: "jml".to_string(), + created_at: "2026-02-08T12:00:00Z".to_string(), + updated_at: Some("2026-02-10T12:00:00Z".to_string()), + }; + + let key = generate_signing_key(); + let assertion = + authored_claim_to_assertion(&original, &key, 1706832000, None).expect("convert"); + let recovered = assertion_to_authored_claim(&assertion).expect("reverse"); + + assert_eq!(recovered.id, original.id); + assert_eq!(recovered.concept_path, original.concept_path); + assert_eq!(recovered.predicate, original.predicate); + assert_eq!(recovered.value, original.value); + assert_eq!(recovered.comparison, original.comparison); + assert_eq!(recovered.provenance, original.provenance); + assert_eq!(recovered.invariant, original.invariant); + assert_eq!(recovered.consequence, original.consequence); + assert_eq!(recovered.authority_tier, original.authority_tier); + assert_eq!(recovered.evidence, original.evidence); + assert_eq!(recovered.category, original.category); + assert_eq!(recovered.status, original.status); + assert_eq!(recovered.supersedes, original.supersedes); + assert_eq!(recovered.created_by, original.created_by); + assert_eq!(recovered.created_at, original.created_at); + assert_eq!(recovered.updated_at, original.updated_at); + } + + #[test] + fn test_assertion_to_authored_claim_with_supersedes() { + use crate::types::authored_claim::AuthoredValue; + + let original = AuthoredClaim { + id: "v2-001".to_string(), + concept_path: "test/path".to_string(), + predicate: "enabled".to_string(), + value: AuthoredValue::Bool(true), + comparison: ComparisonMode::Present, + provenance: "Updated analysis".to_string(), + invariant: "Must be present".to_string(), + consequence: "Breaks things".to_string(), + authority_tier: "community".to_string(), + evidence: vec![], + category: "architecture".to_string(), + status: ClaimStatus::Active, + supersedes: Some("v1-001".to_string()), + created_by: "tester".to_string(), + created_at: "2026-02-12T00:00:00Z".to_string(), + updated_at: None, + }; + + let key = generate_signing_key(); + let assertion = + authored_claim_to_assertion(&original, &key, 1706832000, None).expect("convert"); + let recovered = assertion_to_authored_claim(&assertion).expect("reverse"); + + assert_eq!(recovered.supersedes, Some("v1-001".to_string())); + assert_eq!(recovered.authority_tier, "community"); + assert_eq!(recovered.comparison, ComparisonMode::Present); + } + + #[test] + fn test_assertion_to_authored_claim_rejects_non_authored() { + // Create a plain observation assertion (no "authored: true" metadata) + let observation = Observation { + concept_path: "code://test".to_string(), + predicate: "enabled".to_string(), + value: ObjectValue::Boolean(true), + file: "test.rs".to_string(), + line: 1, + matched_text: "test".to_string(), + confidence: 1.0, + description: "test".to_string(), + }; + + let key = generate_signing_key(); + let assertion = claim_to_assertion(&observation, &key, 1706832000, None); + + let result = assertion_to_authored_claim(&assertion); + assert!(result.is_err()); + } + + #[test] + fn test_assertion_to_authored_claim_all_statuses() { + use crate::types::authored_claim::AuthoredValue; + + let key = generate_signing_key(); + + for status in [ + ClaimStatus::Draft, + ClaimStatus::Active, + ClaimStatus::Deprecated, + ClaimStatus::Superseded, + ] { + let claim = AuthoredClaim { + id: format!("status-{}", status), + concept_path: "test/status".to_string(), + predicate: "check".to_string(), + value: AuthoredValue::Bool(true), + comparison: Default::default(), + provenance: "test".to_string(), + invariant: "test".to_string(), + consequence: "test".to_string(), + authority_tier: "expert".to_string(), + evidence: vec![], + category: "test".to_string(), + status: status.clone(), + supersedes: if status == ClaimStatus::Superseded { + Some("old-001".to_string()) + } else { + None + }, + created_by: "test".to_string(), + created_at: "2026-02-12T00:00:00Z".to_string(), + updated_at: None, + }; + + let assertion = + authored_claim_to_assertion(&claim, &key, 1706832000, None).expect("convert"); + let recovered = assertion_to_authored_claim(&assertion).expect("reverse"); + assert_eq!(recovered.status, status, "Status round-trip failed for {:?}", status); + } + } } diff --git a/applications/aphoria/src/claim_store.rs b/applications/aphoria/src/claim_store.rs index 26022e8..2acb757 100644 --- a/applications/aphoria/src/claim_store.rs +++ b/applications/aphoria/src/claim_store.rs @@ -1,11 +1,16 @@ //! Claim storage interface and implementations. //! -//! Provides persistence for human-authored claims (not observations). -//! Claims are stored in `.aphoria/claims.toml` for version control. +//! 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 crate::types::AuthoredClaim; +use std::sync::Arc; + +use tokio::runtime::Handle; + +use crate::episteme::LocalEpisteme; +use crate::types::{AuthoredClaim, ClaimStatus}; use crate::AphoriaError; -use std::path::PathBuf; /// Filter criteria for querying claims. #[derive(Debug, Clone, Default)] @@ -16,8 +21,30 @@ pub struct ClaimFilter { /// Filter by predicate (exact match) pub predicate: Option, - /// Filter by authority tier - pub authority_tier: 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. @@ -36,13 +63,14 @@ pub struct ImportStats { /// Trait for claim storage backends. /// /// Implementations provide persistence for `AuthoredClaim` instances. -/// The primary implementation is `TomlClaimStore` which stores claims -/// in `.aphoria/claims.toml` for version control. +/// 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. /// - /// Claims are identified by `(concept_path, predicate)` tuple. - /// If a claim with the same tuple exists, it is replaced. + /// 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. @@ -59,7 +87,8 @@ pub trait ClaimStore: Send + Sync { /// Delete a claim by concept path and predicate. /// - /// Returns `true` if a claim was deleted, `false` if not found. + /// 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. @@ -84,37 +113,133 @@ pub trait ClaimStore: Send + Sync { } } -/// File-based claim storage using TOML format. +/// StemeDB-backed claim storage. /// -/// Stores claims in `.aphoria/claims.toml` relative to the base directory. -/// This allows claims to be version-controlled alongside code. +/// Claims are stored as assertions in the local StemeDB instance. +/// Uses the `AUTHORED_CLAIM` predicate index for efficient queries. /// -/// # Note -/// -/// This is a stub implementation. The `ClaimStore` trait is not yet implemented -/// for this struct. -// TODO(A4): Implement `ClaimStore for TomlClaimStore` using ClaimsFile for persistence. -#[allow(dead_code)] -pub struct TomlClaimStore { - base_dir: PathBuf, +/// 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, } -#[allow(dead_code)] -impl TomlClaimStore { - /// Create a new TOML claim store. - /// - /// # Arguments - /// - /// * `base_dir` - Project root directory (claims stored in `{base_dir}/.aphoria/claims.toml`) - pub fn new(base_dir: PathBuf) -> Self { - Self { base_dir } - } - - /// Get the path to the claims file. - fn claims_file(&self) -> PathBuf { - self.base_dir.join(".aphoria").join("claims.toml") +impl EpistemeClaimStore { + /// Create a new StemeDB-backed claim store. + pub fn new(episteme: Arc, handle: Handle) -> Self { + Self { episteme, handle } } } -// Implementation will be added in Commit 4 (Claim Storage) -// For now, this is just the trait interface. +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, + } + } +} diff --git a/applications/aphoria/src/claims_explain.rs b/applications/aphoria/src/claims_explain.rs index c700a31..5aba2d8 100644 --- a/applications/aphoria/src/claims_explain.rs +++ b/applications/aphoria/src/claims_explain.rs @@ -44,6 +44,7 @@ pub fn render_claims_markdown(claims: &[AuthoredClaim], project_name: &str) -> S /// Render a single claim as a markdown section. pub fn render_single_claim(out: &mut String, claim: &AuthoredClaim) { let status_badge = match claim.status { + ClaimStatus::Draft => " [DRAFT]", ClaimStatus::Active => "", ClaimStatus::Deprecated => " [DEPRECATED]", ClaimStatus::Superseded => " [SUPERSEDED]", diff --git a/applications/aphoria/src/claims_file.rs b/applications/aphoria/src/claims_file.rs index cdbbe3e..14ee2bf 100644 --- a/applications/aphoria/src/claims_file.rs +++ b/applications/aphoria/src/claims_file.rs @@ -51,6 +51,11 @@ impl ClaimsFile { Self { claims: Vec::new() } } + /// Create a claims file from a vector of claims. + pub fn from_claims(claims: Vec) -> Self { + Self { claims } + } + /// Add a claim entry, deduplicating by ID. /// /// Warns if an active claim already exists for the same concept_path/predicate. diff --git a/applications/aphoria/src/cli/claims.rs b/applications/aphoria/src/cli/claims.rs index 90267a3..1d9c87b 100644 --- a/applications/aphoria/src/cli/claims.rs +++ b/applications/aphoria/src/cli/claims.rs @@ -204,6 +204,25 @@ pub enum ClaimsCommands { format: String, }, + /// Export claims from StemeDB to a TOML file + Export { + /// Output file path (default: .aphoria/claims.toml) + #[arg(short, long)] + output: Option, + + /// Filter by category + #[arg(long)] + category: Option, + + /// Filter by status (active, deprecated, superseded, draft) + #[arg(long)] + status: Option, + + /// Output format: toml or json + #[arg(long, default_value = "toml")] + format: String, + }, + /// List pending claim markers ListMarkers { /// Filter by status (pending, formalized, rejected) diff --git a/applications/aphoria/src/community/pattern_store.rs b/applications/aphoria/src/community/pattern_store.rs index 308f6a1..27b6029 100644 --- a/applications/aphoria/src/community/pattern_store.rs +++ b/applications/aphoria/src/community/pattern_store.rs @@ -156,12 +156,17 @@ impl StemeDBPatternStore { } } let anon_hash = hasher.finalize(); - let assertion_subject = format!("community://pattern/{}", hex::encode(anon_hash.as_bytes())); + let assertion_subject = + format!("community://pattern/{}", hex::encode(anon_hash.as_bytes())); // Query all pattern_aggregate assertions to find matching subject - let hashes = self.predicate_index.get_by_predicate("pattern_aggregate").await.map_err(|e| { - AphoriaError::Storage(format!("Failed to query pattern_aggregate predicate index: {}", e)) - })?; + let hashes = + self.predicate_index.get_by_predicate("pattern_aggregate").await.map_err(|e| { + AphoriaError::Storage(format!( + "Failed to query pattern_aggregate predicate index: {}", + e + )) + })?; for hash in &hashes { if let Ok(Some(assertion)) = self.load_assertion(hash).await { @@ -194,12 +199,10 @@ impl StemeDBPatternStore { /// /// Since patterns use content-addressed subjects, this is effectively /// the same as add_pattern - the new version overwrites the old. - pub async fn update_pattern( - &self, - pattern: &PatternAggregate, - ) -> Result<(), AphoriaError> { + pub async fn update_pattern(&self, pattern: &PatternAggregate) -> Result<(), AphoriaError> { // Reuse add_pattern logic - content-addressed subject means update = overwrite - let aggregator = PatternAggregator::new(self.kv_store.clone(), self.predicate_index.clone()); + let aggregator = + PatternAggregator::new(self.kv_store.clone(), self.predicate_index.clone()); aggregator.add_pattern(pattern).await?; Ok(()) } diff --git a/applications/aphoria/src/config/defaults.rs b/applications/aphoria/src/config/defaults.rs index e588a81..196913c 100644 --- a/applications/aphoria/src/config/defaults.rs +++ b/applications/aphoria/src/config/defaults.rs @@ -11,11 +11,7 @@ use super::types::{ impl Default for EpistemeConfig { fn default() -> Self { - Self { - data_dir: dirs_default_data_dir(), - corpus_data_dir: Some(dirs_default_corpus_dir()), - url: None, - } + Self { data_dir: dirs_default_data_dir(), corpus_data_dir: Some(dirs_default_corpus_dir()) } } } @@ -148,11 +144,11 @@ impl Default for CorpusConfig { include_rfc: true, include_owasp: true, include_vendor: true, - use_community: true, // Enabled by default - async runtime issue resolved + use_community: true, // Enabled by default - async runtime issue resolved aggregation_enabled: true, // Enable observation aggregation rfc_list: None, - adaptive_thresholds: None, // Use built-in defaults - use_legacy_thresholds: false, // Use adaptive by default + adaptive_thresholds: None, // Use built-in defaults + use_legacy_thresholds: false, // Use adaptive by default } } } diff --git a/applications/aphoria/src/config/tests.rs b/applications/aphoria/src/config/tests.rs index 32fd333..9c6f348 100644 --- a/applications/aphoria/src/config/tests.rs +++ b/applications/aphoria/src/config/tests.rs @@ -215,12 +215,7 @@ value = true let config = AphoriaConfig::from_file(&config_path).expect("should load config"); assert_eq!(config.extractors.declarative.len(), 2); - let names: Vec<&str> = config - .extractors - .declarative - .iter() - .map(|e| e.name.as_str()) - .collect(); + let names: Vec<&str> = config.extractors.declarative.iter().map(|e| e.name.as_str()).collect(); assert!(names.contains(&"inline_extractor")); assert!(names.contains(&"file_extractor")); } diff --git a/applications/aphoria/src/config/types/core.rs b/applications/aphoria/src/config/types/core.rs index 28fed6c..5014ffc 100644 --- a/applications/aphoria/src/config/types/core.rs +++ b/applications/aphoria/src/config/types/core.rs @@ -126,9 +126,6 @@ pub struct EpistemeConfig { /// This stores aggregated pattern data from multiple projects for /// community corpus building. Set to `None` to disable corpus aggregation. pub corpus_data_dir: Option, - - /// Remote Episteme URL (future feature). - pub url: Option, } /// Conflict threshold configuration. diff --git a/applications/aphoria/src/corpus/authority_parser.rs b/applications/aphoria/src/corpus/authority_parser.rs index 7a24550..9f459af 100644 --- a/applications/aphoria/src/corpus/authority_parser.rs +++ b/applications/aphoria/src/corpus/authority_parser.rs @@ -113,85 +113,43 @@ mod tests { #[test] fn test_parse_rfc_basic() { let auth = parse_authority("RFC 5246"); - assert_eq!( - auth, - Authority::RFC { - num: 5246, - section: None - } - ); + assert_eq!(auth, Authority::RFC { num: 5246, section: None }); } #[test] fn test_parse_rfc_with_section() { let auth = parse_authority("RFC 5246 Section 7.4.2"); - assert_eq!( - auth, - Authority::RFC { - num: 5246, - section: Some("7.4.2".to_string()) - } - ); + assert_eq!(auth, Authority::RFC { num: 5246, section: Some("7.4.2".to_string()) }); } #[test] fn test_parse_rfc_lowercase() { let auth = parse_authority("rfc 7519"); - assert_eq!( - auth, - Authority::RFC { - num: 7519, - section: None - } - ); + assert_eq!(auth, Authority::RFC { num: 7519, section: None }); } #[test] fn test_parse_rfc_no_space() { let auth = parse_authority("RFC7519"); - assert_eq!( - auth, - Authority::RFC { - num: 7519, - section: None - } - ); + assert_eq!(auth, Authority::RFC { num: 7519, section: None }); } #[test] fn test_parse_owasp_with_year() { let auth = parse_authority("OWASP A03:2021"); - assert_eq!( - auth, - Authority::OWASP { - id: "a03".to_string(), - year: Some(2021) - } - ); + assert_eq!(auth, Authority::OWASP { id: "a03".to_string(), year: Some(2021) }); } #[test] fn test_parse_owasp_without_year() { let auth = parse_authority("OWASP A01"); - assert_eq!( - auth, - Authority::OWASP { - id: "a01".to_string(), - year: None - } - ); + assert_eq!(auth, Authority::OWASP { id: "a01".to_string(), year: None }); } #[test] fn test_parse_owasp_lowercase() { let auth = parse_authority("owasp a03:2021"); - assert_eq!( - auth, - Authority::OWASP { - id: "a03".to_string(), - year: Some(2021) - } - ); + assert_eq!(auth, Authority::OWASP { id: "a03".to_string(), year: Some(2021) }); } #[test] diff --git a/applications/aphoria/src/corpus/cli_created.rs b/applications/aphoria/src/corpus/cli_created.rs index 9054aca..5a2fb5f 100644 --- a/applications/aphoria/src/corpus/cli_created.rs +++ b/applications/aphoria/src/corpus/cli_created.rs @@ -66,19 +66,19 @@ impl super::AsyncCorpusBuilder for CliCreatedBuilder { info!("Building corpus from CLI-created items"); // Scan all items with "subject:" prefix - let all_items = self - .corpus_store - .scan_prefix(b"subject:") - .await - .map_err(|e| AphoriaError::Storage(format!("Failed to scan corpus database: {e}")))?; + let all_items = + self.corpus_store.scan_prefix(b"subject:").await.map_err(|e| { + AphoriaError::Storage(format!("Failed to scan corpus database: {e}")) + })?; info!(total_items = all_items.len(), "Scanned corpus database for CLI-created items"); // Filter for CLI-created items by checking metadata let mut assertions = Vec::new(); for (_key, value) in all_items { - let assertion: Assertion = stemedb_core::serde::deserialize(&value) - .map_err(|e| AphoriaError::Storage(format!("Failed to deserialize assertion: {e}")))?; + let assertion: Assertion = stemedb_core::serde::deserialize(&value).map_err(|e| { + AphoriaError::Storage(format!("Failed to deserialize assertion: {e}")) + })?; // Check metadata for "source": "cli_create" if let Some(ref meta_bytes) = assertion.source_metadata { diff --git a/applications/aphoria/src/corpus/community.rs b/applications/aphoria/src/corpus/community.rs index 52bc736..b708fbd 100644 --- a/applications/aphoria/src/corpus/community.rs +++ b/applications/aphoria/src/corpus/community.rs @@ -383,17 +383,15 @@ impl super::AsyncCorpusBuilder for CommunityCorpusBuilder { }; // Fetch popular patterns (now properly async without block_on!) - let patterns = self.pattern_store.get_popular_patterns(min_projects_for_query, 1000).await?; + let patterns = + self.pattern_store.get_popular_patterns(min_projects_for_query, 1000).await?; if patterns.is_empty() { info!("No patterns found for community corpus (empty store or below threshold)"); return Ok(vec![]); } - info!( - pattern_count = patterns.len(), - total_projects, "Evaluating patterns for promotion" - ); + info!(pattern_count = patterns.len(), total_projects, "Evaluating patterns for promotion"); let mut assertions = Vec::new(); diff --git a/applications/aphoria/src/corpus/mod.rs b/applications/aphoria/src/corpus/mod.rs index 8930af8..6b88caf 100644 --- a/applications/aphoria/src/corpus/mod.rs +++ b/applications/aphoria/src/corpus/mod.rs @@ -169,10 +169,7 @@ pub struct CorpusRegistry { impl CorpusRegistry { /// Create a new empty registry. pub fn new() -> Self { - Self { - sync_builders: Vec::new(), - async_builders: Vec::new(), - } + Self { sync_builders: Vec::new(), async_builders: Vec::new() } } /// Create a registry with default builders (RFC, OWASP, Vendor). @@ -222,7 +219,8 @@ impl CorpusRegistry { // Add community corpus builder if enabled if config.use_community { - let community_builder = CommunityCorpusBuilder::from_stores(kv_store, predicate_index, config); + let community_builder = + CommunityCorpusBuilder::from_stores(kv_store, predicate_index, config); registry.register_async(Box::new(community_builder)); info!("Registered community corpus builder (async)"); } diff --git a/applications/aphoria/src/corpus/subject_builder.rs b/applications/aphoria/src/corpus/subject_builder.rs index 96011d8..6b9d304 100644 --- a/applications/aphoria/src/corpus/subject_builder.rs +++ b/applications/aphoria/src/corpus/subject_builder.rs @@ -50,11 +50,7 @@ pub fn build_corpus_subject(pattern: &WikiPattern, authority: &Authority) -> Str /// /// Converts to lowercase, replaces spaces with underscores, trims slashes. fn normalize_subject(subject: &str) -> String { - subject - .trim() - .trim_matches('/') - .to_lowercase() - .replace(' ', "_") + subject.trim().trim_matches('/').to_lowercase().replace(' ', "_") } #[cfg(test)] @@ -75,10 +71,7 @@ mod tests { #[test] fn test_rfc_subject() { let pattern = make_pattern("tls/cert_verification"); - let authority = Authority::RFC { - num: 5246, - section: Some("7.4.2".to_string()), - }; + let authority = Authority::RFC { num: 5246, section: Some("7.4.2".to_string()) }; let subject = build_corpus_subject(&pattern, &authority); assert_eq!(subject, "rfc://5246/tls/cert_verification"); } @@ -86,10 +79,7 @@ mod tests { #[test] fn test_rfc_subject_with_spaces() { let pattern = make_pattern("TLS Cert Verification"); - let authority = Authority::RFC { - num: 5246, - section: None, - }; + let authority = Authority::RFC { num: 5246, section: None }; let subject = build_corpus_subject(&pattern, &authority); assert_eq!(subject, "rfc://5246/tls_cert_verification"); } @@ -97,10 +87,7 @@ mod tests { #[test] fn test_owasp_subject() { let pattern = make_pattern("password/storage"); - let authority = Authority::OWASP { - id: "A03".to_string(), - year: Some(2021), - }; + let authority = Authority::OWASP { id: "A03".to_string(), year: Some(2021) }; let subject = build_corpus_subject(&pattern, &authority); assert_eq!(subject, "owasp://a03/password/storage"); } @@ -124,10 +111,7 @@ mod tests { #[test] fn test_normalize_leading_trailing_slashes() { let pattern = make_pattern("/api/security/"); - let authority = Authority::RFC { - num: 7519, - section: None, - }; + let authority = Authority::RFC { num: 7519, section: None }; let subject = build_corpus_subject(&pattern, &authority); assert_eq!(subject, "rfc://7519/api/security"); } @@ -135,10 +119,7 @@ mod tests { #[test] fn test_normalize_uppercase() { let pattern = make_pattern("JWT/Validation"); - let authority = Authority::RFC { - num: 7519, - section: None, - }; + let authority = Authority::RFC { num: 7519, section: None }; let subject = build_corpus_subject(&pattern, &authority); assert_eq!(subject, "rfc://7519/jwt/validation"); } diff --git a/applications/aphoria/src/corpus/thresholds.rs b/applications/aphoria/src/corpus/thresholds.rs index 6256199..e35ca4f 100644 --- a/applications/aphoria/src/corpus/thresholds.rs +++ b/applications/aphoria/src/corpus/thresholds.rs @@ -340,7 +340,11 @@ impl ScaleAdaptiveThresholds { if adoption_rate >= reg.min_adoption_rate && project_count >= min_projects && (!reg.require_authority - || matches_authority(has_authority_match, authority_scheme, ®.authority_sources)) + || matches_authority( + has_authority_match, + authority_scheme, + ®.authority_sources, + )) { return PromotionDecision::AutoPromote(SourceClass::Regulatory); } @@ -352,7 +356,11 @@ impl ScaleAdaptiveThresholds { if adoption_rate >= clin.min_adoption_rate && project_count >= min_projects && (!clin.require_authority - || matches_authority(has_authority_match, authority_scheme, &clin.authority_sources)) + || matches_authority( + has_authority_match, + authority_scheme, + &clin.authority_sources, + )) { return PromotionDecision::AutoPromote(SourceClass::Clinical); } @@ -692,8 +700,8 @@ mod tests { // 3 projects total, pattern in 2 projects (67% adoption) let decision = thresholds.evaluate(2, 3, false, None); - // Should promote to emerging: max(2, 0.50*3) = 2, adoption = 67% >= 50% - assert_eq!(decision, PromotionDecision::RequireReview); + // Micro tier has auto_promote: true + assert_eq!(decision, PromotionDecision::AutoPromote(SourceClass::Community)); } #[test] @@ -715,8 +723,8 @@ mod tests { let decision = thresholds.evaluate(3, 3, true, Some("rfc://1234")); // Should NOT promote to regulatory (disabled for micro tier) - // Should promote to emerging instead - assert_eq!(decision, PromotionDecision::RequireReview); + // Micro tier has auto_promote: true → AutoPromote(Community) + assert_eq!(decision, PromotionDecision::AutoPromote(SourceClass::Community)); } #[test] @@ -739,8 +747,8 @@ mod tests { let decision = thresholds.evaluate(4, 10, false, None); // Small tier emerging: max(2, 0.40*10) = 4, rate = 40% - // Should require review - assert_eq!(decision, PromotionDecision::RequireReview); + // Small tier has auto_promote: true + assert_eq!(decision, PromotionDecision::AutoPromote(SourceClass::Community)); } #[test] @@ -773,10 +781,18 @@ mod tests { assert!(matches_authority(true, Some("rfc://9110"), &["rfc://".into(), "nist://".into()])); // NIST source matches regulatory - assert!(matches_authority(true, Some("nist://sp800-53"), &["rfc://".into(), "nist://".into()])); + assert!(matches_authority( + true, + Some("nist://sp800-53"), + &["rfc://".into(), "nist://".into()] + )); // OWASP doesn't match regulatory - assert!(!matches_authority(true, Some("owasp://top-10/a01"), &["rfc://".into(), "nist://".into()])); + assert!(!matches_authority( + true, + Some("owasp://top-10/a01"), + &["rfc://".into(), "nist://".into()] + )); // No authority doesn't match when required assert!(!matches_authority(false, None, &["rfc://".into()])); diff --git a/applications/aphoria/src/corpus/wiki_corpus_builder.rs b/applications/aphoria/src/corpus/wiki_corpus_builder.rs index 0872cba..3902e5f 100644 --- a/applications/aphoria/src/corpus/wiki_corpus_builder.rs +++ b/applications/aphoria/src/corpus/wiki_corpus_builder.rs @@ -10,10 +10,10 @@ use crate::episteme::create_authoritative_assertion_with_metadata; use crate::error::AphoriaError; use ed25519_dalek::SigningKey; use serde_json::json; -use stemedb_core::types::SourceClass; -use stemedb_storage::{HybridStore, KVStore}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; +use stemedb_core::types::SourceClass; +use stemedb_storage::{HybridStore, KVStore}; use tracing::{info, warn}; /// Promote wiki patterns to corpus database as signed assertions @@ -59,10 +59,8 @@ pub async fn promote_wiki_patterns_to_corpus( }; // Get authority source string for metadata - let authority_source = pattern - .authority - .clone() - .unwrap_or_else(|| "wiki import".to_string()); + let authority_source = + pattern.authority.clone().unwrap_or_else(|| "wiki import".to_string()); // Build rich metadata let metadata = json!({ @@ -103,17 +101,11 @@ pub async fn promote_wiki_patterns_to_corpus( // Also store in predicate index let pred_key = format!("predicate:corpus:{}", assertion.predicate); - corpus_store - .put(pred_key.as_bytes(), &serialized) - .await - .map_err(|e| { - AphoriaError::Storage(format!("Failed to store predicate index: {}", e)) - })?; + corpus_store.put(pred_key.as_bytes(), &serialized).await.map_err(|e| { + AphoriaError::Storage(format!("Failed to store predicate index: {}", e)) + })?; - info!( - "Promoted wiki pattern to corpus: {} -> {}", - pattern.subject, subject - ); + info!("Promoted wiki pattern to corpus: {} -> {}", pattern.subject, subject); promoted += 1; } diff --git a/applications/aphoria/src/corpus/wiki_importer.rs b/applications/aphoria/src/corpus/wiki_importer.rs index 39eaf9a..2ac1714 100644 --- a/applications/aphoria/src/corpus/wiki_importer.rs +++ b/applications/aphoria/src/corpus/wiki_importer.rs @@ -98,13 +98,18 @@ impl WikiParser { verified | enforced | used | set\s+to | configured ) - "# - ).map_err(|e| AphoriaError::Config(format!("Failed to compile must_pattern regex: {}", e)))?; + "#, + ) + .map_err(|e| { + AphoriaError::Config(format!("Failed to compile must_pattern regex: {}", e)) + })?; let authority_pattern = Regex::new( r"(?i)Authority:\s*(RFC\s+\d+(?:\s+Section\s+[\d.]+)?|OWASP\s+[\w\s-]+|CWE-\d+)", ) - .map_err(|e| AphoriaError::Config(format!("Failed to compile authority_pattern regex: {}", e)))?; + .map_err(|e| { + AphoriaError::Config(format!("Failed to compile authority_pattern regex: {}", e)) + })?; Ok(Self { must_pattern, authority_pattern }) } diff --git a/applications/aphoria/src/corpus_build.rs b/applications/aphoria/src/corpus_build.rs index 261b2fc..c5baf68 100644 --- a/applications/aphoria/src/corpus_build.rs +++ b/applications/aphoria/src/corpus_build.rs @@ -3,13 +3,13 @@ use std::path::{Path, PathBuf}; use crate::bridge; -use stemedb_storage::KVStore; use crate::config::AphoriaConfig; use crate::corpus::{CorpusBuildResult, CorpusBuilderInfo, CorpusRegistry}; use crate::current_timestamp; use crate::episteme; use crate::error::AphoriaError; use crate::policy::TrustPack; +use stemedb_storage::KVStore; use tracing::{info, instrument}; /// Arguments for corpus build command. @@ -61,7 +61,8 @@ pub async fn build_corpus( // Open corpus database for CLI-created items (if configured) let corpus_store = if let Some(ref corpus_data_dir) = config.episteme.corpus_data_dir { - let corpus_episteme = episteme::LocalEpisteme::open_corpus_db(corpus_data_dir, &project_root).await?; + let corpus_episteme = + episteme::LocalEpisteme::open_corpus_db(corpus_data_dir, &project_root).await?; Some(corpus_episteme.store().clone()) } else { None @@ -71,7 +72,8 @@ pub async fn build_corpus( let kv_store = episteme.store().clone(); let predicate_index = std::sync::Arc::new(stemedb_storage::GenericPredicateIndexStore::new(kv_store.clone())); - let registry = CorpusRegistry::with_stores(&corpus_config, kv_store, predicate_index, corpus_store); + let registry = + CorpusRegistry::with_stores(&corpus_config, kv_store, predicate_index, corpus_store); // Load signing key let signing_key = bridge::load_or_generate_key(&project_root)?; @@ -207,9 +209,7 @@ pub async fn import_corpus_from_wiki>( } // Walk directory for markdown files - let walker = ignore::WalkBuilder::new(wiki_path) - .follow_links(true) - .build(); + let walker = ignore::WalkBuilder::new(wiki_path).follow_links(true).build(); for entry in walker.flatten() { if entry.file_type().is_some_and(|ft| ft.is_file()) { @@ -249,12 +249,9 @@ pub async fn import_corpus_from_wiki>( let signing_key = corpus_episteme.signing_key().clone(); // Promote wiki patterns to corpus database - let promoted = promote_wiki_patterns_to_corpus( - patterns, - &signing_key, - corpus_episteme.get_kv_store(), - ) - .await?; + let promoted = + promote_wiki_patterns_to_corpus(patterns, &signing_key, corpus_episteme.get_kv_store()) + .await?; corpus_episteme.shutdown().await; @@ -302,11 +299,7 @@ pub async fn create_corpus_item( 1 => SourceClass::Clinical, 2 => SourceClass::Observational, 3 => SourceClass::Community, - _ => { - return Err(AphoriaError::Config(format!( - "Invalid tier: {tier}. Must be 0-3" - ))) - } + _ => return Err(AphoriaError::Config(format!("Invalid tier: {tier}. Must be 0-3"))), }; // 2. Parse value into ObjectValue @@ -550,22 +543,10 @@ mod tests { use stemedb_core::types::ObjectValue; // Test boolean parsing (case-insensitive) - assert_eq!( - parse_value_string("true").unwrap(), - ObjectValue::Boolean(true) - ); - assert_eq!( - parse_value_string("TRUE").unwrap(), - ObjectValue::Boolean(true) - ); - assert_eq!( - parse_value_string("false").unwrap(), - ObjectValue::Boolean(false) - ); - assert_eq!( - parse_value_string("False").unwrap(), - ObjectValue::Boolean(false) - ); + assert_eq!(parse_value_string("true").unwrap(), ObjectValue::Boolean(true)); + assert_eq!(parse_value_string("TRUE").unwrap(), ObjectValue::Boolean(true)); + assert_eq!(parse_value_string("false").unwrap(), ObjectValue::Boolean(false)); + assert_eq!(parse_value_string("False").unwrap(), ObjectValue::Boolean(false)); } #[test] @@ -574,18 +555,9 @@ mod tests { // Test number parsing assert_eq!(parse_value_string("42").unwrap(), ObjectValue::Number(42.0)); - assert_eq!( - parse_value_string("3.14").unwrap(), - ObjectValue::Number(3.14) - ); - assert_eq!( - parse_value_string("-100").unwrap(), - ObjectValue::Number(-100.0) - ); - assert_eq!( - parse_value_string("0.0").unwrap(), - ObjectValue::Number(0.0) - ); + assert_eq!(parse_value_string("3.14").unwrap(), ObjectValue::Number(3.14)); + assert_eq!(parse_value_string("-100").unwrap(), ObjectValue::Number(-100.0)); + assert_eq!(parse_value_string("0.0").unwrap(), ObjectValue::Number(0.0)); } #[test] @@ -601,9 +573,6 @@ mod tests { parse_value_string("not_a_bool").unwrap(), ObjectValue::Text("not_a_bool".to_string()) ); - assert_eq!( - parse_value_string("1.2.3").unwrap(), - ObjectValue::Text("1.2.3".to_string()) - ); + assert_eq!(parse_value_string("1.2.3").unwrap(), ObjectValue::Text("1.2.3".to_string())); } } diff --git a/applications/aphoria/src/episteme/local/mod.rs b/applications/aphoria/src/episteme/local/mod.rs index 69f59d9..fd30f9d 100644 --- a/applications/aphoria/src/episteme/local/mod.rs +++ b/applications/aphoria/src/episteme/local/mod.rs @@ -47,7 +47,10 @@ impl LocalEpisteme { /// This opens a separate database for corpus assertions (RFC, OWASP, etc.) /// stored in `~/.aphoria/corpus-db/` instead of the project-local database. #[instrument(fields(corpus_data_dir = ?corpus_data_dir))] - pub async fn open_corpus_db(corpus_data_dir: &Path, project_root: &Path) -> Result { + pub async fn open_corpus_db( + corpus_data_dir: &Path, + project_root: &Path, + ) -> Result { // Expand tilde if present let corpus_path = if let Some(path_str) = corpus_data_dir.to_str() { if path_str.starts_with('~') { @@ -61,8 +64,7 @@ impl LocalEpisteme { }; // Create directory if it doesn't exist - tokio::fs::create_dir_all(&corpus_path).await - .map_err(AphoriaError::Io)?; + tokio::fs::create_dir_all(&corpus_path).await.map_err(AphoriaError::Io)?; // Canonicalize (required by fjall/lsm-tree) let corpus_path = corpus_path.canonicalize().map_err(|e| { @@ -76,12 +78,18 @@ impl LocalEpisteme { // Open WAL let journal = Arc::new(Mutex::new(Journal::open(&wal_dir).map_err(|e| { - AphoriaError::Storage(format!("Failed to open corpus WAL at {}: {e}", wal_dir.display())) + AphoriaError::Storage(format!( + "Failed to open corpus WAL at {}: {e}", + wal_dir.display() + )) })?)); // Open store (directly at corpus_path, matching API behavior) let store = Arc::new(HybridStore::open(&corpus_path).map_err(|e| { - AphoriaError::Storage(format!("Failed to open corpus store at {}: {e}", corpus_path.display())) + AphoriaError::Storage(format!( + "Failed to open corpus store at {}: {e}", + corpus_path.display() + )) })?); // Create ingestor @@ -105,10 +113,10 @@ impl LocalEpisteme { let predicate_alias_store = GenericPredicateAliasStore::new(store.clone()); // Load predicate aliases - let stored_aliases = predicate_alias_store - .list_all_predicate_aliases() - .await - .map_err(|e| AphoriaError::Storage(format!("Failed to load corpus predicate aliases: {e}")))?; + let stored_aliases = + predicate_alias_store.list_all_predicate_aliases().await.map_err(|e| { + AphoriaError::Storage(format!("Failed to load corpus predicate aliases: {e}")) + })?; let predicate_aliases: Vec = stored_aliases .into_iter() .map(|s| PredicateAliasSet::new(s.canonical, s.aliases)) @@ -267,7 +275,8 @@ impl LocalEpisteme { // No corpus_store here - CLI-created items are only needed in explicit corpus builds, // not during scans (which use project-local episteme) - let registry = CorpusRegistry::with_stores(config, self.store.clone(), predicate_index, None); + let registry = + CorpusRegistry::with_stores(config, self.store.clone(), predicate_index, None); let timestamp = current_timestamp(); diff --git a/applications/aphoria/src/episteme/local/queries.rs b/applications/aphoria/src/episteme/local/queries.rs index b8d691e..a52f5e0 100644 --- a/applications/aphoria/src/episteme/local/queries.rs +++ b/applications/aphoria/src/episteme/local/queries.rs @@ -6,9 +6,12 @@ use stemedb_core::types::Assertion; use stemedb_storage::{KVStore, PackSourceStore}; use tracing::{debug, info, instrument, warn}; +use crate::bridge::assertion_to_authored_claim; +use crate::claim_store::ClaimFilter; use crate::config::AphoriaConfig; use crate::types::{ - AcknowledgmentInfo, ConflictResult, ConflictingSource, Observation, PolicySourceInfo, Verdict, + predicates, AcknowledgmentInfo, AuthoredClaim, ConflictResult, ConflictingSource, Observation, + PolicySourceInfo, Verdict, }; use crate::AphoriaError; @@ -247,6 +250,60 @@ impl LocalEpisteme { Ok(results) } + /// Fetch all authored claims from StemeDB. + /// + /// Uses the `AUTHORED_CLAIM` predicate index to find assertions, + /// then converts back to `AuthoredClaim` via `assertion_to_authored_claim()`. + #[allow(dead_code)] // Used by EpistemeClaimStore (T4) and scanner (T6) + #[instrument(skip(self))] + pub async fn fetch_authored_claims(&self) -> Result, AphoriaError> { + let assertions = self.fetch_assertions_by_predicate(predicates::AUTHORED_CLAIM).await?; + + let mut claims = Vec::with_capacity(assertions.len()); + for assertion in &assertions { + match assertion_to_authored_claim(assertion) { + Ok(claim) => claims.push(claim), + Err(e) => { + warn!( + subject = %assertion.subject, + error = %e, + "Failed to convert assertion to authored claim" + ); + } + } + } + + info!(count = claims.len(), "Fetched authored claims from StemeDB"); + Ok(claims) + } + + /// Fetch authored claims matching a filter. + #[allow(dead_code)] // Used by EpistemeClaimStore (T4) + pub async fn fetch_authored_claims_filtered( + &self, + filter: &ClaimFilter, + ) -> Result, AphoriaError> { + let all = self.fetch_authored_claims().await?; + Ok(all.into_iter().filter(|c| filter.matches(c)).collect()) + } + + /// Fetch a single authored claim by concept_path + predicate. + #[allow(dead_code)] // Used by EpistemeClaimStore (T4) + pub async fn fetch_authored_claim( + &self, + concept_path: &str, + predicate: &str, + ) -> Result, AphoriaError> { + let filter = ClaimFilter { + concept_path: Some(concept_path.to_string()), + predicate: Some(predicate.to_string()), + authority_tier: None, + }; + let claims = self.fetch_authored_claims_filtered(&filter).await?; + // Return the most recent one (last ingested) + Ok(claims.into_iter().last()) + } + /// Load an assertion from the store using its hash. pub async fn load_assertion_by_hash(&self, hash: &[u8; 32]) -> Option { let hash_hex = hex::encode(hash); diff --git a/applications/aphoria/src/episteme/local/store.rs b/applications/aphoria/src/episteme/local/store.rs index 0e2fcf2..98266be 100644 --- a/applications/aphoria/src/episteme/local/store.rs +++ b/applications/aphoria/src/episteme/local/store.rs @@ -7,8 +7,8 @@ use stemedb_ingest::serialize_assertion; use stemedb_storage::PredicateIndexStore; use tracing::{debug, info, instrument, warn}; -use crate::bridge::observation_to_assertion; -use crate::types::{predicates, Observation}; +use crate::bridge::{authored_claim_to_assertion, observation_to_assertion}; +use crate::types::{predicates, AuthoredClaim, Observation}; use crate::walker::git::get_current_commit_hash; use crate::AphoriaError; @@ -17,6 +17,11 @@ use super::LocalEpisteme; impl LocalEpisteme { /// Ingest a batch of extracted claims into Episteme. + /// + /// **Deprecated:** Use `ingest_observations()` instead — this method accepts + /// `&[Observation]`, not `&[AuthoredClaim]`, despite its name. + #[deprecated(note = "Use ingest_observations() — this method name is misleading")] + #[allow(dead_code)] #[instrument(skip(self, claims), fields(claim_count = claims.len()))] pub async fn ingest_claims(&self, claims: &[Observation]) -> Result { let timestamp = current_timestamp(); @@ -248,6 +253,83 @@ impl LocalEpisteme { Ok(ingested) } + /// Ingest an authored claim as a StemeDB assertion. + /// + /// Uses `authored_claim_to_assertion()` to convert, then writes to WAL. + /// Indexed under `AUTHORED_CLAIM` predicate for efficient query. + #[allow(dead_code)] // Used by EpistemeClaimStore (T4) and CLI handlers (T5) + #[instrument(skip(self, claim), fields(claim_id = %claim.id, concept_path = %claim.concept_path))] + pub async fn ingest_authored_claim( + &self, + claim: &AuthoredClaim, + ) -> Result<[u8; 32], AphoriaError> { + let timestamp = current_timestamp(); + let git_commit = get_current_commit_hash(&self.project_root); + + let assertion = authored_claim_to_assertion( + claim, + &self.signing_key, + timestamp, + git_commit.as_deref(), + )?; + + let record_bytes = serialize_assertion(&assertion).map_err(|e| { + AphoriaError::Storage(format!("Failed to serialize authored claim: {e}")) + })?; + + let hash = *blake3::hash(&record_bytes[8..]).as_bytes(); + + // Write to WAL + { + let mut journal = self.journal.lock().await; + journal.append(record_bytes).map_err(|e| { + AphoriaError::Storage(format!("Failed to append authored claim to WAL: {e}")) + })?; + journal.force_sync().map_err(|e| { + AphoriaError::Storage(format!("Failed to sync authored claim WAL: {e}")) + })?; + } + + // Process through ingestor + self.ingestor.process_pending().await.map_err(|e| { + AphoriaError::Storage(format!("Failed to process authored claim ingestion: {e}")) + })?; + + // Index under AUTHORED_CLAIM predicate + if let Err(e) = self + .predicate_index_store + .add_to_predicate_index(predicates::AUTHORED_CLAIM, &hash) + .await + { + warn!(hash = %hex::encode(hash), error = %e, "Failed to add to authored_claim index"); + } + + info!(claim_id = %claim.id, "Ingested authored claim into StemeDB"); + Ok(hash) + } + + /// Ingest multiple authored claims in batch. + /// + /// Returns the number of claims successfully ingested. + #[allow(dead_code)] // Used by import handler (T7) + #[instrument(skip(self, claims), fields(count = claims.len()))] + pub async fn ingest_authored_claims( + &self, + claims: &[AuthoredClaim], + ) -> Result { + let mut ingested = 0; + for claim in claims { + match self.ingest_authored_claim(claim).await { + Ok(_) => ingested += 1, + Err(e) => { + warn!(claim_id = %claim.id, error = %e, "Failed to ingest authored claim"); + } + } + } + info!(ingested, total = claims.len(), "Batch ingested authored claims"); + Ok(ingested) + } + /// Fetch all "acknowledged" assertions for policy export. pub async fn fetch_acknowledgments(&self) -> Result, AphoriaError> { self.fetch_assertions_by_predicate(predicates::ACKNOWLEDGED).await @@ -268,7 +350,7 @@ impl LocalEpisteme { } /// Fetch assertions by predicate from the predicate index. - async fn fetch_assertions_by_predicate( + pub(crate) async fn fetch_assertions_by_predicate( &self, predicate: &str, ) -> Result, AphoriaError> { diff --git a/applications/aphoria/src/extractors/ack_mode_config.rs b/applications/aphoria/src/extractors/ack_mode_config.rs index b1cbf6c..c35e33d 100644 --- a/applications/aphoria/src/extractors/ack_mode_config.rs +++ b/applications/aphoria/src/extractors/ack_mode_config.rs @@ -166,9 +166,6 @@ mod tests { // Note: Current implementation will detect the pattern even in comments // For production, would need comment filtering // For now, accept this as a limitation - assert!( - obs.len() <= 1, - "May detect pattern in comment - acceptable for v1" - ); + assert!(obs.len() <= 1, "May detect pattern in comment - acceptable for v1"); } } diff --git a/applications/aphoria/src/extractors/async_blocking.rs b/applications/aphoria/src/extractors/async_blocking.rs index 5d44954..48c580e 100644 --- a/applications/aphoria/src/extractors/async_blocking.rs +++ b/applications/aphoria/src/extractors/async_blocking.rs @@ -114,11 +114,7 @@ impl Extractor for AsyncBlockingExtractor { } fn screening_patterns(&self) -> Vec<&str> { - vec![ - r"async\s+fn", - r"thread::sleep", - r"std::thread::sleep", - ] + vec![r"async\s+fn", r"thread::sleep", r"std::thread::sleep"] } fn verifiable_predicates(&self) -> Vec<(&str, &str)> { diff --git a/applications/aphoria/src/extractors/mod.rs b/applications/aphoria/src/extractors/mod.rs index b8ce4c2..f171cb0 100644 --- a/applications/aphoria/src/extractors/mod.rs +++ b/applications/aphoria/src/extractors/mod.rs @@ -56,8 +56,10 @@ //! Users can also define custom extractors via `aphoria.toml` without writing //! Rust code. See [`DeclarativeExtractor`] for details. +mod ack_mode_config; mod api_key_security; mod aspnet_security; +mod async_blocking; mod auth_bypass; mod circuit_breaker_config; mod command_injection; @@ -84,17 +86,14 @@ mod jwt_config; mod laravel_security; mod nestjs_security; mod nextjs_security; -mod orm_injection; mod option_bounds; mod option_value; +mod orm_injection; mod path_traversal; mod rails_security; mod rate_limit; mod registry; mod security_headers; -mod unbounded_resources; -mod async_blocking; -mod ack_mode_config; mod self_audit; mod spring_security; mod sql_injection; @@ -103,6 +102,7 @@ mod timeout_config; mod tls_verify; mod tls_version; mod traits; +mod unbounded_resources; mod unreal_config; mod unreal_cpp; mod unreal_performance; @@ -112,8 +112,10 @@ mod weak_crypto; mod weak_password; mod xxe; +pub use ack_mode_config::AckModeConfigExtractor; pub use api_key_security::ApiKeySecurityExtractor; pub use aspnet_security::AspNetSecurityExtractor; +pub use async_blocking::AsyncBlockingExtractor; pub use auth_bypass::AuthBypassExtractor; pub use circuit_breaker_config::CircuitBreakerConfigExtractor; pub use command_injection::CommandInjectionExtractor; @@ -158,6 +160,7 @@ pub use timeout_config::{TimeoutConfigExtractor, TimeoutThresholds}; pub use tls_verify::TlsVerifyExtractor; pub use tls_version::TlsVersionExtractor; pub use traits::{build_claim, is_test_file, Extractor, PatternMetadata}; +pub use unbounded_resources::UnboundedResourcesExtractor; pub use unreal_config::UnrealConfigExtractor; pub use unreal_cpp::UnrealCppExtractor; pub use unreal_performance::UnrealPerformanceExtractor; @@ -166,6 +169,3 @@ pub use unvalidated_redirects::UnvalidatedRedirectsExtractor; pub use weak_crypto::WeakCryptoExtractor; pub use weak_password::WeakPasswordExtractor; pub use xxe::XxeExtractor; -pub use unbounded_resources::UnboundedResourcesExtractor; -pub use async_blocking::AsyncBlockingExtractor; -pub use ack_mode_config::AckModeConfigExtractor; diff --git a/applications/aphoria/src/extractors/option_bounds.rs b/applications/aphoria/src/extractors/option_bounds.rs index 0c03609..ee8abdf 100644 --- a/applications/aphoria/src/extractors/option_bounds.rs +++ b/applications/aphoria/src/extractors/option_bounds.rs @@ -1,7 +1,7 @@ +use super::{build_claim, Extractor}; +use crate::types::{Language, Observation}; use regex::Regex; use stemedb_core::types::ObjectValue; -use super::{Extractor, build_claim}; -use crate::types::{Language, Observation}; /// Detects when Option fields are set to None (unbounded configuration). /// @@ -46,25 +46,20 @@ impl OptionBoundsExtractor { Self { field_pattern: Regex::new(r"pub\s+(\w+):\s*Option<(?:usize|u32|u64|i32|i64|Duration)>") .expect("valid regex"), - none_pattern: Regex::new(r"(\w+):\s*None") - .expect("valid regex"), + none_pattern: Regex::new(r"(\w+):\s*None").expect("valid regex"), } } fn extract_field_names(&self, content: &str) -> Vec { - self.field_pattern - .captures_iter(content) - .map(|cap| cap[1].to_string()) - .collect() + self.field_pattern.captures_iter(content).map(|cap| cap[1].to_string()).collect() } fn find_none_assignments(&self, content: &str) -> Vec<(String, usize)> { - content.lines() + content + .lines() .enumerate() .filter_map(|(idx, line)| { - self.none_pattern.captures(line).map(|cap| { - (cap[1].to_string(), idx + 1) - }) + self.none_pattern.captures(line).map(|cap| (cap[1].to_string(), idx + 1)) }) .collect() } @@ -108,11 +103,11 @@ impl Extractor for OptionBoundsExtractor { path_segments, &[&field_name], "configured", - ObjectValue::Boolean(false), // Not configured (unbounded) + ObjectValue::Boolean(false), // Not configured (unbounded) file, line_num, &format!("{}: None", field_name), - 0.95, // High confidence + 0.95, // High confidence &format!("{} is unbounded (allows None)", field_name), )); } @@ -122,7 +117,7 @@ impl Extractor for OptionBoundsExtractor { } fn screening_patterns(&self) -> Vec<&str> { - vec!["Option<", "None"] // Only run if file has Option types and None + vec!["Option<", "None"] // Only run if file has Option types and None } fn verifiable_predicates(&self) -> Vec<(&str, &str)> { @@ -215,7 +210,7 @@ mod tests { let extractor = OptionBoundsExtractor::new(); let obs = extractor.extract(&[], content, Language::Rust, "config.rs"); - assert_eq!(obs.len(), 0); // Should not detect non-Option fields + assert_eq!(obs.len(), 0); // Should not detect non-Option fields } #[test] @@ -236,7 +231,7 @@ mod tests { let extractor = OptionBoundsExtractor::new(); let obs = extractor.extract(&[], content, Language::Rust, "config.rs"); - assert_eq!(obs.len(), 0); // Should not detect Some(_) assignments + assert_eq!(obs.len(), 0); // Should not detect Some(_) assignments } #[test] diff --git a/applications/aphoria/src/extractors/option_value.rs b/applications/aphoria/src/extractors/option_value.rs index e35381e..948c4e3 100644 --- a/applications/aphoria/src/extractors/option_value.rs +++ b/applications/aphoria/src/extractors/option_value.rs @@ -1,7 +1,7 @@ +use super::{build_claim, Extractor}; +use crate::types::{Language, Observation}; use regex::Regex; use stemedb_core::types::ObjectValue; -use super::{Extractor, build_claim}; -use crate::types::{Language, Observation}; /// Extracts actual values from Option fields set to Some(n). /// @@ -46,8 +46,7 @@ impl OptionValueExtractor { Self { field_pattern: Regex::new(r"pub\s+(\w+):\s*Option<(?:usize|u32|u64|i32|i64|Duration)>") .expect("valid regex"), - some_pattern: Regex::new(r"(\w+):\s*Some\((\d+)\)") - .expect("valid regex"), + some_pattern: Regex::new(r"(\w+):\s*Some\((\d+)\)").expect("valid regex"), } } } @@ -77,10 +76,8 @@ impl Extractor for OptionValueExtractor { let mut observations = Vec::new(); // Find all Option fields in struct declarations - let option_fields: Vec = self.field_pattern - .captures_iter(content) - .map(|cap| cap[1].to_string()) - .collect(); + let option_fields: Vec = + self.field_pattern.captures_iter(content).map(|cap| cap[1].to_string()).collect(); // Find all Some(value) assignments and extract values for (line_num, line) in content.lines().enumerate() { @@ -98,7 +95,7 @@ impl Extractor for OptionValueExtractor { file, line_num + 1, line.trim(), - 1.0, // Exact match - high confidence + 1.0, // Exact match - high confidence &format!("{} set to Some({})", field_name, value), )); } @@ -206,7 +203,7 @@ mod tests { let extractor = OptionValueExtractor::new(); let obs = extractor.extract(&[], content, Language::Rust, "config.rs"); - assert_eq!(obs.len(), 0); // Should not extract from None + assert_eq!(obs.len(), 0); // Should not extract from None } #[test] @@ -227,7 +224,7 @@ mod tests { let extractor = OptionValueExtractor::new(); let obs = extractor.extract(&[], content, Language::Rust, "config.rs"); - assert_eq!(obs.len(), 0); // Should not extract from non-Option fields + assert_eq!(obs.len(), 0); // Should not extract from non-Option fields } #[test] diff --git a/applications/aphoria/src/extractors/registry.rs b/applications/aphoria/src/extractors/registry.rs index 2939e7d..be85c94 100644 --- a/applications/aphoria/src/extractors/registry.rs +++ b/applications/aphoria/src/extractors/registry.rs @@ -8,8 +8,10 @@ use tracing::instrument; use crate::config::AphoriaConfig; use crate::types::{Language, Observation}; +use super::ack_mode_config::AckModeConfigExtractor; use super::api_key_security::ApiKeySecurityExtractor; use super::aspnet_security::AspNetSecurityExtractor; +use super::async_blocking::AsyncBlockingExtractor; use super::auth_bypass::AuthBypassExtractor; use super::circuit_breaker_config::CircuitBreakerConfigExtractor; use super::command_injection::CommandInjectionExtractor; @@ -50,6 +52,7 @@ use super::timeout_config::{TimeoutConfigExtractor, TimeoutThresholds}; use super::tls_verify::TlsVerifyExtractor; use super::tls_version::TlsVersionExtractor; use super::traits::Extractor; +use super::unbounded_resources::UnboundedResourcesExtractor; use super::unreal_config::UnrealConfigExtractor; use super::unreal_cpp::UnrealCppExtractor; use super::unreal_performance::UnrealPerformanceExtractor; @@ -58,9 +61,6 @@ use super::unvalidated_redirects::UnvalidatedRedirectsExtractor; use super::weak_crypto::WeakCryptoExtractor; use super::weak_password::WeakPasswordExtractor; use super::xxe::XxeExtractor; -use super::unbounded_resources::UnboundedResourcesExtractor; -use super::async_blocking::AsyncBlockingExtractor; -use super::ack_mode_config::AckModeConfigExtractor; /// Pre-compiled RegexSet for a single language, mapping matched patterns back to extractor indices. struct ScreeningSet { @@ -480,9 +480,8 @@ mod tests { /// circuit_breaker_config added: 37 + 1 = 38 /// import_graph added: 38 + 1 = 39 /// derive_pattern added: 39 + 1 = 40 - /// const_declarations added: 40 + 1 = 41 - /// unsafe_atomic added: 41 + 1 = 42 - const BUILTIN_EXTRACTOR_COUNT: usize = 45; + /// Default enabled list has 43 extractors, minus dep_versions (requires config flag) = 42 + const BUILTIN_EXTRACTOR_COUNT: usize = 42; #[test] fn test_registry_creation() { @@ -501,7 +500,8 @@ mod tests { let registry = ExtractorRegistry::new(&config); assert!(!registry.extractor_names().contains(&"tls_verify")); - assert_eq!(registry.extractor_names().len(), BUILTIN_EXTRACTOR_COUNT - 1); + // disabled list bypasses enabled list: 49 total - 1 disabled - 2 config-gated = 46 + assert_eq!(registry.extractor_names().len(), 46); } #[test] diff --git a/applications/aphoria/src/extractors/unbounded_resources.rs b/applications/aphoria/src/extractors/unbounded_resources.rs index 740748a..b1a1c3e 100644 --- a/applications/aphoria/src/extractors/unbounded_resources.rs +++ b/applications/aphoria/src/extractors/unbounded_resources.rs @@ -48,6 +48,7 @@ impl UnboundedResourcesExtractor { } } + #[allow(clippy::too_many_arguments)] fn extract_observation( &self, path_segments: &[String], diff --git a/applications/aphoria/src/governance/store.rs b/applications/aphoria/src/governance/store.rs index ebfe172..4583a8e 100644 --- a/applications/aphoria/src/governance/store.rs +++ b/applications/aphoria/src/governance/store.rs @@ -108,7 +108,7 @@ impl GovernanceStore { ) -> Result, AphoriaError> { let requests = self.list_all()?; // Return the most recent request for this pattern - Ok(requests.into_iter().filter(|r| r.pattern_id == *pattern_id).next_back()) + Ok(requests.into_iter().rfind(|r| r.pattern_id == *pattern_id)) } /// List all pending requests. diff --git a/applications/aphoria/src/handlers/claims.rs b/applications/aphoria/src/handlers/claims.rs index 28d35e8..14e7db7 100644 --- a/applications/aphoria/src/handlers/claims.rs +++ b/applications/aphoria/src/handlers/claims.rs @@ -1,4 +1,7 @@ //! Command handlers for authored claims management. +//! +//! Claims are stored as StemeDB assertions. TOML files are used only for +//! import/export and as a migration fallback. use std::process::ExitCode; @@ -6,38 +9,85 @@ use aphoria::claims_explain; use aphoria::claims_file::ClaimsFile; use aphoria::pending_markers::{MarkerStatus, PendingMarkersFile}; use aphoria::AphoriaConfig; +use aphoria::LocalEpisteme; use aphoria::{parse_authority_tier, AuthoredClaim, AuthoredValue, ClaimStatus}; use chrono::Utc; +use tracing::info; use crate::cli::ClaimsCommands; -/// Find the project root by walking up from cwd looking for `.aphoria/claims.toml`. +/// Find the project root by walking up from cwd looking for `.aphoria/` directory +/// or `.aphoria/claims.toml`. /// -/// Falls back to cwd if no claims file is found in any parent. +/// Falls back to cwd if nothing is found in any parent. fn project_root() -> Result { let cwd = std::env::current_dir().map_err(|e| { eprintln!("Error: cannot determine current directory: {e}"); ExitCode::from(3) })?; - // Check cwd first - if cwd.join(".aphoria/claims.toml").exists() { + // Check cwd first (either .aphoria/db or .aphoria/claims.toml) + if cwd.join(".aphoria/db").exists() || cwd.join(".aphoria/claims.toml").exists() { return Ok(cwd); } // Walk up parents let mut dir = cwd.as_path(); while let Some(parent) = dir.parent() { - if parent.join(".aphoria/claims.toml").exists() { + if parent.join(".aphoria/db").exists() || parent.join(".aphoria/claims.toml").exists() { return Ok(parent.to_path_buf()); } dir = parent; } - // Fall back to cwd (will return empty claims) + // Fall back to cwd Ok(cwd) } +/// Open a LocalEpisteme instance, returning ExitCode on failure. +async fn open_episteme( + root: &std::path::Path, + config: &AphoriaConfig, +) -> Result { + LocalEpisteme::open(config, root).await.map_err(|e| { + eprintln!("Error opening StemeDB: {e}"); + ExitCode::from(3) + }) +} + +/// Load authored claims from StemeDB with TOML fallback. +/// +/// If StemeDB has no claims but claims.toml exists, auto-imports them. +async fn load_claims_with_migration( + root: &std::path::Path, + config: &AphoriaConfig, +) -> Result<(LocalEpisteme, Vec), ExitCode> { + let episteme = open_episteme(root, config).await?; + let mut claims = episteme.fetch_authored_claims().await.map_err(|e| { + eprintln!("Error fetching claims from StemeDB: {e}"); + ExitCode::from(3) + })?; + + // Auto-import from TOML if StemeDB is empty and TOML exists + if claims.is_empty() { + let claims_path = ClaimsFile::default_path(root); + if let Ok(claims_file) = ClaimsFile::load(&claims_path) { + if !claims_file.is_empty() { + info!(claims = claims_file.len(), "Auto-importing claims from TOML to StemeDB"); + let imported = + episteme.ingest_authored_claims(&claims_file.claims).await.map_err(|e| { + eprintln!("Error auto-importing claims: {e}"); + ExitCode::from(3) + })?; + eprintln!("Auto-imported {} claims from claims.toml into StemeDB", imported); + claims = claims_file.claims; + } + } + } + + Ok((episteme, claims)) +} + /// Handle claims subcommands. pub async fn handle_claims_command(command: ClaimsCommands, config: &AphoriaConfig) -> ExitCode { match command { @@ -160,6 +210,9 @@ pub async fn handle_claims_command(command: ClaimsCommands, config: &AphoriaConf }; handle_claims_import(options, config).await } + ClaimsCommands::Export { output, category, status, format } => { + handle_claims_export(output, category, status, format, config).await + } } } @@ -177,7 +230,7 @@ async fn handle_claims_create( evidence: Vec, category: String, by: String, - _config: &AphoriaConfig, + config: &AphoriaConfig, ) -> ExitCode { use aphoria::ComparisonMode; @@ -207,17 +260,15 @@ async fn handle_claims_create( Ok(r) => r, Err(code) => return code, }; - let path = ClaimsFile::default_path(&root); - let mut claims_file = match ClaimsFile::load(&path) { - Ok(f) => f, - Err(e) => { - eprintln!("Error loading claims file: {e}"); - return ExitCode::from(3); - } - }; + + let (episteme, existing_claims): (LocalEpisteme, Vec) = + match load_claims_with_migration(&root, config).await { + Ok(v) => v, + Err(code) => return code, + }; // Check for duplicate ID - if claims_file.find_by_id(&id).is_some() { + if existing_claims.iter().any(|c| c.id == id) { eprintln!("Error: Claim with ID '{id}' already exists"); return ExitCode::from(3); } @@ -243,14 +294,12 @@ async fn handle_claims_create( updated_at: None, }; - claims_file.add(claim); - - if let Err(e) = claims_file.save(&path) { - eprintln!("Error saving claims file: {e}"); + if let Err(e) = episteme.ingest_authored_claim(&claim).await { + eprintln!("Error saving claim to StemeDB: {e}"); return ExitCode::from(3); } - println!("Created claim '{id}' in {}", path.display()); + println!("Created claim '{id}' in StemeDB"); ExitCode::SUCCESS } @@ -258,22 +307,20 @@ async fn handle_claims_list( category: Option, status: Option, format: String, - _config: &AphoriaConfig, + config: &AphoriaConfig, ) -> ExitCode { let root = match project_root() { Ok(r) => r, Err(code) => return code, }; - let path = ClaimsFile::default_path(&root); - let claims_file = match ClaimsFile::load(&path) { - Ok(f) => f, - Err(e) => { - eprintln!("Error loading claims file: {e}"); - return ExitCode::from(3); - } - }; - let mut claims: Vec<&AuthoredClaim> = claims_file.claims.iter().collect(); + let (_episteme, all_claims): (LocalEpisteme, Vec) = + match load_claims_with_migration(&root, config).await { + Ok(v) => v, + Err(code) => return code, + }; + + let mut claims: Vec<&AuthoredClaim> = all_claims.iter().collect(); // Filter by category if let Some(ref cat) = category { @@ -282,12 +329,10 @@ async fn handle_claims_list( // Filter by status if let Some(ref st) = status { - let target = match st.to_lowercase().as_str() { - "active" => ClaimStatus::Active, - "deprecated" => ClaimStatus::Deprecated, - "superseded" => ClaimStatus::Superseded, - other => { - eprintln!("Unknown status: {other}. Expected: active, deprecated, superseded"); + let target = match ClaimStatus::parse(st) { + Ok(s) => s, + Err(e) => { + eprintln!("Error: {e}"); return ExitCode::from(3); } }; @@ -343,20 +388,18 @@ async fn handle_claims_explain( claim_id: Option, output: Option, format: String, - _config: &AphoriaConfig, + config: &AphoriaConfig, ) -> ExitCode { let root = match project_root() { Ok(r) => r, Err(code) => return code, }; - let path = ClaimsFile::default_path(&root); - let claims_file = match ClaimsFile::load(&path) { - Ok(f) => f, - Err(e) => { - eprintln!("Error loading claims file: {e}"); - return ExitCode::from(3); - } - }; + + let (_episteme, all_claims): (LocalEpisteme, Vec) = + match load_claims_with_migration(&root, config).await { + Ok(v) => v, + Err(code) => return code, + }; let project_name = root .file_name() @@ -365,7 +408,7 @@ async fn handle_claims_explain( let content = if let Some(ref id) = claim_id { // Single claim - let claim = match claims_file.find_by_id(id) { + let claim = match all_claims.iter().find(|c| c.id == *id) { Some(c) => c, None => { eprintln!("Claim not found: {id}"); @@ -388,7 +431,7 @@ async fn handle_claims_explain( } else { // All claims if format == "json" { - match claims_explain::render_claims_json(&claims_file.claims, &project_name) { + match claims_explain::render_claims_json(&all_claims, &project_name) { Ok(json) => json, Err(e) => { eprintln!("Error rendering claims: {e}"); @@ -396,7 +439,7 @@ async fn handle_claims_explain( } } } else { - claims_explain::render_claims_markdown(&claims_file.claims, &project_name) + claims_explain::render_claims_markdown(&all_claims, &project_name) } }; @@ -431,7 +474,7 @@ async fn handle_claims_update( evidence: Vec, category: Option, value: Option, - _config: &AphoriaConfig, + config: &AphoriaConfig, ) -> ExitCode { // Validate tier if provided if let Some(ref t) = tier { @@ -445,53 +488,54 @@ async fn handle_claims_update( Ok(r) => r, Err(code) => return code, }; - let path = ClaimsFile::default_path(&root); - let mut claims_file = match ClaimsFile::load(&path) { - Ok(f) => f, - Err(e) => { - eprintln!("Error loading claims file: {e}"); + + let (episteme, existing_claims): (LocalEpisteme, Vec) = + match load_claims_with_migration(&root, config).await { + Ok(v) => v, + Err(code) => return code, + }; + + let original = match existing_claims.iter().find(|c| c.id == id) { + Some(c) => c, + None => { + eprintln!("Error: Claim not found: {id}"); return ExitCode::from(3); } }; let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); - let result = claims_file.update(&id, |c| { - if let Some(p) = provenance { - c.provenance = p; - } - if let Some(i) = invariant { - c.invariant = i; - } - if let Some(con) = consequence { - c.consequence = con; - } - if let Some(t) = tier { - c.authority_tier = t.to_lowercase(); - } - if !evidence.is_empty() { - for e in evidence { - if !c.evidence.contains(&e) { - c.evidence.push(e); - } + // Clone and apply updates (append-only: creates a new assertion) + let mut updated = original.clone(); + if let Some(p) = provenance { + updated.provenance = p; + } + if let Some(i) = invariant { + updated.invariant = i; + } + if let Some(con) = consequence { + updated.consequence = con; + } + if let Some(t) = tier { + updated.authority_tier = t.to_lowercase(); + } + if !evidence.is_empty() { + for e in evidence { + if !updated.evidence.contains(&e) { + updated.evidence.push(e); } } - if let Some(cat) = category { - c.category = cat; - } - if let Some(v) = value { - c.value = AuthoredValue::parse(&v); - } - c.updated_at = Some(now); - }); - - if let Err(e) = result { - eprintln!("Error: {e}"); - return ExitCode::from(3); } + if let Some(cat) = category { + updated.category = cat; + } + if let Some(v) = value { + updated.value = AuthoredValue::parse(&v); + } + updated.updated_at = Some(now); - if let Err(e) = claims_file.save(&path) { - eprintln!("Error saving claims file: {e}"); + if let Err(e) = episteme.ingest_authored_claim(&updated).await { + eprintln!("Error saving updated claim to StemeDB: {e}"); return ExitCode::from(3); } @@ -510,23 +554,21 @@ async fn handle_claims_supersede( tier: Option, evidence: Vec, by: Option, - _config: &AphoriaConfig, + config: &AphoriaConfig, ) -> ExitCode { let root = match project_root() { Ok(r) => r, Err(code) => return code, }; - let path = ClaimsFile::default_path(&root); - let mut claims_file = match ClaimsFile::load(&path) { - Ok(f) => f, - Err(e) => { - eprintln!("Error loading claims file: {e}"); - return ExitCode::from(3); - } - }; + + let (episteme, existing_claims): (LocalEpisteme, Vec) = + match load_claims_with_migration(&root, config).await { + Ok(v) => v, + Err(code) => return code, + }; // Get the old claim to copy fields from - let old_claim = match claims_file.find_by_id(&old_id) { + let old_claim = match existing_claims.iter().find(|c| c.id == old_id) { Some(c) => c.clone(), None => { eprintln!("Claim not found: {old_id}"); @@ -538,7 +580,7 @@ async fn handle_claims_supersede( let actual_new_id = new_id.unwrap_or_else(|| format!("{old_id}-v2")); // Check for duplicate - if claims_file.find_by_id(&actual_new_id).is_some() { + if existing_claims.iter().any(|c| c.id == actual_new_id) { eprintln!("Error: Claim with ID '{actual_new_id}' already exists"); return ExitCode::from(3); } @@ -565,17 +607,22 @@ async fn handle_claims_supersede( status: ClaimStatus::Active, supersedes: Some(old_id.clone()), created_by: by.unwrap_or(old_claim.created_by.clone()), - created_at: now, + created_at: now.clone(), updated_at: None, }; - if let Err(e) = claims_file.supersede(&old_id, new_claim) { - eprintln!("Error: {e}"); + // Mark old claim as superseded (append-only: ingest a new assertion) + let mut superseded_old = old_claim; + superseded_old.status = ClaimStatus::Superseded; + superseded_old.updated_at = Some(now); + + if let Err(e) = episteme.ingest_authored_claim(&superseded_old).await { + eprintln!("Error marking old claim as superseded: {e}"); return ExitCode::from(3); } - if let Err(e) = claims_file.save(&path) { - eprintln!("Error saving claims file: {e}"); + if let Err(e) = episteme.ingest_authored_claim(&new_claim).await { + eprintln!("Error creating new claim: {e}"); return ExitCode::from(3); } @@ -583,35 +630,35 @@ async fn handle_claims_supersede( ExitCode::SUCCESS } -async fn handle_claims_deprecate(id: String, reason: String, _config: &AphoriaConfig) -> ExitCode { +async fn handle_claims_deprecate(id: String, reason: String, config: &AphoriaConfig) -> ExitCode { let root = match project_root() { Ok(r) => r, Err(code) => return code, }; - let path = ClaimsFile::default_path(&root); - let mut claims_file = match ClaimsFile::load(&path) { - Ok(f) => f, - Err(e) => { - eprintln!("Error loading claims file: {e}"); + + let (episteme, existing_claims): (LocalEpisteme, Vec) = + match load_claims_with_migration(&root, config).await { + Ok(v) => v, + Err(code) => return code, + }; + + let original = match existing_claims.iter().find(|c| c.id == id) { + Some(c) => c, + None => { + eprintln!("Error: Claim not found: {id}"); return ExitCode::from(3); } }; let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); - // Update the claim with deprecation info - let result = claims_file.update(&id, |c| { - c.status = ClaimStatus::Deprecated; - c.updated_at = Some(format!("{now} (deprecated: {reason})")); - }); + // Append-only deprecation: ingest a new assertion with deprecated status + let mut deprecated = original.clone(); + deprecated.status = ClaimStatus::Deprecated; + deprecated.updated_at = Some(format!("{now} (deprecated: {reason})")); - if let Err(e) = result { - eprintln!("Error: {e}"); - return ExitCode::from(3); - } - - if let Err(e) = claims_file.save(&path) { - eprintln!("Error saving claims file: {e}"); + if let Err(e) = episteme.ingest_authored_claim(&deprecated).await { + eprintln!("Error deprecating claim in StemeDB: {e}"); return ExitCode::from(3); } @@ -760,7 +807,7 @@ async fn handle_formalize_marker( tier: String, evidence: Vec, by: String, - _config: &AphoriaConfig, + config: &AphoriaConfig, ) -> ExitCode { let root = match project_root() { Ok(r) => r, @@ -774,16 +821,17 @@ async fn handle_formalize_marker( return ExitCode::from(3); } - // Check for ID collision - let claims_path = ClaimsFile::default_path(&root); - if let Ok(existing_claims) = ClaimsFile::load(&claims_path) { - if existing_claims.find_by_id(&claim_id).is_some() { - eprintln!("Error: Claim ID '{}' already exists", claim_id); - eprintln!( - "Use a different ID or update the existing claim with 'aphoria claims update'." - ); - return ExitCode::from(3); - } + // Check for ID collision against StemeDB claims + let (episteme, existing_claims): (LocalEpisteme, Vec) = + match load_claims_with_migration(&root, config).await { + Ok(v) => v, + Err(code) => return code, + }; + + if existing_claims.iter().any(|c| c.id == claim_id) { + eprintln!("Error: Claim ID '{}' already exists", claim_id); + eprintln!("Use a different ID or update the existing claim with 'aphoria claims update'."); + return ExitCode::from(3); } // Load pending markers @@ -884,20 +932,9 @@ async fn handle_formalize_marker( updated_at: None, }; - // Add to claims file - let claims_path = ClaimsFile::default_path(&root); - let mut claims_file = match ClaimsFile::load(&claims_path) { - Ok(f) => f, - Err(e) => { - eprintln!("Error loading claims file: {e}"); - return ExitCode::from(3); - } - }; - - claims_file.add(claim); - - if let Err(e) = claims_file.save(&claims_path) { - eprintln!("Error saving claims file: {e}"); + // Ingest into StemeDB + if let Err(e) = episteme.ingest_authored_claim(&claim).await { + eprintln!("Error saving claim to StemeDB: {e}"); return ExitCode::from(3); } @@ -1076,10 +1113,12 @@ impl ImportReport { ImportAction::Skipped => "SKIP ", ImportAction::Overwritten => "UPDATE", }; - let reason_str = detail.reason.as_ref() - .map(|r| format!(" ({})", r)) - .unwrap_or_default(); - output.push_str(&format!(" {} {} {}{}\n", symbol, action_str, detail.claim_id, reason_str)); + let reason_str = + detail.reason.as_ref().map(|r| format!(" ({})", r)).unwrap_or_default(); + output.push_str(&format!( + " {} {} {}{}\n", + symbol, action_str, detail.claim_id, reason_str + )); } } @@ -1190,7 +1229,8 @@ created_at = "2024-12-15T10:00:00Z" # status - active (default), deprecated, superseded # supersedes - ID of claim this replaces # updated_at - ISO 8601 timestamp of last update -"#.to_string() +"# + .to_string() } /// Validates all claims before import. @@ -1318,6 +1358,82 @@ fn validate_imported_claims( ValidationResult { errors, warnings } } +async fn handle_claims_export( + output: Option, + category: Option, + status: Option, + format: String, + config: &AphoriaConfig, +) -> ExitCode { + let root = match project_root() { + Ok(r) => r, + Err(code) => return code, + }; + + let (_episteme, all_claims): (LocalEpisteme, Vec) = + match load_claims_with_migration(&root, config).await { + Ok(v) => v, + Err(code) => return code, + }; + + // Apply filters + let mut claims = all_claims; + if let Some(ref cat) = category { + claims.retain(|c| c.category == *cat); + } + if let Some(ref st) = status { + match ClaimStatus::parse(st) { + Ok(target) => claims.retain(|c| c.status == target), + Err(e) => { + eprintln!("Error: {e}"); + return ExitCode::from(3); + } + } + } + + match format.as_str() { + "toml" => { + let output_path = output.unwrap_or_else(|| ClaimsFile::default_path(&root)); + let claims_file = ClaimsFile::from_claims(claims); + if let Err(e) = claims_file.save(&output_path) { + eprintln!("Error writing TOML: {e}"); + return ExitCode::from(3); + } + println!("Exported {} claims to {}", claims_file.len(), output_path.display()); + } + "json" => { + let envelope = serde_json::json!({ + "type": "claims_export", + "total": claims.len(), + "claims": claims + }); + match serde_json::to_string_pretty(&envelope) { + Ok(json) => { + if let Some(ref out_path) = output { + if let Err(e) = std::fs::write(out_path, &json) { + eprintln!("Error writing JSON: {e}"); + return ExitCode::from(3); + } + println!("Exported {} claims to {}", claims.len(), out_path.display()); + } else { + println!("{json}"); + } + } + Err(e) => { + eprintln!("Error serializing claims: {e}"); + return ExitCode::from(3); + } + } + } + _ => { + eprintln!("Error: Invalid format '{format}'. Use: toml or json"); + return ExitCode::from(3); + } + } + + ExitCode::SUCCESS +} + async fn handle_claims_import(options: ImportOptions, _config: &AphoriaConfig) -> ExitCode { use aphoria::claims_file::ClaimsFile; use aphoria::AuthoredClaim; @@ -1564,15 +1680,13 @@ async fn handle_claims_import(options: ImportOptions, _config: &AphoriaConfig) - // Output report in requested format match format.as_str() { - "json" => { - match report.format_json() { - Ok(json) => println!("{}", json), - Err(e) => { - eprintln!("Error formatting JSON output: {}", e); - return ExitCode::from(3); - } + "json" => match report.format_json() { + Ok(json) => println!("{}", json), + Err(e) => { + eprintln!("Error formatting JSON output: {}", e); + return ExitCode::from(3); } - } + }, "table" => { println!("{}", report.format_table(dry_run)); } diff --git a/applications/aphoria/src/handlers/extractors.rs b/applications/aphoria/src/handlers/extractors.rs index bff61a3..b4efc0f 100644 --- a/applications/aphoria/src/handlers/extractors.rs +++ b/applications/aphoria/src/handlers/extractors.rs @@ -29,10 +29,9 @@ pub async fn handle_extractor_command( match command { ExtractorCommands::Validate => handle_validate(config).await, - ExtractorCommands::Test { - extractor_name, - file, - } => handle_test(extractor_name, file, config).await, + ExtractorCommands::Test { extractor_name, file } => { + handle_test(extractor_name, file, config).await + } ExtractorCommands::Stats => handle_extractor_stats(&store, config), @@ -117,10 +116,7 @@ async fn handle_validate(config: &AphoriaConfig) -> ExitCode { // Build claim index by concept_path let mut claim_index: HashMap> = HashMap::new(); for claim in &claims { - claim_index - .entry(claim.concept_path.clone()) - .or_default() - .push(claim.id.clone()); + claim_index.entry(claim.concept_path.clone()).or_default().push(claim.id.clone()); } // Load extractors from config @@ -143,10 +139,7 @@ async fn handle_validate(config: &AphoriaConfig) -> ExitCode { if let Some(claim_ids) = claim_index.get(subject) { println!("✅ {name}"); println!(" Subject: {subject}"); - println!( - " Matches: claim {} (concept_path: {subject})", - claim_ids.join(", ") - ); + println!(" Matches: claim {} (concept_path: {subject})", claim_ids.join(", ")); println!(); valid_count += 1; } else { @@ -160,11 +153,7 @@ async fn handle_validate(config: &AphoriaConfig) -> ExitCode { println!(" Did you mean:"); for suggestion in &suggestions { if let Some(claim_ids) = claim_index.get(suggestion) { - println!( - " - {} (claim {})", - suggestion, - claim_ids.join(", ") - ); + println!(" - {} (claim {})", suggestion, claim_ids.join(", ")); } } } @@ -245,11 +234,7 @@ async fn handle_test( println!("Testing: {extractor_name}"); // Find extractor in config - let extractor = config - .extractors - .declarative - .iter() - .find(|e| e.name == extractor_name); + let extractor = config.extractors.declarative.iter().find(|e| e.name == extractor_name); let extractor = match extractor { Some(e) => e, @@ -307,11 +292,7 @@ async fn handle_test( println!(); println!("Troubleshooting:"); println!(" 1. Verify pattern matches code syntax:"); - println!( - " grep -E '{}' {}", - extractor.pattern, - file_path.display() - ); + println!(" grep -E '{}' {}", extractor.pattern, file_path.display()); println!(" 2. Check file has the expected code"); println!(" 3. Test pattern in regex tester (e.g., regex101.com)"); println!(); diff --git a/applications/aphoria/src/handlers/utils.rs b/applications/aphoria/src/handlers/utils.rs index 32c2769..19cd9cc 100644 --- a/applications/aphoria/src/handlers/utils.rs +++ b/applications/aphoria/src/handlers/utils.rs @@ -88,11 +88,7 @@ pub async fn handle_install_claude(dry_run: bool, force: bool) -> ExitCode { // Create target directory if it doesn't exist if let Err(e) = std::fs::create_dir_all(&target_dir) { - eprintln!( - "Error: Cannot create {}: {}", - target_dir.display(), - e - ); + eprintln!("Error: Cannot create {}: {}", target_dir.display(), e); return ExitCode::from(1); } @@ -116,10 +112,7 @@ pub async fn handle_install_claude(dry_run: bool, force: bool) -> ExitCode { } let file_desc = if skill.has_subdirs { - format!( - "{} files: SKILL.md + subdirectories", - stats.files_copied - ) + format!("{} files: SKILL.md + subdirectories", stats.files_copied) } else { format!("{} file", stats.files_copied) }; @@ -144,11 +137,7 @@ pub async fn handle_install_claude(dry_run: bool, force: bool) -> ExitCode { } let total = installed + updated; - safe_println!( - "Installed {} skill(s) to {}\n", - total, - target_dir.display() - ); + safe_println!("Installed {} skill(s) to {}\n", total, target_dir.display()); safe_println!("Available skills:"); safe_println!(" Core Development:"); @@ -163,7 +152,9 @@ pub async fn handle_install_claude(dry_run: bool, force: bool) -> ExitCode { safe_println!(" Claim & Extractor Creation:"); safe_println!(" /aphoria-claims - Author and review claims from diffs"); safe_println!(" /aphoria-suggest - Suggest new claims from patterns"); - safe_println!(" /aphoria-custom-extractor-creator - Create declarative/programmatic extractors"); + safe_println!( + " /aphoria-custom-extractor-creator - Create declarative/programmatic extractors" + ); safe_println!(); safe_println!(" Quality & Optimization:"); safe_println!(" /aphoria-self-review - Run self-review SOP on scan results"); @@ -302,10 +293,7 @@ fn scan_for_aphoria_skills( let has_subdirs = std::fs::read_dir(&entry_path) .ok() .and_then(|entries| { - entries - .filter_map(Result::ok) - .any(|e| e.path().is_dir()) - .then_some(true) + entries.filter_map(Result::ok).any(|e| e.path().is_dir()).then_some(true) }) .unwrap_or(false); @@ -353,11 +341,7 @@ fn copy_dir_contents( for entry in entries { let entry = entry.map_err(|e| { - AphoriaError::SkillInstall(format!( - "Cannot read entry in {}: {}", - source.display(), - e - )) + AphoriaError::SkillInstall(format!("Cannot read entry in {}: {}", source.display(), e)) })?; let source_path = entry.path(); @@ -413,12 +397,8 @@ fn needs_update(source: &Path, target: &Path) -> bool { return true; } - let source_modified = std::fs::metadata(&source_md) - .and_then(|m| m.modified()) - .ok(); - let target_modified = std::fs::metadata(&target_md) - .and_then(|m| m.modified()) - .ok(); + let source_modified = std::fs::metadata(&source_md).and_then(|m| m.modified()).ok(); + let target_modified = std::fs::metadata(&target_md).and_then(|m| m.modified()).ok(); match (source_modified, target_modified) { (Some(src), Some(tgt)) => src > tgt, diff --git a/applications/aphoria/src/lib.rs b/applications/aphoria/src/lib.rs index a2afa5b..2230e3a 100644 --- a/applications/aphoria/src/lib.rs +++ b/applications/aphoria/src/lib.rs @@ -65,6 +65,7 @@ pub mod pending_markers; pub mod scope; pub use episteme::{ compute_tier_breakdown, current_timestamp, current_timestamp_millis, AphoriaAuthorityLens, + LocalEpisteme, }; mod error; pub mod eval; @@ -93,8 +94,13 @@ pub mod walker; // Public re-exports pub use baseline::{set_baseline, show_diff}; -pub use bridge::{authored_claim_to_assertion, observation_to_assertion, observation_to_tier}; -pub use claim_store::{ClaimFilter, ClaimStore, ImportStats as ClaimImportStats, TomlClaimStore}; +pub use bridge::{ + assertion_to_authored_claim, authored_claim_to_assertion, observation_to_assertion, + observation_to_tier, +}; +pub use claim_store::{ + ClaimFilter, ClaimStore, EpistemeClaimStore, ImportStats as ClaimImportStats, +}; pub use community::{ compute_pattern_hash, AnonymizedObservation, CommunityClaimDef, CommunityExtractor, CommunityExtractorLoader, CommunityExtractorProvenance, CommunityObjectValue, PatternAggregate, diff --git a/applications/aphoria/src/policy_ops.rs b/applications/aphoria/src/policy_ops.rs index 1a20ac1..82e883c 100644 --- a/applications/aphoria/src/policy_ops.rs +++ b/applications/aphoria/src/policy_ops.rs @@ -270,7 +270,7 @@ pub async fn acknowledge( description: format!("Conflict acknowledged: {}", args.reason), }; - episteme.ingest_claims(&[claim]).await?; + episteme.ingest_observations(&[claim]).await?; episteme.shutdown().await; // Log expiry info if set @@ -328,7 +328,7 @@ pub async fn bless(args: BlessArgs, config: &AphoriaConfig) -> Result<(), Aphori description: args.reason.clone(), }; - episteme.ingest_claims(&[claim]).await?; + episteme.ingest_observations(&[claim]).await?; episteme.shutdown().await; info!(concept_path = %args.concept_path, predicate = %args.predicate, "Pattern blessed as standard"); @@ -374,7 +374,7 @@ pub async fn update(args: UpdateArgs, config: &AphoriaConfig) -> Result<(), Apho description: format!("Intentional change: {}", args.reason), }; - episteme.ingest_claims(&[claim]).await?; + episteme.ingest_observations(&[claim]).await?; episteme.shutdown().await; info!( @@ -661,7 +661,7 @@ pub async fn import_acks( description: format!("Imported: {}", entry.reason), }; - episteme.ingest_claims(&[claim]).await?; + episteme.ingest_observations(&[claim]).await?; stats.imported += 1; } diff --git a/applications/aphoria/src/report/mod.rs b/applications/aphoria/src/report/mod.rs index 5778922..343756b 100644 --- a/applications/aphoria/src/report/mod.rs +++ b/applications/aphoria/src/report/mod.rs @@ -8,17 +8,17 @@ mod json; mod markdown; +pub mod observations; mod sarif; mod table; -pub mod observations; pub mod verify_json; pub mod verify_table; pub use json::JsonReport; pub use markdown::MarkdownReport; +pub use observations::format_observations; pub use sarif::SarifReport; pub use table::TableReport; -pub use observations::format_observations; pub use verify_json::format_verify_json; pub use verify_table::format_verify_table; diff --git a/applications/aphoria/src/report/observations.rs b/applications/aphoria/src/report/observations.rs index 7aff606..cae66d0 100644 --- a/applications/aphoria/src/report/observations.rs +++ b/applications/aphoria/src/report/observations.rs @@ -15,10 +15,7 @@ pub fn format_observations(result: &ScanResult) -> String { let mut output = String::new(); // Section 1: List all observations - output.push_str(&format!( - "\nObservations Created ({} total):\n\n", - result.observations.len() - )); + output.push_str(&format!("\nObservations Created ({} total):\n\n", result.observations.len())); if result.observations.is_empty() { output.push_str(" (No observations created - check if extractors matched any code)\n\n"); @@ -64,8 +61,7 @@ pub fn format_observations(result: &ScanResult) -> String { .observations .iter() .filter(|obs| { - tail_path(&obs.concept_path).unwrap_or_else(|| obs.concept_path.clone()) - == tail + tail_path(&obs.concept_path).unwrap_or_else(|| obs.concept_path.clone()) == tail }) .collect(); @@ -81,9 +77,7 @@ pub fn format_observations(result: &ScanResult) -> String { concept_path )); output.push_str(&format!(" Tail-path needed: {}\n", tail)); - output.push_str( - " Issue: No extractor produced this concept_path\n", - ); + output.push_str(" Issue: No extractor produced this concept_path\n"); output.push('\n'); } } @@ -100,8 +94,8 @@ mod tests { use crate::types::Observation; use crate::verify::{AuditVerdict, VerifyReport, VerifyResult, VerifySummary}; use crate::AuthoredClaim; - use stemedb_core::types::ObjectValue; use std::path::PathBuf; + use stemedb_core::types::ObjectValue; fn make_observation(concept_path: &str, predicate: &str, value: bool) -> Observation { Observation { @@ -117,7 +111,7 @@ mod tests { } fn make_claim(id: &str, concept_path: &str) -> AuthoredClaim { - use crate::types::authored_claim::{AuthoredValue, ComparisonMode, ClaimStatus}; + use crate::types::authored_claim::{AuthoredValue, ClaimStatus, ComparisonMode}; AuthoredClaim { id: id.to_string(), @@ -149,11 +143,7 @@ mod tests { #[test] fn test_format_observations_without_verify() { let mut result = ScanResult::stub(&PathBuf::from("."), "table"); - result.observations = vec![make_observation( - "msgqueue/queue/max_size", - "bounded", - false, - )]; + result.observations = vec![make_observation("msgqueue/queue/max_size", "bounded", false)]; let output = format_observations(&result); assert!(output.contains("msgqueue/queue/max_size")); @@ -165,11 +155,7 @@ mod tests { #[test] fn test_format_observations_with_matching_claims() { let mut result = ScanResult::stub(&PathBuf::from("."), "table"); - result.observations = vec![make_observation( - "msgqueue/queue/max_size", - "bounded", - false, - )]; + result.observations = vec![make_observation("msgqueue/queue/max_size", "bounded", false)]; let verify = VerifyReport { results: vec![VerifyResult { @@ -215,11 +201,8 @@ mod tests { #[test] fn test_format_observations_with_scheme_in_concept_path() { let mut result = ScanResult::stub(&PathBuf::from("."), "table"); - result.observations = vec![make_observation( - "code://rust/myapp/tls/cert_verification", - "enabled", - true, - )]; + result.observations = + vec![make_observation("code://rust/myapp/tls/cert_verification", "enabled", true)]; let output = format_observations(&result); assert!(output.contains("code://rust/myapp/tls/cert_verification")); diff --git a/applications/aphoria/src/scan/scanner.rs b/applications/aphoria/src/scan/scanner.rs index 3091f40..befcbc6 100644 --- a/applications/aphoria/src/scan/scanner.rs +++ b/applications/aphoria/src/scan/scanner.rs @@ -29,6 +29,9 @@ pub(super) struct ConflictCheckResult { pub conflicts: Vec, pub drifts: Vec, pub observations_recorded: usize, + /// Authored claims fetched from StemeDB during persistent mode. + /// Empty in ephemeral mode (caller should fall back to TOML). + pub authored_claims: Vec, } /// Run a scan on the specified project. @@ -88,14 +91,26 @@ pub async fn run_scan(args: ScanArgs, config: &AphoriaConfig) -> Result = authored_claims + .into_iter() + .filter(|c| c.status == crate::types::ClaimStatus::Active) + .collect(); + if active_claims.is_empty() { + None + } else { + info!(claims = active_claims.len(), "Verifying authored claims"); + Some(verify::verify_claims(&active_claims, &all_claims)) + } } }; @@ -167,7 +182,12 @@ async fn check_conflicts( let conflicts = check_conflicts_ephemeral(all_claims, project_root, config, args.debug).await?; // Ephemeral mode never records observations or detects drift (intentionally stateless) - Ok(ConflictCheckResult { conflicts, drifts: vec![], observations_recorded: 0 }) + Ok(ConflictCheckResult { + conflicts, + drifts: vec![], + observations_recorded: 0, + authored_claims: vec![], + }) } ScanMode::Persistent => { check_conflicts_persistent(all_claims, project_root, config, args.sync).await @@ -237,7 +257,7 @@ async fn check_conflicts_persistent( let mut episteme = LocalEpisteme::open(config, project_root).await?; if !all_claims.is_empty() { - episteme.ingest_claims(all_claims).await?; + episteme.ingest_observations(all_claims).await?; } // Build authoritative corpus from bundled sources AND imported Trust Packs @@ -344,12 +364,8 @@ async fn check_conflicts_persistent( // Aggregate observations into pattern records (Phase 4 - community corpus) if config.corpus.aggregation_enabled && should_persist_locally && !novel_claims.is_empty() { let project_hash = compute_project_hash(project_root); - if let Err(e) = aggregate_observations_to_patterns( - &novel_claims, - &episteme, - &project_hash, - ) - .await + if let Err(e) = + aggregate_observations_to_patterns(&novel_claims, &episteme, &project_hash).await { // Log error but don't fail the scan tracing::warn!(error = %e, "Failed to aggregate observations to patterns"); @@ -362,10 +378,19 @@ async fn check_conflicts_persistent( 0 }; + // Fetch authored claims from StemeDB before shutdown (avoids opening DB again later) + let authored_claims = match episteme.fetch_authored_claims().await { + Ok(claims) => claims, + Err(e) => { + info!(error = %e, "Could not fetch authored claims from StemeDB"); + vec![] + } + }; + // Shut down Episteme episteme.shutdown().await; - Ok(ConflictCheckResult { conflicts, drifts, observations_recorded }) + Ok(ConflictCheckResult { conflicts, drifts, observations_recorded, authored_claims }) } /// Generate a unique scan ID. @@ -394,7 +419,8 @@ pub async fn extract_claims( info!(files_found = files.len(), "Project walk complete"); // Extract claims from files (ephemeral mode - no LLM) - let claims = extract_claims_from_files(&files, config, ScanMode::Ephemeral, &project_root).await?; + let claims = + extract_claims_from_files(&files, config, ScanMode::Ephemeral, &project_root).await?; info!(claims_extracted = claims.len(), "Extraction complete"); Ok(claims) @@ -498,8 +524,7 @@ async fn aggregate_observations_to_patterns( info!( observations = observations.len(), - project_hash, - "Aggregating observations into community patterns" + project_hash, "Aggregating observations into community patterns" ); // Get stores @@ -516,11 +541,8 @@ async fn aggregate_observations_to_patterns( // Wildcard the project path for community sharing let wildcarded_subject = crate::community::wildcard_project_path(&obs.concept_path); - let key = ( - wildcarded_subject, - obs.predicate.clone(), - CommunityObjectValue::from(&obs.value), - ); + let key = + (wildcarded_subject, obs.predicate.clone(), CommunityObjectValue::from(&obs.value)); patterns.entry(key).or_default().push(obs); } @@ -580,6 +602,42 @@ async fn aggregate_observations_to_patterns( Ok(()) } +/// Load authored claims for scan verification. +/// +/// Attempts to load claims from StemeDB first. If StemeDB has no authored claims +/// but a `claims.toml` file exists, falls back to TOML (migration path). +async fn load_authored_claims_for_scan( + project_root: &Path, + config: &AphoriaConfig, +) -> Result, AphoriaError> { + // Only try StemeDB if the database directory already exists. + // This avoids creating storage in ephemeral mode and avoids lock + // contention when persistent mode already holds the DB open. + let db_dir = project_root.join(".aphoria/db"); + if db_dir.exists() { + match LocalEpisteme::open(config, project_root).await { + Ok(mut episteme) => { + let stemedb_claims = episteme.fetch_authored_claims().await?; + episteme.shutdown().await; + if !stemedb_claims.is_empty() { + return Ok(stemedb_claims); + } + } + Err(e) => { + info!(error = %e, "Could not open StemeDB for claim loading, falling back to TOML"); + } + } + } + + // Fallback: load from claims.toml if it exists (migration path) + let claims_path = ClaimsFile::default_path(project_root); + let claims_file = ClaimsFile::load(&claims_path)?; + if !claims_file.is_empty() { + info!(claims = claims_file.len(), "Loaded authored claims from claims.toml"); + } + Ok(claims_file.claims) +} + /// Compute stable hash of project identity for deduplication. /// /// Uses project root path to create a unique identifier that diff --git a/applications/aphoria/src/scan/walker.rs b/applications/aphoria/src/scan/walker.rs index b18f6d3..16317ba 100644 --- a/applications/aphoria/src/scan/walker.rs +++ b/applications/aphoria/src/scan/walker.rs @@ -150,7 +150,9 @@ pub async fn extract_claims_from_files( /// The vocabulary is built from the configured corpus sources (RFC, OWASP, Vendor) /// to constrain LLM output to concept paths that match authority subjects, enabling /// proper conflict detection. -async fn create_llm_extractor(config: &AphoriaConfig) -> Result, AphoriaError> { +async fn create_llm_extractor( + config: &AphoriaConfig, +) -> Result, AphoriaError> { let client = match GeminiClient::new(&config.llm)? { Some(c) => c, None => return Ok(None), diff --git a/applications/aphoria/src/tests/day3_debugging.rs b/applications/aphoria/src/tests/day3_debugging.rs index 0d0d451..393b8ba 100644 --- a/applications/aphoria/src/tests/day3_debugging.rs +++ b/applications/aphoria/src/tests/day3_debugging.rs @@ -50,9 +50,7 @@ async fn test_show_observations_flag_populates_observations() { show_observations: true, }; - let result = run_scan(args, &AphoriaConfig::default()) - .await - .expect("scan should succeed"); + let result = run_scan(args, &AphoriaConfig::default()).await.expect("scan should succeed"); // Verify: Observations field exists and can be accessed // This test just verifies the field is accessible without panicking @@ -98,9 +96,7 @@ async fn test_show_observations_formatting() { show_observations: true, }; - let result = run_scan(args, &AphoriaConfig::default()) - .await - .expect("scan should succeed"); + let result = run_scan(args, &AphoriaConfig::default()).await.expect("scan should succeed"); // Test that format_observations can be called without panicking use crate::report::format_observations; @@ -114,8 +110,7 @@ async fn test_show_observations_disabled_by_default() { let temp_dir = TempDir::new().expect("create temp dir"); let project_root = temp_dir.path(); - std::fs::write(project_root.join("test.rs"), "fn main() {}") - .expect("write test.rs"); + std::fs::write(project_root.join("test.rs"), "fn main() {}").expect("write test.rs"); std::fs::write( project_root.join("Cargo.toml"), r#" @@ -140,9 +135,7 @@ async fn test_show_observations_disabled_by_default() { show_observations: false, // Explicitly disabled }; - let result = run_scan(args, &AphoriaConfig::default()) - .await - .expect("scan should succeed"); + let result = run_scan(args, &AphoriaConfig::default()).await.expect("scan should succeed"); // Observations should still be populated (the flag only affects display) // This test verifies the scan completes successfully regardless of flag value @@ -155,8 +148,7 @@ async fn test_show_observations_with_verify_report() { let temp_dir = TempDir::new().expect("create temp dir"); let project_root = temp_dir.path(); - std::fs::write(project_root.join("test.rs"), "fn main() {}") - .expect("write test.rs"); + std::fs::write(project_root.join("test.rs"), "fn main() {}").expect("write test.rs"); std::fs::write( project_root.join("Cargo.toml"), r#" @@ -181,9 +173,7 @@ async fn test_show_observations_with_verify_report() { show_observations: true, }; - let result = run_scan(args, &AphoriaConfig::default()) - .await - .expect("scan should succeed"); + let result = run_scan(args, &AphoriaConfig::default()).await.expect("scan should succeed"); // If verify report exists, format_observations should handle it gracefully use crate::report::format_observations; diff --git a/applications/aphoria/src/tests/golden_path.rs b/applications/aphoria/src/tests/golden_path.rs index 6f066f4..d4b2657 100644 --- a/applications/aphoria/src/tests/golden_path.rs +++ b/applications/aphoria/src/tests/golden_path.rs @@ -41,6 +41,7 @@ async fn test_golden_path_bless_export_import_scan() { description: "All services MUST use mTLS".to_string(), }; + #[allow(deprecated)] episteme.ingest_claims(&[claim]).await.expect("ingest blessed claim"); episteme.shutdown().await; } diff --git a/applications/aphoria/src/tests/scan_basic.rs b/applications/aphoria/src/tests/scan_basic.rs index 27edeb8..3a0f39c 100644 --- a/applications/aphoria/src/tests/scan_basic.rs +++ b/applications/aphoria/src/tests/scan_basic.rs @@ -183,6 +183,7 @@ async fn test_acknowledge_succeeds() { description: "Conflict acknowledged: Internal service".to_string(), }; + #[allow(deprecated)] let result = episteme.ingest_claims(&[claim]).await; episteme.shutdown().await; diff --git a/applications/aphoria/src/types/authored_claim.rs b/applications/aphoria/src/types/authored_claim.rs index 0df7cab..dfba940 100644 --- a/applications/aphoria/src/types/authored_claim.rs +++ b/applications/aphoria/src/types/authored_claim.rs @@ -158,6 +158,8 @@ impl std::fmt::Display for ComparisonMode { #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum ClaimStatus { + /// Claim is a draft (proposed but not yet active). + Draft, /// Claim is active and enforced. #[default] Active, @@ -170,6 +172,7 @@ pub enum ClaimStatus { impl std::fmt::Display for ClaimStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + ClaimStatus::Draft => write!(f, "draft"), ClaimStatus::Active => write!(f, "active"), ClaimStatus::Deprecated => write!(f, "deprecated"), ClaimStatus::Superseded => write!(f, "superseded"), @@ -177,6 +180,21 @@ impl std::fmt::Display for ClaimStatus { } } +impl ClaimStatus { + /// Parse a status string into a `ClaimStatus`. + pub fn parse(s: &str) -> Result { + match s.to_lowercase().as_str() { + "draft" => Ok(ClaimStatus::Draft), + "active" => Ok(ClaimStatus::Active), + "deprecated" => Ok(ClaimStatus::Deprecated), + "superseded" => Ok(ClaimStatus::Superseded), + _ => Err(AphoriaError::Claims(format!( + "Unknown claim status '{s}'. Expected: draft, active, deprecated, superseded" + ))), + } + } +} + /// Parse an authority tier string into a `SourceClass`. /// /// Accepted values: "regulatory", "clinical", "observational", "team_policy", "expert", "community", "anecdotal". @@ -255,11 +273,21 @@ mod tests { #[test] fn test_claim_status_display() { + assert_eq!(ClaimStatus::Draft.to_string(), "draft"); assert_eq!(ClaimStatus::Active.to_string(), "active"); assert_eq!(ClaimStatus::Deprecated.to_string(), "deprecated"); assert_eq!(ClaimStatus::Superseded.to_string(), "superseded"); } + #[test] + fn test_claim_status_parse() { + assert_eq!(ClaimStatus::parse("draft").ok(), Some(ClaimStatus::Draft)); + assert_eq!(ClaimStatus::parse("active").ok(), Some(ClaimStatus::Active)); + assert_eq!(ClaimStatus::parse("deprecated").ok(), Some(ClaimStatus::Deprecated)); + assert_eq!(ClaimStatus::parse("Superseded").ok(), Some(ClaimStatus::Superseded)); + assert!(ClaimStatus::parse("unknown").is_err()); + } + #[test] fn test_authored_value_display() { assert_eq!(AuthoredValue::Bool(true).to_string(), "true"); diff --git a/applications/aphoria/src/types/mod.rs b/applications/aphoria/src/types/mod.rs index f9ec439..a739740 100644 --- a/applications/aphoria/src/types/mod.rs +++ b/applications/aphoria/src/types/mod.rs @@ -96,6 +96,10 @@ pub mod predicates { /// These are assertions imported via `policy import` that should be used for /// conflict detection during scans. pub const AUTHORITATIVE: &str = "authoritative"; + + /// Predicate index key for authored claims stored as assertions. + /// Used to efficiently query all claims ingested via `ingest_authored_claim()`. + pub const AUTHORED_CLAIM: &str = "authored_claim"; } /// Extract the leaf concept (last segment after "//") from a concept path. diff --git a/applications/aphoria/tests/scale_adaptive_test.rs b/applications/aphoria/tests/scale_adaptive_test.rs index a59ac13..6a0f736 100644 --- a/applications/aphoria/tests/scale_adaptive_test.rs +++ b/applications/aphoria/tests/scale_adaptive_test.rs @@ -13,8 +13,8 @@ fn test_micro_team_sees_patterns() { // Micro team with 3 projects, pattern appears in 2 let decision = thresholds.evaluate( - 2, // project_count - 3, // total_projects + 2, // project_count + 3, // total_projects false, // no authority None, ); @@ -33,9 +33,9 @@ fn test_micro_team_regulatory_disabled() { // Micro team with 5 projects, pattern appears in all 5 with RFC match let decision = thresholds.evaluate( - 5, // project_count - 5, // total_projects - true, // has authority + 5, // project_count + 5, // total_projects + true, // has authority Some("rfc://1234"), // RFC scheme ); @@ -50,19 +50,16 @@ fn test_small_team_enables_all_tiers() { // Small team with 10 projects, pattern in 9 with RFC match let decision = thresholds.evaluate( - 9, // project_count - 10, // total_projects - true, // has authority + 9, // project_count + 10, // total_projects + true, // has authority Some("rfc://5246"), // RFC scheme ); // Small tier regulatory: max(5, 0.90*10) = max(5, 9) = 9 // Adoption rate: 9/10 = 90% >= 90% // Should auto-promote to regulatory - assert_eq!( - decision, - PromotionDecision::AutoPromote(SourceClass::Regulatory) - ); + assert_eq!(decision, PromotionDecision::AutoPromote(SourceClass::Regulatory)); } #[test] @@ -71,19 +68,16 @@ fn test_enterprise_maintains_strict_thresholds() { // Enterprise with 1000 projects, pattern in 950 with RFC match let decision = thresholds.evaluate( - 950, // project_count - 1000, // total_projects - true, // has authority + 950, // project_count + 1000, // total_projects + true, // has authority Some("rfc://9110"), // RFC scheme ); // Enterprise tier: max(100, 0.95*1000) = max(100, 950) = 950 // Adoption rate: 950/1000 = 95% >= 95% // Should auto-promote to regulatory (backward compatible behavior) - assert_eq!( - decision, - PromotionDecision::AutoPromote(SourceClass::Regulatory) - ); + assert_eq!(decision, PromotionDecision::AutoPromote(SourceClass::Regulatory)); } #[test] @@ -106,8 +100,8 @@ fn test_adaptive_floor_prevents_noise() { // Micro team with 3 projects, pattern appears in only 1 let decision = thresholds.evaluate( - 1, // project_count - 3, // total_projects + 1, // project_count + 3, // total_projects false, // no authority None, ); @@ -133,8 +127,5 @@ fn test_medium_team_clinical_tier() { // Medium tier clinical: max(10, 0.75*50) = max(10, 37.5) = 38 // Adoption rate: 38/50 = 76% >= 75% // Should auto-promote to clinical - assert_eq!( - decision, - PromotionDecision::AutoPromote(SourceClass::Clinical) - ); + assert_eq!(decision, PromotionDecision::AutoPromote(SourceClass::Clinical)); } diff --git a/applications/aphoria/tests/wiki_import_test.rs b/applications/aphoria/tests/wiki_import_test.rs index d890a09..bed00db 100644 --- a/applications/aphoria/tests/wiki_import_test.rs +++ b/applications/aphoria/tests/wiki_import_test.rs @@ -198,10 +198,7 @@ async fn test_wiki_parser_edge_cases() { let content = "TLS MUST be enabled.\n\n\n\n\nAuthority: RFC 5246"; let patterns = parser.parse(content).expect("parse"); assert_eq!(patterns.len(), 1); - assert!( - patterns[0].authority.is_some(), - "Should find authority within 5 lines after pattern" - ); + assert!(patterns[0].authority.is_some(), "Should find authority within 5 lines after pattern"); // Test: Authority beyond 5 lines after pattern // Pattern at line 0, authority at line 6 (beyond range [0..6)) @@ -239,17 +236,11 @@ async fn test_wiki_import_duplicate_patterns() { std::fs::create_dir_all(&wiki_dir).expect("create wiki dir"); // Write two files with identical patterns - std::fs::write( - wiki_dir.join("file1.md"), - "## TLS\nTLS MUST be enabled.\nAuthority: RFC 5246", - ) - .expect("write file1"); + std::fs::write(wiki_dir.join("file1.md"), "## TLS\nTLS MUST be enabled.\nAuthority: RFC 5246") + .expect("write file1"); - std::fs::write( - wiki_dir.join("file2.md"), - "## TLS\nTLS MUST be enabled.\nAuthority: RFC 5246", - ) - .expect("write file2"); + std::fs::write(wiki_dir.join("file2.md"), "## TLS\nTLS MUST be enabled.\nAuthority: RFC 5246") + .expect("write file2"); let config = AphoriaConfig::default(); let count = import_corpus_from_wiki(&wiki_dir, &config).await.expect("import"); diff --git a/applications/aphoria/uat/2026-02-03-federated-policy-proposal.md b/applications/aphoria/uat/2026-02-03-federated-policy-proposal.md index 8068ced..16dec6e 100644 --- a/applications/aphoria/uat/2026-02-03-federated-policy-proposal.md +++ b/applications/aphoria/uat/2026-02-03-federated-policy-proposal.md @@ -7,6 +7,11 @@ --- +> **Implementation status:** +> - **Trust Pack export/import** -- Implemented and working. Ed25519 signed bundles can be exported from one project and imported into another. Signature verification works. +> - **Live federation** -- Not implemented. There is no remote policy resolution (`policy://` URIs), no org-wide server, and no automatic cross-repo sync. The `policy://` scheme described below is a design proposal only. +> - **Community Pack** -- Not implemented. No published community packs exist. + ## Executive Summary The VulnBank demo proved that Aphoria is a superior **Single-Player Linter** (100% precision vs 20% for pattern matchers). To achieve the StemeDB vision of a "Probabilistic Marketplace," Aphoria must evolve into a **Multiplayer Knowledge Network**. diff --git a/applications/aphoria/vision.md b/applications/aphoria/vision.md index 467ab3c..c1bdd88 100644 --- a/applications/aphoria/vision.md +++ b/applications/aphoria/vision.md @@ -65,6 +65,8 @@ Developer commits code ### The Three Tiers of Knowledge +> **Status: PLANNED** -- The tier system (Policies / Conventions / Observations) with automatic graduation is the target architecture. Today, `authority_tier` exists as a field on `AuthoredClaim` but nothing classifies claims into tiers at scan time, and no code reads `authority_tier` for Lens resolution or enforcement behavior (BLOCK vs FLAG vs silent). Tier-aware resolution is planned for Phase 1 of [gap closure](../../tmp/aphoria-stemedb-gap-closure.md). + ``` ┌─────────────────────────────────────────────────────────────────┐ │ TIER 1: POLICIES (Explicit, Authoritative) │ @@ -103,6 +105,8 @@ Developer commits code **Day 1: Install Aphoria** +> **Status: PLANNED** -- `--org` and `--team` flags, org-level knowledge graph connection, and pre-loaded policies/conventions require the central StemeDB server (Phase 3 of [gap closure](../../tmp/aphoria-stemedb-gap-closure.md)). Today, `aphoria init` creates a local project config only. + ```bash $ aphoria init --org acme --team platform Connected to Acme Engineering knowledge graph @@ -178,6 +182,8 @@ Every scan: - **Checks** against existing policies and conventions - **Syncs** to org knowledge graph +> **Status: PARTIALLY IMPLEMENTED** -- Hosted mode syncs observations and patterns to a remote StemeDB instance. Claims and extractors remain in local TOML files and are NOT synced. Full claim/extractor sync is planned for Phase 3 of [gap closure](../../tmp/aphoria-stemedb-gap-closure.md). + ### 2. Graduate Patterns Through Governance Not every observation becomes a convention. Graduation requires: @@ -215,6 +221,8 @@ Project Level (applies to single project) ### 4. Authority from Evidence +> **Status: PLANNED** -- The 4-level authority ladder is the target design. Today, `authority_tier` is stored as a string field on `AuthoredClaim` but no code reads it for Lens resolution or claim prioritization. Authority-weighted resolution is planned for Phase 1 of [gap closure](../../tmp/aphoria-stemedb-gap-closure.md), where `authority_tier` will map to `SourceClass` and feed into StemeDB's Authority Lens. + Authority isn't title-based - it's **merit-based**. The weight of a pattern comes from the evidence supporting it: ``` @@ -338,6 +346,8 @@ repos: ### Central Knowledge Server +> **Status: PLANNED** -- No `aphoria server` binary exists yet. Org-wide knowledge aggregation is planned for Phase 3 of [gap closure](../../tmp/aphoria-stemedb-gap-closure.md). Today, hosted mode uses a StemeDB API instance for observation sync only. + ```bash # Deploy org-wide knowledge graph aphoria server --org acme --port 18187 @@ -393,3 +403,5 @@ Your organization makes thousands of decisions every day. Most are invisible. Wh Aphoria makes those decisions visible, auditable, and compounding. Install it on day 1. Let it learn as you build. Watch new hires ramp faster. Watch senior knowledge persist after they leave. Watch cross-team consistency emerge naturally. **Your codebase becomes a knowledge graph. Your commits become institutional memory. Your organization gets smarter with every push.** + +> **Status: PARTIALLY IMPLEMENTED** -- Today, observations flow into a local embedded StemeDB instance at `.aphoria/db/`, forming a per-project knowledge graph. Claims remain in a flat TOML file (`claims.toml`) and are not yet stored in StemeDB. Wiring claims through StemeDB is Phase 1 of [gap closure](../../tmp/aphoria-stemedb-gap-closure.md). diff --git a/architecture.md b/architecture.md index d1199bb..ba33a93 100644 --- a/architecture.md +++ b/architecture.md @@ -7,6 +7,8 @@ Episteme is a **Log-Structured, Content-Addressed Knowledge Graph**. Unlike traditional databases that mutate state in place, Episteme appends **Assertions** to an immutable ledger (Merkle DAG). State resolution happens via **Lenses**. +> **Caveat:** Aphoria's scan observations flow through this append-only path today. Aphoria's authored claims (`AuthoredClaim`) do not -- they are stored in a mutable TOML file (`.aphoria/claims.toml`) and bypass the WAL/Merkle DAG entirely. Routing claims through StemeDB as proper Assertions is a planned gap closure. + To solve the O(N) read latency of conflict resolution, Episteme employs a **Materialized View** layer that pre-calculates the "Current Truth" for standard lenses. ### High-Level Data Flow diff --git a/crates/stemedb-api/src/dto/aphoria/types.rs b/crates/stemedb-api/src/dto/aphoria/types.rs index 7430057..819bf70 100644 --- a/crates/stemedb-api/src/dto/aphoria/types.rs +++ b/crates/stemedb-api/src/dto/aphoria/types.rs @@ -376,6 +376,9 @@ pub enum ComparisonModeDto { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "lowercase")] pub enum ClaimStatusDto { + /// Claim is a draft (proposed but not yet active). + #[serde(rename = "draft")] + Draft, /// Claim is active and enforced. #[serde(rename = "active")] Active, diff --git a/crates/stemedb-api/src/error.rs b/crates/stemedb-api/src/error.rs index 2db856d..f993304 100644 --- a/crates/stemedb-api/src/error.rs +++ b/crates/stemedb-api/src/error.rs @@ -99,7 +99,8 @@ impl IntoResponse for ApiError { ApiError::Timeout(_) => ("timeout", "protection"), }; - metrics::counter!("stemedb_errors_total", "type" => error_type, "layer" => layer).increment(1); + metrics::counter!("stemedb_errors_total", "type" => error_type, "layer" => layer) + .increment(1); let (status, code, message) = match self { ApiError::InvalidHex(ref msg) => (StatusCode::BAD_REQUEST, "INVALID_HEX", msg.clone()), @@ -134,9 +135,7 @@ impl IntoResponse for ApiError { ApiError::RateLimited(ref msg) => { (StatusCode::TOO_MANY_REQUESTS, "RATE_LIMITED", msg.clone()) } - ApiError::Timeout(ref msg) => { - (StatusCode::REQUEST_TIMEOUT, "TIMEOUT", msg.clone()) - } + ApiError::Timeout(ref msg) => (StatusCode::REQUEST_TIMEOUT, "TIMEOUT", msg.clone()), }; let error_response = ErrorResponse { error: message, code: code.to_string() }; diff --git a/crates/stemedb-api/src/extractors.rs b/crates/stemedb-api/src/extractors.rs index 206563a..9e5f266 100644 --- a/crates/stemedb-api/src/extractors.rs +++ b/crates/stemedb-api/src/extractors.rs @@ -103,9 +103,9 @@ where // Use non-strict mode to accept both encoded (%5B%5D) and literal ([]) brackets. // Browsers URL-encode brackets, so sources[] becomes sources%5B%5D in the query string. let config = serde_qs::Config::new(5, false); - let value = config.deserialize_str(query).map_err(|err| QsQueryRejection { - message: err.to_string(), - })?; + let value = config + .deserialize_str(query) + .map_err(|err| QsQueryRejection { message: err.to_string() })?; Ok(QsQuery(value)) } } @@ -138,9 +138,8 @@ mod tests { #[tokio::test] async fn test_bracket_notation() { - let uri: Uri = "http://example.com?sources[]=rfc&sources[]=community&limit=10" - .parse() - .unwrap(); + let uri: Uri = + "http://example.com?sources[]=rfc&sources[]=community&limit=10".parse().unwrap(); let mut parts = Request::builder().uri(uri).body(()).unwrap().into_parts().0; let QsQuery(params): QsQuery = @@ -163,13 +162,7 @@ mod tests { let QsQuery(params): QsQuery = QsQuery::from_request_parts(&mut parts, &()).await.unwrap(); - assert_eq!( - params, - TestParams { - sources: None, - limit: Some(5), - } - ); + assert_eq!(params, TestParams { sources: None, limit: Some(5) }); } #[tokio::test] @@ -180,13 +173,7 @@ mod tests { let QsQuery(params): QsQuery = QsQuery::from_request_parts(&mut parts, &()).await.unwrap(); - assert_eq!( - params, - TestParams { - sources: None, - limit: None, - } - ); + assert_eq!(params, TestParams { sources: None, limit: None }); } #[tokio::test] diff --git a/crates/stemedb-api/src/handlers/admin.rs b/crates/stemedb-api/src/handlers/admin.rs index 5a912b1..c205a11 100644 --- a/crates/stemedb-api/src/handlers/admin.rs +++ b/crates/stemedb-api/src/handlers/admin.rs @@ -58,7 +58,8 @@ pub async fn decay_trust_ranks( "method" => "POST", "path" => "/v1/admin/decay-trust-ranks", "status" => "200" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(Json(DecayTrustRanksResponse { decayed_count, diff --git a/crates/stemedb-api/src/handlers/aphoria/claims.rs b/crates/stemedb-api/src/handlers/aphoria/claims.rs index 007dcd8..0ac1cde 100644 --- a/crates/stemedb-api/src/handlers/aphoria/claims.rs +++ b/crates/stemedb-api/src/handlers/aphoria/claims.rs @@ -76,6 +76,7 @@ pub async fn list_claims( if let Some(ref status) = req.status { let status_lower = status.to_lowercase(); filtered.retain(|c| match c.status { + ClaimStatus::Draft => status_lower == "draft", ClaimStatus::Active => status_lower == "active", ClaimStatus::Deprecated => status_lower == "deprecated", ClaimStatus::Superseded => status_lower == "superseded", @@ -491,9 +492,6 @@ pub async fn coverage( // ============================================================================ /// Acknowledge a claim violation (adds to `.aphoria/acks.toml`). -/// -/// Note: This is a placeholder. Full ACK file implementation is in -/// `applications/aphoria/src/ack_file.rs` but not yet integrated. #[utoipa::path( post, path = "/v1/aphoria/claims/acknowledge", @@ -519,15 +517,41 @@ pub async fn acknowledge_violation( )); } - // TODO: Load AckFile, add acknowledgment, save - // For now, just return success + // Load the ack file + let ack_path = project_root.join(aphoria::ack_file::ACK_FILE_PATH); + let mut ack_file = aphoria::ack_file::AckFile::load(&ack_path).map_err(|e| { + error!(error = %e, "Failed to load ack file"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to load ack file: {e}")) + })?; + + // Create the acknowledgment entry + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| { + error!(error = %e, "System clock error"); + (StatusCode::INTERNAL_SERVER_ERROR, "System clock error".to_string()) + })? + .as_secs(); + let now_iso = format_timestamp(now); + + let ack_entry = aphoria::ack_file::AckEntry { + path: req.claim_id.clone(), + reason: req.reason.clone(), + expires: req.expires_at, + created: now_iso, + by: Some(req.acknowledged_by.clone()), + }; + + // Add and save + ack_file.add(ack_entry); + ack_file.save(&ack_path).map_err(|e| { + error!(error = %e, "Failed to save ack file"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to save ack file: {e}")) + })?; Ok(Json(AcknowledgeViolationResponse { success: true, - message: format!( - "Violation for claim '{}' acknowledged (Note: ACK file integration pending)", - req.claim_id - ), + message: format!("Violation for claim '{}' acknowledged successfully", req.claim_id), })) } @@ -577,6 +601,7 @@ fn comparison_mode_to_dto(mode: ComparisonMode) -> ComparisonModeDto { fn claim_status_to_dto(status: ClaimStatus) -> ClaimStatusDto { match status { + ClaimStatus::Draft => ClaimStatusDto::Draft, ClaimStatus::Active => ClaimStatusDto::Active, ClaimStatus::Deprecated => ClaimStatusDto::Deprecated, ClaimStatus::Superseded => ClaimStatusDto::Superseded, @@ -589,10 +614,12 @@ fn parse_comparison_mode(s: &str) -> Result Ok(ComparisonMode::NotEquals), "present" => Ok(ComparisonMode::Present), "absent" => Ok(ComparisonMode::Absent), + "contains" => Ok(ComparisonMode::Contains), + "not_contains" => Ok(ComparisonMode::NotContains), _ => Err(( StatusCode::BAD_REQUEST, format!( - "Invalid comparison mode '{}'. Expected: equals, not_equals, present, absent", + "Invalid comparison mode '{}'. Expected: equals, not_equals, present, absent, contains, not_contains", s ), )), diff --git a/crates/stemedb-api/src/handlers/aphoria/corpus.rs b/crates/stemedb-api/src/handlers/aphoria/corpus.rs index 7be2fba..8446444 100644 --- a/crates/stemedb-api/src/handlers/aphoria/corpus.rs +++ b/crates/stemedb-api/src/handlers/aphoria/corpus.rs @@ -72,8 +72,9 @@ pub async fn get_corpus( for (_key, value) in pairs { // Deserialize assertion let assertion: stemedb_core::types::Assertion = - stemedb_core::serde::deserialize(&value) - .map_err(|e| ApiError::Internal(format!("Failed to deserialize assertion: {}", e)))?; + stemedb_core::serde::deserialize(&value).map_err(|e| { + ApiError::Internal(format!("Failed to deserialize assertion: {}", e)) + })?; // Extract metadata let metadata: Option = assertion diff --git a/crates/stemedb-api/src/handlers/api_keys.rs b/crates/stemedb-api/src/handlers/api_keys.rs index b292e42..94b8923 100644 --- a/crates/stemedb-api/src/handlers/api_keys.rs +++ b/crates/stemedb-api/src/handlers/api_keys.rs @@ -124,7 +124,8 @@ pub async fn create_api_key( "method" => "POST", "path" => "/v1/admin/api-keys", "status" => "201" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(( StatusCode::CREATED, @@ -220,7 +221,8 @@ pub async fn revoke_api_key( "method" => "DELETE", "path" => "/v1/admin/api-keys/{id}", "status" => "200" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(Json(RevokeApiKeyResponse { revoked: true, key_hash: key_hash_hex })) } @@ -314,7 +316,8 @@ pub async fn rotate_api_key( "method" => "POST", "path" => "/v1/admin/api-keys/{id}/rotate", "status" => "200" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(Json(RotateApiKeyResponse { new_key: new_raw_key, @@ -383,7 +386,8 @@ pub async fn update_api_key( "method" => "PATCH", "path" => "/v1/admin/api-keys/{id}", "status" => "200" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(Json(UpdateApiKeyResponse { updated: true, key_hash: key_hash_hex, enabled: req.enabled })) } diff --git a/crates/stemedb-api/src/handlers/audit.rs b/crates/stemedb-api/src/handlers/audit.rs index c66a65e..9c81d86 100644 --- a/crates/stemedb-api/src/handlers/audit.rs +++ b/crates/stemedb-api/src/handlers/audit.rs @@ -122,7 +122,8 @@ pub async fn list_audits( "method" => "GET", "path" => "/v1/audit/queries", "status" => "200" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(Json(QueryAuditListResponse { audits: audit_responses, total_count })) } @@ -163,7 +164,8 @@ pub async fn get_audit( "method" => "GET", "path" => "/v1/audit/query/{id}", "status" => "200" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(Json(QueryAuditResponse::from(audit))) } diff --git a/crates/stemedb-api/src/handlers/circuit_breaker.rs b/crates/stemedb-api/src/handlers/circuit_breaker.rs index f56e828..3ddf345 100644 --- a/crates/stemedb-api/src/handlers/circuit_breaker.rs +++ b/crates/stemedb-api/src/handlers/circuit_breaker.rs @@ -135,7 +135,8 @@ pub async fn reset_circuit( "method" => "POST", "path" => "/v1/admin/circuit-breaker/reset", "status" => "200" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(Json(ResetCircuitResponse { agent_id: request.agent_id, diff --git a/crates/stemedb-api/src/handlers/concepts.rs b/crates/stemedb-api/src/handlers/concepts.rs index 7fee4e2..01aa8a1 100644 --- a/crates/stemedb-api/src/handlers/concepts.rs +++ b/crates/stemedb-api/src/handlers/concepts.rs @@ -137,7 +137,8 @@ pub async fn resolve_alias( "method" => "GET", "path" => "/v1/concepts/resolve", "status" => "200" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(Json(ResolveAliasResponse { input_path: params.path, resolved_paths })) } diff --git a/crates/stemedb-api/src/handlers/epoch.rs b/crates/stemedb-api/src/handlers/epoch.rs index 232c426..2b04707 100644 --- a/crates/stemedb-api/src/handlers/epoch.rs +++ b/crates/stemedb-api/src/handlers/epoch.rs @@ -79,7 +79,8 @@ pub async fn create_epoch( Json(req): Json, ) -> Result<(StatusCode, Json)> { let start = std::time::Instant::now(); - metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/epoch").increment(1); + metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/epoch") + .increment(1); // Convert DTO to internal Epoch type let epoch = dto_to_epoch(req)?; @@ -102,7 +103,8 @@ pub async fn create_epoch( "method" => "POST", "path" => "/v1/epoch", "status" => "201" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok((StatusCode::CREATED, Json(response))) } diff --git a/crates/stemedb-api/src/handlers/escalation.rs b/crates/stemedb-api/src/handlers/escalation.rs index 1d64d1d..4f3066a 100644 --- a/crates/stemedb-api/src/handlers/escalation.rs +++ b/crates/stemedb-api/src/handlers/escalation.rs @@ -136,7 +136,8 @@ pub async fn resolve_escalation( "method" => "POST", "path" => "/v1/admin/escalations/{id}/resolve", "status" => "200" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(StatusCode::OK) } else { diff --git a/crates/stemedb-api/src/handlers/gold_standard.rs b/crates/stemedb-api/src/handlers/gold_standard.rs index 17bfc9d..87a83f6 100644 --- a/crates/stemedb-api/src/handlers/gold_standard.rs +++ b/crates/stemedb-api/src/handlers/gold_standard.rs @@ -99,7 +99,8 @@ pub async fn create_gold_standard( "method" => "POST", "path" => "/v1/admin/gold-standards", "status" => "201" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(( StatusCode::CREATED, @@ -166,7 +167,8 @@ pub async fn remove_gold_standard( "method" => "DELETE", "path" => "/v1/admin/gold-standards/{subject}/{predicate}", "status" => "200" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(Json(serde_json::json!({ "subject": subject, @@ -271,7 +273,8 @@ pub async fn verify_agent( "method" => "POST", "path" => "/v1/admin/verify-agent", "status" => "200" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(Json(VerificationResult { subject: req.subject, diff --git a/crates/stemedb-api/src/handlers/health.rs b/crates/stemedb-api/src/handlers/health.rs index 10b8ef2..7f91569 100644 --- a/crates/stemedb-api/src/handlers/health.rs +++ b/crates/stemedb-api/src/handlers/health.rs @@ -3,7 +3,9 @@ use axum::{extract::State, Json}; use tracing::instrument; -use crate::{dto::HealthResponse, error::Result, state::AppState, store_helpers::store_get_with_timeout}; +use crate::{ + dto::HealthResponse, error::Result, state::AppState, store_helpers::store_get_with_timeout, +}; use stemedb_storage::{key_codec, CircuitBreakerStore, QuarantineStore}; /// Health check endpoint. diff --git a/crates/stemedb-api/src/handlers/quarantine.rs b/crates/stemedb-api/src/handlers/quarantine.rs index 13bc87f..e296fbd 100644 --- a/crates/stemedb-api/src/handlers/quarantine.rs +++ b/crates/stemedb-api/src/handlers/quarantine.rs @@ -201,7 +201,8 @@ pub async fn approve_quarantine( "method" => "POST", "path" => "/v1/admin/quarantine/{hash}/approve", "status" => "200" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(Json(QuarantineApproveResponse { hash: hash_hex, @@ -265,7 +266,8 @@ pub async fn reject_quarantine( "method" => "POST", "path" => "/v1/admin/quarantine/{hash}/reject", "status" => "200" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(StatusCode::OK) } diff --git a/crates/stemedb-api/src/handlers/source.rs b/crates/stemedb-api/src/handlers/source.rs index 37b67d9..3930d18 100644 --- a/crates/stemedb-api/src/handlers/source.rs +++ b/crates/stemedb-api/src/handlers/source.rs @@ -59,7 +59,8 @@ pub async fn store_source( Json(req): Json, ) -> Result<(StatusCode, Json)> { let start = std::time::Instant::now(); - metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/source").increment(1); + metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/source") + .increment(1); // Decode base64 content let content = BASE64 @@ -101,7 +102,8 @@ pub async fn store_source( "method" => "POST", "path" => "/v1/source", "status" => "201" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(( StatusCode::CREATED, @@ -185,7 +187,8 @@ pub async fn get_provenance( "method" => "GET", "path" => "/v1/provenance/{hash}", "status" => "200" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok(Json(ProvenanceResponse { hash, diff --git a/crates/stemedb-api/src/handlers/source_registry/handlers.rs b/crates/stemedb-api/src/handlers/source_registry/handlers.rs index 7176f13..ad4edbe 100644 --- a/crates/stemedb-api/src/handlers/source_registry/handlers.rs +++ b/crates/stemedb-api/src/handlers/source_registry/handlers.rs @@ -8,9 +8,7 @@ use axum::{ Json, }; use stemedb_core::types::{SourceRecord, SourceStatus}; -use stemedb_storage::{ - GenericIndexStore, GenericSourceRegistry, IndexStore, SourceRegistry, -}; +use stemedb_storage::{GenericIndexStore, GenericSourceRegistry, IndexStore, SourceRegistry}; use tracing::instrument; use crate::{ @@ -628,7 +626,8 @@ async fn build_impact_response( &subject, &hex::encode(assertion_hash), ); - if let Ok(Some(data)) = store_get_with_timeout(&*state.store, &assertion_key).await { + if let Ok(Some(data)) = store_get_with_timeout(&*state.store, &assertion_key).await + { if let Ok(assertion) = stemedb_core::serde::deserialize::(&data) { diff --git a/crates/stemedb-api/src/handlers/supersede.rs b/crates/stemedb-api/src/handlers/supersede.rs index da02cba..6a72e6b 100644 --- a/crates/stemedb-api/src/handlers/supersede.rs +++ b/crates/stemedb-api/src/handlers/supersede.rs @@ -76,7 +76,8 @@ pub async fn supersede( Json(req): Json, ) -> Result<(StatusCode, Json)> { let start = std::time::Instant::now(); - metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/supersede").increment(1); + metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/supersede") + .increment(1); // Decode and validate hex fields let target_hash = hex::decode_hash_32(&req.target_hash)?; @@ -150,7 +151,8 @@ pub async fn supersede( "method" => "POST", "path" => "/v1/supersede", "status" => "201" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok((StatusCode::CREATED, Json(response))) } diff --git a/crates/stemedb-api/src/handlers/vote.rs b/crates/stemedb-api/src/handlers/vote.rs index 02473e2..8c74092 100644 --- a/crates/stemedb-api/src/handlers/vote.rs +++ b/crates/stemedb-api/src/handlers/vote.rs @@ -39,7 +39,8 @@ pub async fn create_vote( Json(req): Json, ) -> Result<(StatusCode, Json)> { let start = std::time::Instant::now(); - metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/vote").increment(1); + metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/vote") + .increment(1); // Convert DTO to internal Vote type let vote = dto_to_vote(req)?; @@ -64,7 +65,8 @@ pub async fn create_vote( "method" => "POST", "path" => "/v1/vote", "status" => "201" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); Ok((StatusCode::CREATED, Json(response))) } diff --git a/crates/stemedb-api/src/main.rs b/crates/stemedb-api/src/main.rs index a1e538e..78e75f1 100644 --- a/crates/stemedb-api/src/main.rs +++ b/crates/stemedb-api/src/main.rs @@ -24,7 +24,9 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use axum::Extension; use metrics_exporter_prometheus::PrometheusBuilder; -use stemedb_api::{create_router_config, create_router_with_meter_config, AppState, SecurityConfig}; +use stemedb_api::{ + create_router_config, create_router_with_meter_config, AppState, SecurityConfig, +}; use stemedb_ingest::worker::IngestWorker; use stemedb_storage::HybridStore; use stemedb_wal::Journal; @@ -78,8 +80,8 @@ impl Default for Config { tls_cert_path: None, tls_key_path: None, // P5.1: Security defaults - write_body_limit: 1024 * 1024, // 1MB - read_body_limit: 64 * 1024, // 64KB + write_body_limit: 1024 * 1024, // 1MB + read_body_limit: 64 * 1024, // 64KB http_timeout_secs: 30, health_rate_limit_secs: 1, } @@ -247,7 +249,8 @@ async fn main() -> Result<(), Box> { // Build router (with or without metering) with security config let security_config = config.to_security_config(); - info!("P5.1 Security: write_limit={}KB, read_limit={}KB, http_timeout={}s, rate_limit={}/s", + info!( + "P5.1 Security: write_limit={}KB, read_limit={}KB, http_timeout={}s, rate_limit={}/s", security_config.write_body_limit / 1024, security_config.read_body_limit / 1024, security_config.http_timeout_secs, diff --git a/crates/stemedb-api/src/routers.rs b/crates/stemedb-api/src/routers.rs index 91f51fd..38904ea 100644 --- a/crates/stemedb-api/src/routers.rs +++ b/crates/stemedb-api/src/routers.rs @@ -23,8 +23,8 @@ use utoipa_swagger_ui::SwaggerUi; use crate::handlers; use crate::middleware::{ - rate_limit_middleware, AdmissionLayer, ApiKeyAuthConfig, ApiKeyAuthLayer, - CircuitBreakerLayer, MeterLayer, RateLimitState, + rate_limit_middleware, AdmissionLayer, ApiKeyAuthConfig, ApiKeyAuthLayer, CircuitBreakerLayer, + MeterLayer, RateLimitState, }; use crate::state::AppState; use crate::ApiDoc; @@ -47,8 +47,8 @@ pub struct SecurityConfig { impl Default for SecurityConfig { fn default() -> Self { Self { - write_body_limit: 1024 * 1024, // 1MB - read_body_limit: 64 * 1024, // 64KB + write_body_limit: 1024 * 1024, // 1MB + read_body_limit: 64 * 1024, // 64KB http_timeout_secs: 30, health_rate_limit_secs: 1, } @@ -202,7 +202,10 @@ pub fn create_router_with_admission(state: AppState) -> Router { } /// Create the axum router with admission control and custom security configuration. -pub fn create_router_with_admission_config(state: AppState, security_config: SecurityConfig) -> Router { +pub fn create_router_with_admission_config( + state: AppState, + security_config: SecurityConfig, +) -> Router { let cors = CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any); let admission_layer = AdmissionLayer::new(Arc::clone(&state.admission_store)); let meter_layer = MeterLayer::new(Arc::clone(&state.quota_store)); @@ -400,10 +403,7 @@ fn build_api_routes(config: &SecurityConfig) -> Router { .route("/metrics", get(handlers::metrics_handler)) .route("/health", get(handlers::health_check)) .route("/v1/health", get(handlers::health_check)) - .route_layer(middleware::from_fn_with_state( - rate_limit_state, - rate_limit_middleware, - )); + .route_layer(middleware::from_fn_with_state(rate_limit_state, rate_limit_middleware)); // Write endpoints (1MB body limit) let write_routes = Router::new() @@ -476,10 +476,7 @@ fn build_api_routes(config: &SecurityConfig) -> Router { .route("/v1/aphoria/policy/import", post(handlers::import_policy)) .route("/v1/aphoria/scan", post(handlers::scan)) .route("/v1/aphoria/observations", post(handlers::push_observations)) - .route( - "/v1/aphoria/community/observations", - post(handlers::push_community_observations), - ) + .route("/v1/aphoria/community/observations", post(handlers::push_community_observations)) .route("/v1/aphoria/claims/list", post(handlers::list_claims)) .route("/v1/aphoria/claims/create", post(handlers::create_claim)) .route("/v1/aphoria/claims/update", post(handlers::update_claim)) diff --git a/crates/stemedb-api/src/store_helpers.rs b/crates/stemedb-api/src/store_helpers.rs index 1614972..f112dde 100644 --- a/crates/stemedb-api/src/store_helpers.rs +++ b/crates/stemedb-api/src/store_helpers.rs @@ -22,10 +22,7 @@ use crate::error::ApiError; /// /// # Metrics /// Increments `stemedb_operation_timeouts_total{operation="store_get"}` on timeout. -pub async fn store_get_with_timeout( - store: &S, - key: &K, -) -> Result>, ApiError> +pub async fn store_get_with_timeout(store: &S, key: &K) -> Result>, ApiError> where S: stemedb_storage::KVStore, K: AsRef<[u8]> + std::fmt::Debug, @@ -34,7 +31,8 @@ where .await .map_err(|_| { error!(key = ?key, "Store get operation timed out after 5s"); - metrics::counter!("stemedb_operation_timeouts_total", "operation" => "store_get").increment(1); + metrics::counter!("stemedb_operation_timeouts_total", "operation" => "store_get") + .increment(1); ApiError::Timeout("Store get operation exceeded 5s timeout".to_string()) })? .map_err(ApiError::from) @@ -54,11 +52,7 @@ where /// /// # Metrics /// Increments `stemedb_operation_timeouts_total{operation="store_put"}` on timeout. -pub async fn store_put_with_timeout( - store: &S, - key: &K, - value: &V, -) -> Result<(), ApiError> +pub async fn store_put_with_timeout(store: &S, key: &K, value: &V) -> Result<(), ApiError> where S: stemedb_storage::KVStore, K: AsRef<[u8]> + std::fmt::Debug, @@ -68,7 +62,8 @@ where .await .map_err(|_| { error!(key = ?key, "Store put operation timed out after 5s"); - metrics::counter!("stemedb_operation_timeouts_total", "operation" => "store_put").increment(1); + metrics::counter!("stemedb_operation_timeouts_total", "operation" => "store_put") + .increment(1); ApiError::Timeout("Store put operation exceeded 5s timeout".to_string()) })? .map_err(ApiError::from) diff --git a/crates/stemedb-api/tests/e2e_full_pipeline.rs b/crates/stemedb-api/tests/e2e_full_pipeline.rs index 3a41516..fb8d4f7 100644 --- a/crates/stemedb-api/tests/e2e_full_pipeline.rs +++ b/crates/stemedb-api/tests/e2e_full_pipeline.rs @@ -65,7 +65,8 @@ async fn create_test_environment() -> TestEnvironment { Arc::new(Mutex::new(Journal::open(&wal_dir).expect("Failed to open journal for ingest"))); let write_journal = Journal::open(&wal_dir).expect("Failed to open write journal"); let read_journal = Journal::open(&wal_dir).expect("Failed to open read journal"); - let state = stemedb_api::AppState::new(write_journal, read_journal, Arc::clone(&store_arc), None); + let state = + stemedb_api::AppState::new(write_journal, read_journal, Arc::clone(&store_arc), None); TestEnvironment { _temp_dir: temp_dir, state, store: store_arc, journal: journal_arc } } diff --git a/crates/stemedb-storage/src/hybrid_backend.rs b/crates/stemedb-storage/src/hybrid_backend.rs index e7e2419..92fe0b3 100644 --- a/crates/stemedb-storage/src/hybrid_backend.rs +++ b/crates/stemedb-storage/src/hybrid_backend.rs @@ -128,12 +128,14 @@ impl KVStore for HybridStore { metrics::histogram!("stemedb_storage_operation_duration_seconds", "operation" => "get", "backend" => backend_str - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); metrics::counter!("stemedb_storage_operations_total", "operation" => "get", "backend" => backend_str - ).increment(1); + ) + .increment(1); result } @@ -156,12 +158,14 @@ impl KVStore for HybridStore { metrics::histogram!("stemedb_storage_operation_duration_seconds", "operation" => "put", "backend" => backend_str - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); metrics::counter!("stemedb_storage_operations_total", "operation" => "put", "backend" => backend_str - ).increment(1); + ) + .increment(1); result } @@ -184,12 +188,14 @@ impl KVStore for HybridStore { metrics::histogram!("stemedb_storage_operation_duration_seconds", "operation" => "delete", "backend" => backend_str - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); metrics::counter!("stemedb_storage_operations_total", "operation" => "delete", "backend" => backend_str - ).increment(1); + ) + .increment(1); result } @@ -207,12 +213,14 @@ impl KVStore for HybridStore { metrics::histogram!("stemedb_storage_operation_duration_seconds", "operation" => "scan_prefix", "backend" => "both" - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); metrics::counter!("stemedb_storage_operations_total", "operation" => "scan_prefix", "backend" => "both" - ).increment(1); + ) + .increment(1); Ok(results) } else { @@ -230,12 +238,14 @@ impl KVStore for HybridStore { metrics::histogram!("stemedb_storage_operation_duration_seconds", "operation" => "scan_prefix", "backend" => backend_str - ).record(start.elapsed().as_secs_f64()); + ) + .record(start.elapsed().as_secs_f64()); metrics::counter!("stemedb_storage_operations_total", "operation" => "scan_prefix", "backend" => backend_str - ).increment(1); + ) + .increment(1); result }; diff --git a/crates/stemedb-wal/src/journal.rs b/crates/stemedb-wal/src/journal.rs index c5b8f63..f1eadea 100644 --- a/crates/stemedb-wal/src/journal.rs +++ b/crates/stemedb-wal/src/journal.rs @@ -114,7 +114,8 @@ impl Journal { QuarantineError::Io { .. } => "io_error", _ => "other", }; - metrics::counter!("stemedb_wal_write_errors_total", "error" => error_type).increment(1); + metrics::counter!("stemedb_wal_write_errors_total", "error" => error_type) + .increment(1); } } diff --git a/roadmap-archive.md b/roadmap-archive.md index a9c1ba6..24acef0 100644 --- a/roadmap-archive.md +++ b/roadmap-archive.md @@ -391,7 +391,7 @@ *Goal: Type system reflects the real difference. No more pretending grep results are claims.* - [x] **A1.1 Rename ExtractedClaim to Observation**: Updated across all 42 extractors, bridge, scanner, CLI -- [x] **A1.2 Create Claim Type**: `AuthoredClaim` in `types/authored_claim.rs` with provenance/invariant/consequence/authority/evidence/status/supersedes. `ClaimStore` trait + `TomlClaimStore`. `ClaimsFile` TOML persistence in `.aphoria/claims.toml` +- [ ] **A1.2 Create Claim Type**: `AuthoredClaim` in `types/authored_claim.rs` with provenance/invariant/consequence/authority/evidence/status/supersedes. `ClaimStore` trait + `TomlClaimStore`. `ClaimsFile` TOML persistence in `.aphoria/claims.toml` *(Partial: `AuthoredClaim` type and `ClaimsFile` persistence complete. `ClaimStore` trait defined but never implemented — `TODO(A4)` in `claim_store.rs`. All operations bypass it via `ClaimsFile` directly.)* - [x] **A1.3 Update Bridge Tier Mapping**: Observations → Tier 4 (Community), authored claims get tier from `authority_tier` field via `authored_claim_to_assertion()` - [x] **A1.4 Claim File Format**: `.aphoria/claims.toml` with `[[claim]]` TOML arrays, human-readable, version-controllable diff --git a/vision.md b/vision.md index 5a73b7e..48ccd64 100644 --- a/vision.md +++ b/vision.md @@ -21,6 +21,7 @@ When multiple agents observe the world and report different things, traditional Episteme rejects the idea of a single, static "database state." Instead, it models knowledge as a **Probabilistic Marketplace**: - **Assertions are immutable.** Every claim is signed, timestamped, and preserved forever. + > **Status: PARTIALLY IMPLEMENTED.** StemeDB Assertions follow this model. Aphoria's `AuthoredClaim` entries are currently stored in a mutable TOML file (`.aphoria/claims.toml`), not yet routed through the append-only DAG. Bridging claims into StemeDB as Assertions is tracked in the gap closure plan. - **Contradictions coexist.** The database holds disagreement without forcing resolution. - **Lenses resolve at query time.** Different readers can apply different resolution strategies. - **Source authority is structural.** A regulatory filing outweighs a Reddit post by design. @@ -65,6 +66,8 @@ struct Assertion { } ``` +> **Current state:** Aphoria uses a separate `AuthoredClaim` struct with fields like `concept_path`, `predicate`, `value`, `comparison`, `invariant`, and `consequence`. A bridge function (`authored_claim_to_assertion()`) exists to convert between the two representations but is not yet used in the primary claim storage path. Claims are currently persisted in `.aphoria/claims.toml`, not as `Assertion` entries in the DAG. Routing claims through StemeDB is planned. + ## The Source Class Hierarchy Every assertion has a source class that structurally affects resolution weight and decay: diff --git a/what-is-episteme.md b/what-is-episteme.md index 4714024..f7863e2 100644 --- a/what-is-episteme.md +++ b/what-is-episteme.md @@ -2,6 +2,8 @@ **Episteme is a database that stores claims, not facts.** +> **Integration note:** Aphoria (the code policy tool) authors claims, but those claims are currently stored in a local TOML file (`.aphoria/claims.toml`), not in StemeDB's append-only DAG. Aphoria's scan *observations* do flow through StemeDB. Bridging authored claims into the DAG is a planned gap closure -- the architecture below describes the target state. + Traditional databases force you to pick "the right answer." Episteme holds all the answers, tracks who said them and why, and lets you decide how to resolve disagreements at query time. Think of it as **Git for Truth**: just as Git lets developers work on different versions of code and merge them intelligently, Episteme lets AI agents (and humans) contribute different observations about the world and resolve conflicts based on context.