feat(aphoria): wire claims through StemeDB — Gap Closure Phase 1

Claims now flow through StemeDB's append-only knowledge graph instead of
mutable TOML files. This resolves all 6 critical claim-bypass code paths:

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-12 02:02:37 -07:00
parent ae7d2ed8b1
commit 422e2d4416
82 changed files with 1543 additions and 750 deletions

View File

@ -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`)

View File

@ -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

View File

@ -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 |

View File

@ -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**

View File

@ -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."**

View File

@ -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!");

View File

@ -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<AuthoredClaim, crate::AphoriaError> {
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<String> = 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);
}
}
}

View File

@ -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<String>,
/// Filter by authority tier
pub authority_tier: Option<u8>,
/// Filter by authority tier (e.g., "expert", "regulatory").
pub authority_tier: Option<String>,
}
impl ClaimFilter {
/// Check if a claim matches this filter.
pub fn matches(&self, claim: &AuthoredClaim) -> bool {
if let Some(ref cp) = self.concept_path {
if claim.concept_path != *cp {
return false;
}
}
if let Some(ref p) = self.predicate {
if claim.predicate != *p {
return false;
}
}
if let Some(ref at) = self.authority_tier {
if claim.authority_tier != *at {
return false;
}
}
true
}
}
/// Statistics from bulk import operations.
@ -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<bool, AphoriaError>;
/// 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<LocalEpisteme>,
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<LocalEpisteme>, 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<Option<AuthoredClaim>, AphoriaError> {
self.handle.block_on(self.episteme.fetch_authored_claim(concept_path, predicate))
}
fn list_claims(&self, filter: &ClaimFilter) -> Result<Vec<AuthoredClaim>, AphoriaError> {
self.handle.block_on(self.episteme.fetch_authored_claims_filtered(filter))
}
fn delete_claim(&self, concept_path: &str, predicate: &str) -> Result<bool, AphoriaError> {
// Append-only: create a "deprecated" assertion instead of deleting
if let Some(mut claim) = self.load_claim(concept_path, predicate)? {
claim.status = ClaimStatus::Deprecated;
self.save_claim(&claim)?;
Ok(true)
} else {
Ok(false)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{AuthoredClaim, AuthoredValue};
#[test]
fn test_claim_filter_empty_matches_all() {
let filter = ClaimFilter::default();
let claim = make_test_claim("test/path", "enabled", "expert");
assert!(filter.matches(&claim));
}
#[test]
fn test_claim_filter_concept_path() {
let filter =
ClaimFilter { concept_path: Some("test/path".to_string()), ..Default::default() };
let matching = make_test_claim("test/path", "enabled", "expert");
let non_matching = make_test_claim("other/path", "enabled", "expert");
assert!(filter.matches(&matching));
assert!(!filter.matches(&non_matching));
}
#[test]
fn test_claim_filter_predicate() {
let filter = ClaimFilter { predicate: Some("enabled".to_string()), ..Default::default() };
let matching = make_test_claim("test/path", "enabled", "expert");
let non_matching = make_test_claim("test/path", "disabled", "expert");
assert!(filter.matches(&matching));
assert!(!filter.matches(&non_matching));
}
#[test]
fn test_claim_filter_authority_tier() {
let filter =
ClaimFilter { authority_tier: Some("regulatory".to_string()), ..Default::default() };
let matching = make_test_claim("test/path", "enabled", "regulatory");
let non_matching = make_test_claim("test/path", "enabled", "expert");
assert!(filter.matches(&matching));
assert!(!filter.matches(&non_matching));
}
#[test]
fn test_claim_filter_combined() {
let filter = ClaimFilter {
concept_path: Some("test/path".to_string()),
predicate: Some("enabled".to_string()),
authority_tier: Some("expert".to_string()),
};
let matching = make_test_claim("test/path", "enabled", "expert");
let wrong_path = make_test_claim("other/path", "enabled", "expert");
let wrong_pred = make_test_claim("test/path", "disabled", "expert");
let wrong_tier = make_test_claim("test/path", "enabled", "community");
assert!(filter.matches(&matching));
assert!(!filter.matches(&wrong_path));
assert!(!filter.matches(&wrong_pred));
assert!(!filter.matches(&wrong_tier));
}
fn make_test_claim(concept_path: &str, predicate: &str, tier: &str) -> AuthoredClaim {
AuthoredClaim {
id: "test-001".to_string(),
concept_path: concept_path.to_string(),
predicate: predicate.to_string(),
value: AuthoredValue::Bool(true),
comparison: Default::default(),
provenance: "test".to_string(),
invariant: "test".to_string(),
consequence: "test".to_string(),
authority_tier: tier.to_string(),
evidence: vec![],
category: "test".to_string(),
status: ClaimStatus::Active,
supersedes: None,
created_by: "test".to_string(),
created_at: "2026-02-12T00:00:00Z".to_string(),
updated_at: None,
}
}
}

View File

@ -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]",

View File

@ -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<AuthoredClaim>) -> Self {
Self { claims }
}
/// Add a claim entry, deduplicating by ID.
///
/// Warns if an active claim already exists for the same concept_path/predicate.

View File

@ -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<PathBuf>,
/// Filter by category
#[arg(long)]
category: Option<String>,
/// Filter by status (active, deprecated, superseded, draft)
#[arg(long)]
status: Option<String>,
/// Output format: toml or json
#[arg(long, default_value = "toml")]
format: String,
},
/// List pending claim markers
ListMarkers {
/// Filter by status (pending, formalized, rejected)

View File

@ -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(())
}

View File

@ -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
}
}
}

View File

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

View File

@ -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<PathBuf>,
/// Remote Episteme URL (future feature).
pub url: Option<String>,
}
/// Conflict threshold configuration.

View File

@ -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]

View File

@ -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 {

View File

@ -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();

View File

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

View File

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

View File

@ -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, &reg.authority_sources))
|| matches_authority(
has_authority_match,
authority_scheme,
&reg.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()]));

View File

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

View File

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

View File

@ -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<P: AsRef<Path>>(
}
// 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<P: AsRef<Path>>(
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()));
}
}

View File

@ -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<Self, AphoriaError> {
pub async fn open_corpus_db(
corpus_data_dir: &Path,
project_root: &Path,
) -> Result<Self, AphoriaError> {
// 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<PredicateAliasSet> = 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();

View File

@ -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<Vec<AuthoredClaim>, 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<Vec<AuthoredClaim>, 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<Option<AuthoredClaim>, 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<Assertion> {
let hash_hex = hex::encode(hash);

View File

@ -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<usize, AphoriaError> {
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<usize, AphoriaError> {
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<Vec<Assertion>, 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<Vec<Assertion>, AphoriaError> {

View File

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

View File

@ -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)> {

View File

@ -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;

View File

@ -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<T> 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<String> {
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]

View File

@ -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<T> 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<T> fields in struct declarations
let option_fields: Vec<String> = self.field_pattern
.captures_iter(content)
.map(|cap| cap[1].to_string())
.collect();
let option_fields: Vec<String> =
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]

View File

@ -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]

View File

@ -48,6 +48,7 @@ impl UnboundedResourcesExtractor {
}
}
#[allow(clippy::too_many_arguments)]
fn extract_observation(
&self,
path_segments: &[String],

View File

@ -108,7 +108,7 @@ impl GovernanceStore {
) -> Result<Option<ApprovalRequest>, 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.

View File

@ -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<std::path::PathBuf, ExitCode> {
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, ExitCode> {
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<AuthoredClaim>), 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<String>,
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<AuthoredClaim>) =
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<String>,
status: Option<String>,
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<AuthoredClaim>) =
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<String>,
output: Option<std::path::PathBuf>,
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<AuthoredClaim>) =
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<String>,
category: Option<String>,
value: Option<String>,
_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<AuthoredClaim>) =
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<String>,
evidence: Vec<String>,
by: Option<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 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<AuthoredClaim>) =
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<AuthoredClaim>) =
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<String>,
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<AuthoredClaim>) =
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<std::path::PathBuf>,
category: Option<String>,
status: Option<String>,
format: String,
config: &AphoriaConfig,
) -> ExitCode {
let root = match project_root() {
Ok(r) => r,
Err(code) => return code,
};
let (_episteme, all_claims): (LocalEpisteme, Vec<AuthoredClaim>) =
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));
}

View File

@ -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<String, Vec<String>> = 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!();

View File

@ -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,

View File

@ -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,

View File

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

View File

@ -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;

View File

@ -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"));

View File

@ -29,6 +29,9 @@ pub(super) struct ConflictCheckResult {
pub conflicts: Vec<ConflictResult>,
pub drifts: Vec<DriftResult>,
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<crate::types::AuthoredClaim>,
}
/// Run a scan on the specified project.
@ -88,14 +91,26 @@ pub async fn run_scan(args: ScanArgs, config: &AphoriaConfig) -> Result<ScanResu
let total_ms = total_start.elapsed().as_millis() as u64;
// 4. Verify authored claims against observations
// Use claims from StemeDB (persistent mode) or fall back to TOML (ephemeral mode)
let verify_report = {
let claims_path = ClaimsFile::default_path(&project_root);
let claims_file = ClaimsFile::load(&claims_path)?;
if claims_file.is_empty() {
let authored_claims = if !result.authored_claims.is_empty() {
result.authored_claims
} else {
load_authored_claims_for_scan(&project_root, config).await?
};
if authored_claims.is_empty() {
None
} else {
info!(claims = claims_file.len(), "Verifying authored claims");
Some(verify::verify_claims(&claims_file.claims, &all_claims))
let active_claims: Vec<_> = 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<Vec<crate::types::AuthoredClaim>, 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

View File

@ -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<Option<LlmExtractor>, AphoriaError> {
async fn create_llm_extractor(
config: &AphoriaConfig,
) -> Result<Option<LlmExtractor>, AphoriaError> {
let client = match GeminiClient::new(&config.llm)? {
Some(c) => c,
None => return Ok(None),

View File

@ -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;

View File

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

View File

@ -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;

View File

@ -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<Self, AphoriaError> {
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");

View File

@ -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.

View File

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

View File

@ -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");

View File

@ -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**.

View File

@ -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).

View File

@ -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

View File

@ -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,

View File

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

View File

@ -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<TestParams> =
@ -163,13 +162,7 @@ mod tests {
let QsQuery(params): QsQuery<TestParams> =
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<TestParams> =
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]

View File

@ -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,

View File

@ -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<ComparisonMode, (StatusCode, String)
"not_equals" => 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
),
)),

View File

@ -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<serde_json::Value> = assertion

View File

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

View File

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

View File

@ -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,

View File

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

View File

@ -79,7 +79,8 @@ pub async fn create_epoch(
Json(req): Json<CreateEpochRequest>,
) -> Result<(StatusCode, Json<CreateResponse>)> {
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)))
}

View File

@ -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 {

View File

@ -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,

View File

@ -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.

View File

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

View File

@ -59,7 +59,8 @@ pub async fn store_source(
Json(req): Json<StoreSourceRequest>,
) -> Result<(StatusCode, Json<StoreSourceResponse>)> {
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,

View File

@ -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::<stemedb_core::types::Assertion>(&data)
{

View File

@ -76,7 +76,8 @@ pub async fn supersede(
Json(req): Json<SupersedeRequest>,
) -> Result<(StatusCode, Json<SupersedeResponse>)> {
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)))
}

View File

@ -39,7 +39,8 @@ pub async fn create_vote(
Json(req): Json<CreateVoteRequest>,
) -> Result<(StatusCode, Json<CreateResponse>)> {
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)))
}

View File

@ -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<dyn std::error::Error>> {
// 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,

View File

@ -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<AppState> {
.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<AppState> {
.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))

View File

@ -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<S, K>(
store: &S,
key: &K,
) -> Result<Option<Vec<u8>>, ApiError>
pub async fn store_get_with_timeout<S, K>(store: &S, key: &K) -> Result<Option<Vec<u8>>, 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<S, K, V>(
store: &S,
key: &K,
value: &V,
) -> Result<(), ApiError>
pub async fn store_put_with_timeout<S, K, V>(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)

View File

@ -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 }
}

View File

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

View File

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

View File

@ -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

View File

@ -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:

View File

@ -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.