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:
parent
ae7d2ed8b1
commit
422e2d4416
@ -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`)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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**
|
||||
|
||||
|
||||
@ -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."**
|
||||
|
||||
@ -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!");
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]",
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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)");
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -340,7 +340,11 @@ impl ScaleAdaptiveThresholds {
|
||||
if adoption_rate >= reg.min_adoption_rate
|
||||
&& project_count >= min_projects
|
||||
&& (!reg.require_authority
|
||||
|| matches_authority(has_authority_match, authority_scheme, ®.authority_sources))
|
||||
|| matches_authority(
|
||||
has_authority_match,
|
||||
authority_scheme,
|
||||
®.authority_sources,
|
||||
))
|
||||
{
|
||||
return PromotionDecision::AutoPromote(SourceClass::Regulatory);
|
||||
}
|
||||
@ -352,7 +356,11 @@ impl ScaleAdaptiveThresholds {
|
||||
if adoption_rate >= clin.min_adoption_rate
|
||||
&& project_count >= min_projects
|
||||
&& (!clin.require_authority
|
||||
|| matches_authority(has_authority_match, authority_scheme, &clin.authority_sources))
|
||||
|| matches_authority(
|
||||
has_authority_match,
|
||||
authority_scheme,
|
||||
&clin.authority_sources,
|
||||
))
|
||||
{
|
||||
return PromotionDecision::AutoPromote(SourceClass::Clinical);
|
||||
}
|
||||
@ -692,8 +700,8 @@ mod tests {
|
||||
// 3 projects total, pattern in 2 projects (67% adoption)
|
||||
let decision = thresholds.evaluate(2, 3, false, None);
|
||||
|
||||
// Should promote to emerging: max(2, 0.50*3) = 2, adoption = 67% >= 50%
|
||||
assert_eq!(decision, PromotionDecision::RequireReview);
|
||||
// Micro tier has auto_promote: true
|
||||
assert_eq!(decision, PromotionDecision::AutoPromote(SourceClass::Community));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -715,8 +723,8 @@ mod tests {
|
||||
let decision = thresholds.evaluate(3, 3, true, Some("rfc://1234"));
|
||||
|
||||
// Should NOT promote to regulatory (disabled for micro tier)
|
||||
// Should promote to emerging instead
|
||||
assert_eq!(decision, PromotionDecision::RequireReview);
|
||||
// Micro tier has auto_promote: true → AutoPromote(Community)
|
||||
assert_eq!(decision, PromotionDecision::AutoPromote(SourceClass::Community));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -739,8 +747,8 @@ mod tests {
|
||||
let decision = thresholds.evaluate(4, 10, false, None);
|
||||
|
||||
// Small tier emerging: max(2, 0.40*10) = 4, rate = 40%
|
||||
// Should require review
|
||||
assert_eq!(decision, PromotionDecision::RequireReview);
|
||||
// Small tier has auto_promote: true
|
||||
assert_eq!(decision, PromotionDecision::AutoPromote(SourceClass::Community));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -773,10 +781,18 @@ mod tests {
|
||||
assert!(matches_authority(true, Some("rfc://9110"), &["rfc://".into(), "nist://".into()]));
|
||||
|
||||
// NIST source matches regulatory
|
||||
assert!(matches_authority(true, Some("nist://sp800-53"), &["rfc://".into(), "nist://".into()]));
|
||||
assert!(matches_authority(
|
||||
true,
|
||||
Some("nist://sp800-53"),
|
||||
&["rfc://".into(), "nist://".into()]
|
||||
));
|
||||
|
||||
// OWASP doesn't match regulatory
|
||||
assert!(!matches_authority(true, Some("owasp://top-10/a01"), &["rfc://".into(), "nist://".into()]));
|
||||
assert!(!matches_authority(
|
||||
true,
|
||||
Some("owasp://top-10/a01"),
|
||||
&["rfc://".into(), "nist://".into()]
|
||||
));
|
||||
|
||||
// No authority doesn't match when required
|
||||
assert!(!matches_authority(false, None, &["rfc://".into()]));
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)> {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -48,6 +48,7 @@ impl UnboundedResourcesExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn extract_observation(
|
||||
&self,
|
||||
path_segments: &[String],
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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!();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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**.
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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() };
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
),
|
||||
)),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }))
|
||||
}
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }))
|
||||
}
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user