feat: add claims search, promote, stats commands and convergence engine

Adds three new Aphoria CLI commands and supporting infrastructure for
org-pattern alignment and claim tier advancement:

- `aphoria claims search` — find claims by concept pattern, predicate,
  category, or max authority tier (works local and hosted mode)
- `aphoria claims promote` — raise a claim to a higher authority tier by
  creating a superseding claim (append-only; original marked Deprecated)
- `aphoria claims stats` — breakdown of claim counts by tier and status
  for a given concept_path + predicate pair

New modules:
- `convergence.rs` — pure engine comparing local scan observations to
  remote org claims, producing `ConvergenceSuggestion`s at read time
- `types/convergence.rs` — `ConvergenceSuggestion` type with severity
  derived from the driving claim's authority tier
- `types/promotion.rs` — `PromotionRequest` / `PromotionResult` types
- `handlers/promote.rs` — promotion handler; validates tier ordering

Remote client: adds `search_claims` and `claim_stats` methods to
`RemoteClaimStore`, wiring hosted mode for all three new commands.

API (`stemedb-api`): new `/v1/claims/search` and `/v1/claims/stats`
endpoints with DTOs, plus report formatters (JSON/Markdown/SARIF/table)
for search and stats output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jml 2026-02-25 08:21:37 +00:00
parent 4096967c20
commit 200b84751e
36 changed files with 2684 additions and 10 deletions

View File

@ -53,6 +53,7 @@ pub async fn show_diff(config: &AphoriaConfig) -> Result<String, AphoriaError> {
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let result = run_scan(args, config).await?;

View File

@ -265,4 +265,66 @@ pub enum ClaimsCommands {
#[arg(long)]
reason: String,
},
/// Search org patterns from remote StemeDB (requires hosted mode)
Search {
/// Pattern to match concept paths (supports * wildcard, e.g., "code://rust/*")
#[arg(long)]
pattern: Option<String>,
/// Filter by predicate
#[arg(long)]
predicate: Option<String>,
/// Filter by category
#[arg(long)]
category: Option<String>,
/// Maximum tier number to include (0=regulatory ... 5=anecdotal)
#[arg(long)]
max_tier: Option<u8>,
/// Maximum results to return
#[arg(long, default_value = "50")]
limit: usize,
/// Output format: table or json
#[arg(long, default_value = "table")]
format: String,
},
/// Promote a claim to a higher authority tier
Promote {
/// ID of the claim to promote
id: String,
/// Target tier: regulatory, clinical, observational, expert, community, anecdotal
#[arg(long)]
tier: String,
/// Supporting evidence (can be specified multiple times, at least one required)
#[arg(long)]
evidence: Vec<String>,
/// Justification for promotion
#[arg(long)]
reason: String,
/// Your name (identity of the promoter)
#[arg(long)]
by: String,
},
/// Show adoption stats for a claim pattern
Stats {
/// Concept path to query stats for
concept_path: String,
/// Predicate to query stats for
predicate: String,
/// Output format: table or json
#[arg(long, default_value = "table")]
format: String,
},
}

View File

@ -107,6 +107,11 @@ pub enum Commands {
/// Show detailed authority tier breakdown for conflicts
#[arg(long)]
explain_authority: bool,
/// Fetch remote org claims and show where your code diverges from org patterns.
/// Requires hosted mode configuration (aphoria.toml with [hosted] section).
#[arg(long)]
suggest_convergence: bool,
},
/// Manage acknowledgments (mark conflicts as intentional)

View File

@ -0,0 +1,623 @@
//! Convergence engine: compare local observations against remote org patterns.
//!
//! This module answers one question at read time: "Does this project's code
//! agree with what the rest of the org has decided?"
//!
//! The engine is pure — no I/O, no mutation. Feed it a slice of `Observation`s
//! produced by local extractors and a slice of `AuthoredClaim`s fetched from a
//! remote StemeDB instance, and it returns `ConvergenceSuggestion`s wherever the
//! two disagree.
//!
//! # Data flow
//!
//! ```text
//! local scan → [Observation, ...]
//! │
//! ▼
//! compute_convergence_suggestions()
//! │
//! remote fetch → [AuthoredClaim, ...]
//! │
//! ▼
//! [ConvergenceSuggestion, ...] (sorted: Authoritative → Advisory → Informational)
//! ```
use std::collections::HashMap;
use stemedb_core::types::ObjectValue;
use crate::types::authored_claim::{AuthoredClaim, AuthoredValue};
use crate::types::convergence::{ConvergenceSeverity, ConvergenceSuggestion, DriveClaimSummary};
use crate::types::Observation;
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
/// Convert an `ObjectValue` to a human-readable string.
fn object_value_to_string(val: &ObjectValue) -> String {
match val {
ObjectValue::Boolean(b) => b.to_string(),
ObjectValue::Number(n) => n.to_string(),
ObjectValue::Text(s) => s.clone(),
ObjectValue::Reference(r) => r.clone(),
}
}
/// Convert an `AuthoredValue` to a human-readable string.
fn authored_value_to_string(val: &AuthoredValue) -> String {
match val {
AuthoredValue::Bool(b) => b.to_string(),
AuthoredValue::Number(n) => n.to_string(),
AuthoredValue::Text(s) => s.clone(),
}
}
/// Map an authority tier name to its integer number (05).
///
/// This is a local copy so that `convergence` does not need to reach into the
/// private `types::promotion` module.
///
/// | Tier name | Number |
/// |----------------|--------|
/// | regulatory | 0 |
/// | clinical | 1 |
/// | observational | 2 |
/// | team_policy | 2 |
/// | expert | 3 |
/// | community | 4 |
/// | anecdotal / * | 5 |
fn tier_to_number(tier: &str) -> u8 {
match tier.to_lowercase().as_str() {
"regulatory" => 0,
"clinical" => 1,
"observational" | "team_policy" => 2,
"expert" => 3,
"community" => 4,
_ => 5,
}
}
/// Format an authority tier number as a human-readable name.
fn tier_number_to_name(tier: u8) -> &'static str {
match tier {
0 => "Regulatory",
1 => "Clinical",
2 => "Observational",
3 => "Expert",
4 => "Community",
_ => "Anecdotal",
}
}
/// Returns `true` when an `ObjectValue` and an `AuthoredValue` represent
/// different logical values.
///
/// Cross-type comparisons (e.g. `Boolean` vs `Text`) always differ.
fn values_differ(local: &ObjectValue, remote: &AuthoredValue) -> bool {
match (local, remote) {
(ObjectValue::Boolean(b), AuthoredValue::Bool(expected)) => b != expected,
(ObjectValue::Number(n), AuthoredValue::Number(expected)) => {
(n - expected).abs() > f64::EPSILON
}
(ObjectValue::Text(s), AuthoredValue::Text(expected)) => s != expected,
// Cross-type: always differ
_ => true,
}
}
/// Ordering index for severity — lower is more authoritative.
fn severity_order(s: &ConvergenceSeverity) -> u8 {
match s {
ConvergenceSeverity::Authoritative => 0,
ConvergenceSeverity::Advisory => 1,
ConvergenceSeverity::Informational => 2,
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Compare a slice of local observations against a slice of remote org claims.
///
/// Returns convergence suggestions wherever the local code differs from the
/// org pattern, sorted by severity (Authoritative first, then Advisory, then
/// Informational).
///
/// A suggestion is generated when **all** of the following hold:
/// 1. A remote claim shares the same `concept_path` **and** `predicate` as the
/// local observation.
/// 2. The local observed value differs from the remote claim's expected value.
/// 3. The remote claim's tier number is `<= max_suggestion_tier` (defaults to
/// `5`, meaning all tiers are included).
///
/// When multiple claims match the same `(concept_path, predicate)` in the
/// remote, only the most authoritative (lowest tier number) claim drives the
/// suggestion. A single suggestion is emitted per
/// `(concept_path, predicate, file, line)` tuple.
///
/// # Arguments
///
/// * `local_observations` observations produced by local extractors.
/// * `remote_claims` org claims fetched from the remote StemeDB instance.
/// * `max_suggestion_tier` optional upper bound on the tier of claims that
/// generate suggestions. `None` is equivalent to `Some(5)` (all tiers).
///
/// # Returns
///
/// A `Vec<ConvergenceSuggestion>` sorted Authoritative → Advisory →
/// Informational.
pub fn compute_convergence_suggestions(
local_observations: &[Observation],
remote_claims: &[AuthoredClaim],
max_suggestion_tier: Option<u8>,
) -> Vec<ConvergenceSuggestion> {
let max_tier = max_suggestion_tier.unwrap_or(5);
// Pre-compute the tier number for every remote claim once.
let claim_tiers: Vec<u8> =
remote_claims.iter().map(|c| tier_to_number(&c.authority_tier)).collect();
// Deduplication key: (concept_path, predicate, file, line) → suggestion.
// We keep only the suggestion driven by the most authoritative claim.
let mut dedup: HashMap<(String, String, String, usize), ConvergenceSuggestion> = HashMap::new();
for obs in local_observations {
// Count all remote claims that share this (concept_path, predicate) —
// used for `matching_claims_count` regardless of whether they differ.
let matching_count = remote_claims
.iter()
.filter(|c| c.concept_path == obs.concept_path && c.predicate == obs.predicate)
.count();
if matching_count == 0 {
continue;
}
// Among matching claims, find the most authoritative one that differs
// and is within the tier limit.
let best_match = remote_claims
.iter()
.zip(claim_tiers.iter())
.filter(|(c, tier)| {
c.concept_path == obs.concept_path
&& c.predicate == obs.predicate
&& **tier <= max_tier
&& values_differ(&obs.value, &c.value)
})
.min_by_key(|(_, tier)| **tier);
let (driving_claim, org_tier) = match best_match {
Some((claim, tier)) => (claim, *tier),
None => continue, // no differing claim within tier limit
};
let severity = ConvergenceSeverity::from_tier(org_tier);
let suggestion = ConvergenceSuggestion {
concept_path: obs.concept_path.clone(),
predicate: obs.predicate.clone(),
local_value: object_value_to_string(&obs.value),
org_value: authored_value_to_string(&driving_claim.value),
org_tier,
org_tier_name: tier_number_to_name(org_tier).to_string(),
matching_claims_count: matching_count,
driving_claim: Some(DriveClaimSummary {
claim_id: driving_claim.id.clone(),
invariant: driving_claim.invariant.clone(),
consequence: driving_claim.consequence.clone(),
provenance: driving_claim.provenance.clone(),
evidence: driving_claim.evidence.clone(),
}),
severity,
file: obs.file.clone(),
line: obs.line,
};
let key = (obs.concept_path.clone(), obs.predicate.clone(), obs.file.clone(), obs.line);
// Keep only the most authoritative suggestion (lowest tier = lower
// severity_order value).
let replace = dedup
.get(&key)
.map(|existing| {
severity_order(&suggestion.severity) < severity_order(&existing.severity)
})
.unwrap_or(true);
if replace {
dedup.insert(key, suggestion);
}
}
let mut results: Vec<ConvergenceSuggestion> = dedup.into_values().collect();
// Sort: Authoritative first, then Advisory, then Informational.
// Within the same severity bucket, sort by (concept_path, predicate, file,
// line) for deterministic output.
results.sort_by(|a, b| {
severity_order(&a.severity)
.cmp(&severity_order(&b.severity))
.then_with(|| a.concept_path.cmp(&b.concept_path))
.then_with(|| a.predicate.cmp(&b.predicate))
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.line.cmp(&b.line))
});
results
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::types::authored_claim::{ClaimStatus, ComparisonMode};
fn make_claim(
id: &str,
concept_path: &str,
predicate: &str,
value: AuthoredValue,
tier: &str,
) -> AuthoredClaim {
AuthoredClaim {
id: id.to_string(),
concept_path: concept_path.to_string(),
predicate: predicate.to_string(),
value,
comparison: ComparisonMode::Equals,
provenance: format!("test provenance for {id}"),
invariant: format!("invariant for {id}"),
consequence: format!("consequence for {id}"),
authority_tier: tier.to_string(),
evidence: vec!["test-evidence".to_string()],
category: "test".to_string(),
status: ClaimStatus::Active,
supersedes: None,
created_by: "test".to_string(),
created_at: "2025-01-01T00:00:00Z".to_string(),
updated_at: None,
}
}
fn make_observation(
concept_path: &str,
predicate: &str,
value: ObjectValue,
file: &str,
line: usize,
) -> Observation {
Observation {
concept_path: concept_path.to_string(),
predicate: predicate.to_string(),
value,
file: file.to_string(),
line,
matched_text: "test match".to_string(),
confidence: 1.0,
description: "test observation".to_string(),
}
}
// -----------------------------------------------------------------------
// Test: boolean divergence is detected
// -----------------------------------------------------------------------
#[test]
fn test_boolean_divergence_produces_suggestion() {
let observations = vec![make_observation(
"code://rust/tls/cert_verification",
"enabled",
ObjectValue::Boolean(false),
"src/client.rs",
42,
)];
let claims = vec![make_claim(
"tls-cert-verify-001",
"code://rust/tls/cert_verification",
"enabled",
AuthoredValue::Bool(true),
"expert",
)];
let suggestions = compute_convergence_suggestions(&observations, &claims, None);
assert_eq!(suggestions.len(), 1);
let s = &suggestions[0];
assert_eq!(s.concept_path, "code://rust/tls/cert_verification");
assert_eq!(s.predicate, "enabled");
assert_eq!(s.local_value, "false");
assert_eq!(s.org_value, "true");
assert_eq!(s.org_tier, 3);
assert_eq!(s.org_tier_name, "Expert");
assert_eq!(s.severity, ConvergenceSeverity::Advisory);
assert_eq!(s.file, "src/client.rs");
assert_eq!(s.line, 42);
let dc = s.driving_claim.as_ref().expect("driving claim should be present");
assert_eq!(dc.claim_id, "tls-cert-verify-001");
}
// -----------------------------------------------------------------------
// Test: no suggestion when values agree
// -----------------------------------------------------------------------
#[test]
fn test_matching_values_produce_no_suggestion() {
let observations = vec![make_observation(
"code://rust/tls/cert_verification",
"enabled",
ObjectValue::Boolean(true),
"src/client.rs",
10,
)];
let claims = vec![make_claim(
"tls-cert-verify-001",
"code://rust/tls/cert_verification",
"enabled",
AuthoredValue::Bool(true),
"expert",
)];
let suggestions = compute_convergence_suggestions(&observations, &claims, None);
assert!(suggestions.is_empty(), "no suggestion when values agree");
}
// -----------------------------------------------------------------------
// Test: max_suggestion_tier filters out claims above the limit
// -----------------------------------------------------------------------
#[test]
fn test_max_suggestion_tier_filters_high_tier_claims() {
let observations = vec![make_observation(
"code://go/http/timeout",
"set",
ObjectValue::Boolean(false),
"main.go",
7,
)];
// Community claim (tier 4) — should be suppressed when max_tier is 3.
let claims = vec![make_claim(
"http-timeout-001",
"code://go/http/timeout",
"set",
AuthoredValue::Bool(true),
"community",
)];
let suggestions = compute_convergence_suggestions(&observations, &claims, Some(3));
assert!(
suggestions.is_empty(),
"community-tier claim should be suppressed when max_tier=3"
);
// With no tier limit, the suggestion should appear.
let suggestions_all = compute_convergence_suggestions(&observations, &claims, None);
assert_eq!(suggestions_all.len(), 1);
assert_eq!(suggestions_all[0].severity, ConvergenceSeverity::Informational);
}
// -----------------------------------------------------------------------
// Test: deduplication keeps the most authoritative suggestion
// -----------------------------------------------------------------------
#[test]
fn test_deduplication_keeps_highest_authority() {
// Same observation targeted by two conflicting remote claims at different
// tiers. Only the most authoritative (lowest tier) should survive.
let observations = vec![make_observation(
"code://rust/crypto/hash_algorithm",
"value",
ObjectValue::Text("md5".to_string()),
"src/crypto.rs",
5,
)];
let claims = vec![
make_claim(
"crypto-hash-community-001",
"code://rust/crypto/hash_algorithm",
"value",
AuthoredValue::Text("sha256".to_string()),
"community", // tier 4
),
make_claim(
"crypto-hash-regulatory-001",
"code://rust/crypto/hash_algorithm",
"value",
AuthoredValue::Text("sha256".to_string()),
"regulatory", // tier 0 — should win
),
];
let suggestions = compute_convergence_suggestions(&observations, &claims, None);
assert_eq!(suggestions.len(), 1, "should be deduplicated to one suggestion");
let s = &suggestions[0];
assert_eq!(s.org_tier, 0, "most authoritative (regulatory, tier 0) should drive");
assert_eq!(s.severity, ConvergenceSeverity::Authoritative);
let dc = s.driving_claim.as_ref().expect("driving claim present");
assert_eq!(dc.claim_id, "crypto-hash-regulatory-001");
// matching_claims_count reflects ALL claims with this concept_path+predicate.
assert_eq!(s.matching_claims_count, 2);
}
// -----------------------------------------------------------------------
// Test: sort order — Authoritative before Advisory before Informational
// -----------------------------------------------------------------------
#[test]
fn test_sort_order_authoritative_first() {
let observations = vec![
make_observation(
"code://go/http/timeout",
"set",
ObjectValue::Boolean(false),
"main.go",
1,
),
make_observation(
"code://rust/tls/version",
"min_version",
ObjectValue::Text("tls1.0".to_string()),
"src/tls.rs",
10,
),
make_observation(
"code://python/logging/level",
"value",
ObjectValue::Text("DEBUG".to_string()),
"app.py",
3,
),
];
let claims = vec![
// community → Informational
make_claim(
"http-timeout-001",
"code://go/http/timeout",
"set",
AuthoredValue::Bool(true),
"community",
),
// clinical → Authoritative
make_claim(
"tls-version-001",
"code://rust/tls/version",
"min_version",
AuthoredValue::Text("tls1.2".to_string()),
"clinical",
),
// expert → Advisory
make_claim(
"logging-level-001",
"code://python/logging/level",
"value",
AuthoredValue::Text("INFO".to_string()),
"expert",
),
];
let suggestions = compute_convergence_suggestions(&observations, &claims, None);
assert_eq!(suggestions.len(), 3);
assert_eq!(
suggestions[0].severity,
ConvergenceSeverity::Authoritative,
"first item must be Authoritative"
);
assert_eq!(
suggestions[1].severity,
ConvergenceSeverity::Advisory,
"second item must be Advisory"
);
assert_eq!(
suggestions[2].severity,
ConvergenceSeverity::Informational,
"third item must be Informational"
);
}
// -----------------------------------------------------------------------
// Test: number comparison uses epsilon, not exact equality
// -----------------------------------------------------------------------
#[test]
fn test_number_comparison_with_epsilon() {
// Identical values should not trigger a suggestion.
let observations_same = vec![make_observation(
"code://rust/pool/max_size",
"value",
ObjectValue::Number(50.0),
"src/pool.rs",
1,
)];
let claims = vec![make_claim(
"pool-max-001",
"code://rust/pool/max_size",
"value",
AuthoredValue::Number(50.0),
"expert",
)];
let suggestions = compute_convergence_suggestions(&observations_same, &claims, None);
assert!(suggestions.is_empty(), "identical numbers must not diverge");
// Different values (beyond epsilon) should trigger a suggestion.
let observations_diff = vec![make_observation(
"code://rust/pool/max_size",
"value",
ObjectValue::Number(25.0),
"src/pool.rs",
1,
)];
let suggestions_diff = compute_convergence_suggestions(&observations_diff, &claims, None);
assert_eq!(suggestions_diff.len(), 1);
assert_eq!(suggestions_diff[0].local_value, "25");
assert_eq!(suggestions_diff[0].org_value, "50");
}
// -----------------------------------------------------------------------
// Test: cross-type comparison always differs
// -----------------------------------------------------------------------
#[test]
fn test_cross_type_comparison_always_differs() {
let observations = vec![make_observation(
"code://rust/flag",
"enabled",
ObjectValue::Text("true".to_string()), // text, not bool
"src/lib.rs",
1,
)];
let claims = vec![make_claim(
"flag-001",
"code://rust/flag",
"enabled",
AuthoredValue::Bool(true), // bool
"expert",
)];
// Text "true" vs Bool(true) are cross-type — should differ.
let suggestions = compute_convergence_suggestions(&observations, &claims, None);
assert_eq!(suggestions.len(), 1, "cross-type should always differ");
}
// -----------------------------------------------------------------------
// Test: empty inputs produce no suggestions
// -----------------------------------------------------------------------
#[test]
fn test_empty_inputs() {
let suggestions = compute_convergence_suggestions(&[], &[], None);
assert!(suggestions.is_empty());
let obs = vec![make_observation(
"code://rust/flag",
"enabled",
ObjectValue::Boolean(true),
"src/lib.rs",
1,
)];
let suggestions = compute_convergence_suggestions(&obs, &[], None);
assert!(suggestions.is_empty(), "no claims means no suggestions");
let claims = vec![make_claim(
"flag-001",
"code://rust/flag",
"enabled",
AuthoredValue::Bool(true),
"expert",
)];
let suggestions = compute_convergence_suggestions(&[], &claims, None);
assert!(suggestions.is_empty(), "no observations means no suggestions");
}
}

View File

@ -213,6 +213,16 @@ pub async fn handle_claims_command(command: ClaimsCommands, config: &AphoriaConf
ClaimsCommands::Export { output, category, status, format } => {
handle_claims_export(output, category, status, format, config).await
}
ClaimsCommands::Search { pattern, predicate, category, max_tier, limit, format } => {
handle_claims_search(pattern, predicate, category, max_tier, limit, format, config)
.await
}
ClaimsCommands::Promote { id, tier, evidence, reason, by } => {
handle_claims_promote(id, tier, evidence, reason, by, config).await
}
ClaimsCommands::Stats { concept_path, predicate, format } => {
handle_claims_stats(concept_path, predicate, format, config).await
}
}
}
@ -1698,3 +1708,374 @@ async fn handle_claims_import(options: ImportOptions, _config: &AphoriaConfig) -
ExitCode::SUCCESS
}
// ============================================================================
// Search / Promote / Stats Handlers
// ============================================================================
/// Handle `aphoria claims search` — find org patterns from local or remote claims.
async fn handle_claims_search(
pattern: Option<String>,
predicate: Option<String>,
category: Option<String>,
max_tier: Option<u8>,
limit: usize,
format: String,
config: &AphoriaConfig,
) -> ExitCode {
use aphoria::remote::RemoteClaimStore;
// Hosted mode: delegate to the remote store.
if config.hosted.is_enabled() {
let store = match RemoteClaimStore::new(&config.hosted) {
Ok(s) => s,
Err(e) => {
eprintln!("Error connecting to remote StemeDB: {e}");
return ExitCode::from(1);
}
};
let claims = match store.search_claims(
pattern.as_deref(),
predicate.as_deref(),
category.as_deref(),
max_tier,
Some(limit),
) {
Ok(c) => c,
Err(e) => {
eprintln!("Error searching remote claims: {e}");
return ExitCode::from(1);
}
};
return print_claims_table_or_json(&claims, &format);
}
// Local mode: load from StemeDB / TOML and apply filters.
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,
};
let mut claims: Vec<AuthoredClaim> = all_claims;
// Filter by concept path pattern (support simple * wildcard).
if let Some(ref pat) = pattern {
let pat_lower = pat.to_lowercase();
if pat_lower.contains('*') {
// Build a prefix from the part before the first '*'.
let prefix = pat_lower.split('*').next().unwrap_or("").to_string();
let suffix = pat_lower.split('*').next_back().unwrap_or("").to_string();
claims.retain(|c| {
let p = c.concept_path.to_lowercase();
p.starts_with(&prefix) && (suffix.is_empty() || p.ends_with(&suffix))
});
} else {
claims.retain(|c| c.concept_path.to_lowercase().contains(&pat_lower));
}
}
// Filter by predicate.
if let Some(ref pred) = predicate {
claims.retain(|c| c.predicate == *pred);
}
// Filter by category.
if let Some(ref cat) = category {
claims.retain(|c| c.category == *cat);
}
// Filter by max_tier (requires knowing the tier number of each claim).
if let Some(max_t) = max_tier {
claims.retain(|c| {
// Tier numbers: regulatory=0, clinical=1, observational=2, expert=3,
// community=4, anecdotal=5. If we can't parse, keep it (be permissive).
if let Ok(source_class) = aphoria::parse_authority_tier(&c.authority_tier) {
source_class.tier() <= max_t
} else {
true
}
});
}
// Apply limit.
claims.truncate(limit);
print_claims_table_or_json(&claims, &format)
}
/// Render a list of claims as a formatted table or JSON envelope.
fn print_claims_table_or_json(claims: &[AuthoredClaim], format: &str) -> ExitCode {
match format {
"json" => {
let envelope = serde_json::json!({
"type": "claims_search",
"total": claims.len(),
"claims": claims,
});
match serde_json::to_string_pretty(&envelope) {
Ok(json) => println!("{json}"),
Err(e) => {
eprintln!("Error serializing claims: {e}");
return ExitCode::from(1);
}
}
}
"table" => {
if claims.is_empty() {
println!("No claims found.");
return ExitCode::SUCCESS;
}
println!(
"{:<32} {:<40} {:<20} {:<12} {:<10} {:<10}",
"ID", "concept_path", "predicate", "tier", "status", "value"
);
println!("{}", "-".repeat(130));
for claim in claims {
let value_str = match &claim.value {
aphoria::AuthoredValue::Bool(b) => b.to_string(),
aphoria::AuthoredValue::Number(n) => format!("{n}"),
aphoria::AuthoredValue::Text(s) => {
if s.len() > 18 {
format!("{}...", &s[..15])
} else {
s.clone()
}
}
};
let concept_short = if claim.concept_path.len() > 38 {
format!("{}...", &claim.concept_path[..35])
} else {
claim.concept_path.clone()
};
let pred_short = if claim.predicate.len() > 18 {
format!("{}...", &claim.predicate[..15])
} else {
claim.predicate.clone()
};
println!(
"{:<32} {:<40} {:<20} {:<12} {:<10} {:<10}",
&claim.id,
concept_short,
pred_short,
&claim.authority_tier,
claim.status.to_string(),
value_str,
);
}
println!("\n{} claim(s)", claims.len());
}
_ => {
eprintln!("Error: Invalid format '{format}'. Use: table or json");
return ExitCode::from(1);
}
}
ExitCode::SUCCESS
}
/// Handle `aphoria claims promote` — raise a claim to a higher authority tier.
async fn handle_claims_promote(
id: String,
tier: String,
evidence: Vec<String>,
reason: String,
by: String,
_config: &AphoriaConfig,
) -> ExitCode {
use aphoria::PromotionRequest;
let root = match project_root() {
Ok(r) => r,
Err(code) => return code,
};
let request = PromotionRequest {
claim_id: id.clone(),
target_tier: tier,
evidence,
reason,
promoted_by: by,
};
match super::promote::execute_promotion(request, &root) {
Ok(result) => {
if result.success {
println!(
"Promoted {} -> {} ({} -> {})",
result.original_claim_id,
result.new_claim_id,
result.previous_tier,
result.new_tier,
);
ExitCode::SUCCESS
} else {
let msg = result.error.unwrap_or_else(|| "unknown error".to_string());
eprintln!("Error: {msg}");
ExitCode::from(1)
}
}
Err(e) => {
eprintln!("Error promoting claim '{id}': {e}");
ExitCode::from(1)
}
}
}
/// Handle `aphoria claims stats` — show adoption stats for a concept path / predicate pair.
async fn handle_claims_stats(
concept_path: String,
predicate: String,
format: String,
config: &AphoriaConfig,
) -> ExitCode {
use aphoria::remote::RemoteClaimStore;
// Hosted mode: query remote stats endpoint.
if config.hosted.is_enabled() {
let store = match RemoteClaimStore::new(&config.hosted) {
Ok(s) => s,
Err(e) => {
eprintln!("Error connecting to remote StemeDB: {e}");
return ExitCode::from(1);
}
};
let stats = match store.get_claim_stats(&concept_path, &predicate) {
Ok(s) => s,
Err(e) => {
eprintln!("Error fetching claim stats: {e}");
return ExitCode::from(1);
}
};
return print_claim_stats(&stats, &format);
}
// Local mode: compute stats from the local claims file.
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,
};
let matching: Vec<&AuthoredClaim> = all_claims
.iter()
.filter(|c| c.concept_path == concept_path && c.predicate == predicate)
.collect();
// Aggregate local stats.
let mut by_tier: std::collections::HashMap<u8, usize> = std::collections::HashMap::new();
let mut by_status: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut has_authoritative = false;
for claim in &matching {
// Map authority tier to a numeric tier number.
let tier_num =
aphoria::parse_authority_tier(&claim.authority_tier).map(|sc| sc.tier()).unwrap_or(5);
*by_tier.entry(tier_num).or_insert(0) += 1;
*by_status.entry(claim.status.to_string()).or_insert(0) += 1;
if tier_num == 0 {
has_authoritative = true;
}
}
// Most common value: simple frequency count.
let most_common_value = {
let mut freq: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for claim in &matching {
let v = match &claim.value {
aphoria::AuthoredValue::Bool(b) => b.to_string(),
aphoria::AuthoredValue::Number(n) => format!("{n}"),
aphoria::AuthoredValue::Text(s) => s.clone(),
};
*freq.entry(v).or_insert(0) += 1;
}
freq.into_iter().max_by_key(|(_, count)| *count).map(|(v, _)| v)
};
let stats = aphoria::remote::ClaimStatsResult {
concept_path,
predicate,
matching_claims: matching.len(),
by_tier,
by_status,
most_common_value,
has_authoritative_backing: has_authoritative,
};
print_claim_stats(&stats, &format)
}
/// Render `ClaimStatsResult` as table or JSON.
fn print_claim_stats(stats: &aphoria::remote::ClaimStatsResult, format: &str) -> ExitCode {
match format {
"json" => match serde_json::to_string_pretty(stats) {
Ok(json) => {
println!("{json}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Error serializing stats: {e}");
ExitCode::from(1)
}
},
"table" => {
println!("Stats for {}/{}", stats.concept_path, stats.predicate);
println!("{}", "=".repeat(60));
println!(" Matching claims: {}", stats.matching_claims);
println!(
" Authoritative backing: {}",
if stats.has_authoritative_backing { "yes" } else { "no" }
);
if let Some(ref v) = stats.most_common_value {
println!(" Most common value: {v}");
}
if !stats.by_tier.is_empty() {
println!("\n By tier:");
let mut tiers: Vec<(&u8, &usize)> = stats.by_tier.iter().collect();
tiers.sort_by_key(|(t, _)| *t);
for (tier, count) in tiers {
let tier_name = match tier {
0 => "regulatory",
1 => "clinical",
2 => "observational",
3 => "expert",
4 => "community",
_ => "anecdotal",
};
println!(" {tier_name:<14}: {count}");
}
}
if !stats.by_status.is_empty() {
println!("\n By status:");
let mut statuses: Vec<(&String, &usize)> = stats.by_status.iter().collect();
statuses.sort_by_key(|(s, _)| s.as_str());
for (status, count) in statuses {
println!(" {status:<14}: {count}");
}
}
ExitCode::SUCCESS
}
_ => {
eprintln!("Error: Invalid format '{format}'. Use: table or json");
ExitCode::from(1)
}
}
}

View File

@ -15,6 +15,7 @@ mod lifecycle;
mod patterns;
mod policy;
mod policy_ops;
mod promote;
mod research;
mod scan;
mod scope;
@ -44,6 +45,8 @@ pub use policy::*;
#[allow(unused_imports)]
pub use policy_ops::*;
#[allow(unused_imports)]
pub use promote::execute_promotion;
#[allow(unused_imports)]
pub use research::*;
#[allow(unused_imports)]
pub use scan::*;
@ -73,6 +76,7 @@ pub async fn handle_command(command: Commands, config: &AphoriaConfig) -> ExitCo
show_claims,
show_observations,
explain_authority,
suggest_convergence,
} => {
if community_preview {
scan::handle_community_preview(path, config).await
@ -90,6 +94,7 @@ pub async fn handle_command(command: Commands, config: &AphoriaConfig) -> ExitCo
show_claims,
show_observations,
explain_authority,
suggest_convergence,
config,
)
.await
@ -181,6 +186,7 @@ pub async fn handle_command(command: Commands, config: &AphoriaConfig) -> ExitCo
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let observations = match aphoria::run_scan(scan_args, config).await {
@ -350,6 +356,7 @@ async fn gather_explain_data(
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let observations = match aphoria::run_scan(scan_args, config).await {

View File

@ -0,0 +1,411 @@
//! Handler for the `aphoria claims promote` command.
//!
//! Promotion raises a claim to a higher authority tier (lower tier number)
//! by creating a new claim that supersedes the original. The original claim
//! is marked Deprecated in the TOML file.
//!
//! The invariant protected here: claim data is never destroyed. The original
//! remains in the file with a Deprecated status; the new claim links back via
//! `supersedes` and carries a provenance trail.
use aphoria::claims_file::ClaimsFile;
use aphoria::{validate_promotion, ClaimStatus, PromotionRequest, PromotionResult};
/// Return the current UNIX timestamp in whole seconds.
///
/// Used to generate unique new claim IDs without external dependencies.
/// Returns 0 if the system clock is not available (e.g. before UNIX epoch).
fn timestamp_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Format the current date as an ISO 8601 string (UTC).
fn date_str_now() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
/// Execute a promotion: validate, load, supersede with higher tier, save.
///
/// Validates the request, loads the claim from the TOML file, creates a new
/// claim at the target tier that supersedes the original, marks the original
/// as Deprecated, and saves both changes.
///
/// Returns `PromotionResult` on success or soft validation failure.
/// Returns `Err(AphoriaError)` only for I/O or unexpected errors that
/// prevent the operation from completing (cannot read/write TOML file,
/// current directory unavailable, etc.).
pub fn execute_promotion(
request: PromotionRequest,
project_root: &std::path::Path,
) -> Result<PromotionResult, aphoria::AphoriaError> {
let claims_path = ClaimsFile::default_path(project_root);
let mut claims_file = ClaimsFile::load(&claims_path)?;
// Find the claim by ID.
let existing = claims_file.claims.iter().find(|c| c.id == request.claim_id).cloned();
let existing = match existing {
Some(c) => c,
None => {
return Ok(PromotionResult {
original_claim_id: request.claim_id.clone(),
new_claim_id: String::new(),
previous_tier: String::new(),
new_tier: String::new(),
success: false,
error: Some(format!("claim '{}' not found", request.claim_id)),
});
}
};
// Validate before touching any state.
let is_deprecated = existing.status == ClaimStatus::Deprecated;
if let Err(e) = validate_promotion(&request, &existing.authority_tier, is_deprecated) {
return Ok(PromotionResult {
original_claim_id: request.claim_id.clone(),
new_claim_id: String::new(),
previous_tier: existing.authority_tier.clone(),
new_tier: request.target_tier.clone(),
success: false,
error: Some(e.to_string()),
});
}
let previous_tier = existing.authority_tier.clone();
let now = date_str_now();
// Build the promoted provenance trail.
let new_provenance = format!(
"{} | Promoted to {} by {} on {}: {}",
existing.provenance, request.target_tier, request.promoted_by, now, request.reason
);
// Merge evidence: existing + new, deduplicated, preserving order.
let mut merged_evidence = existing.evidence.clone();
for item in &request.evidence {
if !merged_evidence.contains(item) {
merged_evidence.push(item.clone());
}
}
let new_id = format!("{}-promoted-{}", request.claim_id, timestamp_secs());
let new_claim = aphoria::AuthoredClaim {
id: new_id.clone(),
concept_path: existing.concept_path.clone(),
predicate: existing.predicate.clone(),
value: existing.value.clone(),
comparison: existing.comparison.clone(),
provenance: new_provenance,
invariant: existing.invariant.clone(),
consequence: existing.consequence.clone(),
authority_tier: request.target_tier.clone(),
evidence: merged_evidence,
category: existing.category.clone(),
status: ClaimStatus::Active,
supersedes: Some(existing.id.clone()),
created_by: request.promoted_by.clone(),
created_at: now.clone(),
updated_at: None,
};
// Mark the original as Deprecated (in-place — ClaimsFile is a flat mutable TOML file).
for c in claims_file.claims.iter_mut() {
if c.id == request.claim_id {
c.status = ClaimStatus::Deprecated;
c.updated_at = Some(now);
break;
}
}
// Append the new claim and persist.
claims_file.claims.push(new_claim);
claims_file.save(&claims_path)?;
Ok(PromotionResult {
original_claim_id: request.claim_id,
new_claim_id: new_id,
previous_tier,
new_tier: request.target_tier,
success: true,
error: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use aphoria::{AuthoredClaim, AuthoredValue, ClaimStatus};
use tempfile::TempDir;
/// Build a minimal active claim for test fixtures.
fn sample_claim(id: &str, tier: &str) -> AuthoredClaim {
AuthoredClaim {
id: id.to_string(),
concept_path: format!("test/{id}"),
predicate: "behavior".to_string(),
value: AuthoredValue::Bool(true),
comparison: Default::default(),
provenance: "Test analysis by engineer".to_string(),
invariant: "System MUST behave correctly".to_string(),
consequence: "Incorrect behavior causes failures".to_string(),
authority_tier: tier.to_string(),
evidence: vec!["ADR-001".to_string()],
category: "architecture".to_string(),
status: ClaimStatus::Active,
supersedes: None,
created_by: "tester".to_string(),
created_at: "2026-02-01T00:00:00Z".to_string(),
updated_at: None,
}
}
fn setup_claims_file(dir: &TempDir, claims: Vec<AuthoredClaim>) -> ClaimsFile {
let path = ClaimsFile::default_path(dir.path());
let mut file = ClaimsFile::new();
for c in claims {
file.claims.push(c);
}
file.save(&path).expect("save claims file");
file
}
fn valid_request(claim_id: &str, target_tier: &str) -> PromotionRequest {
PromotionRequest {
claim_id: claim_id.to_string(),
target_tier: target_tier.to_string(),
evidence: vec!["new-study-reference".to_string()],
reason: "Adopted as org-wide standard after review".to_string(),
promoted_by: "jml".to_string(),
}
}
#[test]
fn test_happy_path_promotes_claim() {
let dir = TempDir::new().expect("temp dir");
setup_claims_file(&dir, vec![sample_claim("claim-001", "community")]);
let req = valid_request("claim-001", "expert");
let result = execute_promotion(req, dir.path()).expect("execute");
assert!(result.success, "expected success, got error: {:?}", result.error);
assert_eq!(result.original_claim_id, "claim-001");
assert!(!result.new_claim_id.is_empty(), "new_claim_id must be set");
assert_eq!(result.previous_tier, "community");
assert_eq!(result.new_tier, "expert");
assert!(result.error.is_none());
}
#[test]
fn test_original_marked_deprecated_after_promotion() {
let dir = TempDir::new().expect("temp dir");
setup_claims_file(&dir, vec![sample_claim("claim-001", "community")]);
let req = valid_request("claim-001", "expert");
let result = execute_promotion(req, dir.path()).expect("execute");
assert!(result.success);
// Reload the file and verify the original is Deprecated.
let path = ClaimsFile::default_path(dir.path());
let loaded = ClaimsFile::load(&path).expect("reload");
let original = loaded.find_by_id("claim-001").expect("find original");
assert_eq!(original.status, ClaimStatus::Deprecated);
assert!(original.updated_at.is_some(), "updated_at must be set on deprecation");
}
#[test]
fn test_new_claim_persisted_with_correct_fields() {
let dir = TempDir::new().expect("temp dir");
setup_claims_file(&dir, vec![sample_claim("claim-001", "community")]);
let req = valid_request("claim-001", "expert");
let result = execute_promotion(req, dir.path()).expect("execute");
assert!(result.success);
let path = ClaimsFile::default_path(dir.path());
let loaded = ClaimsFile::load(&path).expect("reload");
let new_claim = loaded.find_by_id(&result.new_claim_id).expect("find new claim");
assert_eq!(new_claim.authority_tier, "expert");
assert_eq!(new_claim.status, ClaimStatus::Active);
assert_eq!(new_claim.supersedes.as_deref(), Some("claim-001"));
assert_eq!(new_claim.created_by, "jml");
// New claim must have inherited the concept path.
assert_eq!(new_claim.concept_path, "test/claim-001");
}
#[test]
fn test_evidence_is_merged_and_deduplicated() {
let dir = TempDir::new().expect("temp dir");
let mut claim = sample_claim("claim-001", "community");
// Original has "ADR-001"; request adds "ADR-001" (dup) and "new-study".
claim.evidence = vec!["ADR-001".to_string()];
setup_claims_file(&dir, vec![claim]);
let mut req = valid_request("claim-001", "expert");
req.evidence = vec!["ADR-001".to_string(), "new-study".to_string()];
let result = execute_promotion(req, dir.path()).expect("execute");
assert!(result.success);
let path = ClaimsFile::default_path(dir.path());
let loaded = ClaimsFile::load(&path).expect("reload");
let new_claim = loaded.find_by_id(&result.new_claim_id).expect("find");
// "ADR-001" must appear exactly once; "new-study" must be present.
assert_eq!(new_claim.evidence.iter().filter(|e| e.as_str() == "ADR-001").count(), 1);
assert!(new_claim.evidence.contains(&"new-study".to_string()));
}
#[test]
fn test_provenance_contains_promotion_trail() {
let dir = TempDir::new().expect("temp dir");
setup_claims_file(&dir, vec![sample_claim("claim-001", "community")]);
let req = valid_request("claim-001", "expert");
let result = execute_promotion(req, dir.path()).expect("execute");
assert!(result.success);
let path = ClaimsFile::default_path(dir.path());
let loaded = ClaimsFile::load(&path).expect("reload");
let new_claim = loaded.find_by_id(&result.new_claim_id).expect("find");
assert!(
new_claim.provenance.contains("Promoted to expert"),
"provenance must record target tier: {}",
new_claim.provenance
);
assert!(
new_claim.provenance.contains("jml"),
"provenance must record promoter: {}",
new_claim.provenance
);
assert!(
new_claim.provenance.contains("Adopted as org-wide standard after review"),
"provenance must contain reason: {}",
new_claim.provenance
);
}
#[test]
fn test_claim_not_found_returns_soft_failure() {
let dir = TempDir::new().expect("temp dir");
setup_claims_file(&dir, vec![]);
let req = valid_request("nonexistent-claim", "expert");
let result = execute_promotion(req, dir.path()).expect("execute (I/O must not fail)");
assert!(!result.success);
assert!(result.error.is_some());
let msg = result.error.unwrap();
assert!(msg.contains("nonexistent-claim"), "error must name the missing claim: {msg}");
}
#[test]
fn test_deprecated_claim_returns_validation_failure() {
let dir = TempDir::new().expect("temp dir");
let mut claim = sample_claim("claim-001", "community");
claim.status = ClaimStatus::Deprecated;
setup_claims_file(&dir, vec![claim]);
let req = valid_request("claim-001", "expert");
let result = execute_promotion(req, dir.path()).expect("execute");
assert!(!result.success);
let msg = result.error.unwrap();
assert!(msg.contains("deprecated"), "error must mention deprecated status: {msg}");
}
#[test]
fn test_same_tier_returns_validation_failure() {
let dir = TempDir::new().expect("temp dir");
setup_claims_file(&dir, vec![sample_claim("claim-001", "expert")]);
let req = valid_request("claim-001", "expert");
let result = execute_promotion(req, dir.path()).expect("execute");
assert!(!result.success);
let msg = result.error.unwrap();
assert!(
msg.contains("lower authority") || msg.contains("equal"),
"error must describe tier constraint: {msg}"
);
}
#[test]
fn test_lower_authority_tier_returns_validation_failure() {
let dir = TempDir::new().expect("temp dir");
// Claim is already at "expert" (tier 3); requesting "community" (tier 4) is a demotion.
setup_claims_file(&dir, vec![sample_claim("claim-001", "expert")]);
let req = valid_request("claim-001", "community");
let result = execute_promotion(req, dir.path()).expect("execute");
assert!(!result.success);
assert!(result.error.is_some());
}
#[test]
fn test_missing_evidence_returns_validation_failure() {
let dir = TempDir::new().expect("temp dir");
setup_claims_file(&dir, vec![sample_claim("claim-001", "community")]);
let mut req = valid_request("claim-001", "expert");
req.evidence = vec![];
let result = execute_promotion(req, dir.path()).expect("execute");
assert!(!result.success);
let msg = result.error.unwrap();
assert!(msg.contains("evidence"), "error must mention evidence: {msg}");
}
#[test]
fn test_missing_reason_returns_validation_failure() {
let dir = TempDir::new().expect("temp dir");
setup_claims_file(&dir, vec![sample_claim("claim-001", "community")]);
let mut req = valid_request("claim-001", "expert");
req.reason = " ".to_string();
let result = execute_promotion(req, dir.path()).expect("execute");
assert!(!result.success);
let msg = result.error.unwrap();
assert!(msg.contains("reason"), "error must mention reason: {msg}");
}
#[test]
fn test_anecdotal_to_regulatory_is_valid() {
let dir = TempDir::new().expect("temp dir");
setup_claims_file(&dir, vec![sample_claim("claim-001", "anecdotal")]);
let req = valid_request("claim-001", "regulatory");
let result = execute_promotion(req, dir.path()).expect("execute");
assert!(result.success);
assert_eq!(result.previous_tier, "anecdotal");
assert_eq!(result.new_tier, "regulatory");
}
#[test]
fn test_file_has_exactly_two_entries_after_promotion() {
let dir = TempDir::new().expect("temp dir");
setup_claims_file(&dir, vec![sample_claim("claim-001", "community")]);
let req = valid_request("claim-001", "expert");
let result = execute_promotion(req, dir.path()).expect("execute");
assert!(result.success);
let path = ClaimsFile::default_path(dir.path());
let loaded = ClaimsFile::load(&path).expect("reload");
// Original (now Deprecated) + new promoted claim.
assert_eq!(
loaded.len(),
2,
"file must contain exactly 2 entries: original (deprecated) + promoted"
);
}
}

View File

@ -18,6 +18,7 @@ pub async fn handle_scan(
show_claims: bool,
show_observations: bool,
explain_authority: bool,
suggest_convergence: bool,
config: &AphoriaConfig,
) -> ExitCode {
// Validate: --sync requires --persist
@ -43,6 +44,7 @@ pub async fn handle_scan(
strict,
show_observations,
explain_authority,
suggest_convergence,
};
// Apply stricter thresholds if requested
@ -56,7 +58,46 @@ pub async fn handle_scan(
};
match run_scan(args, &config).await {
Ok(result) => {
Ok(mut result) => {
// If --suggest-convergence, fetch remote org claims and compute suggestions.
// Network/auth failures are logged but do NOT fail the scan.
if suggest_convergence {
if config.hosted.is_enabled() {
match aphoria::remote::RemoteClaimStore::new(&config.hosted) {
Ok(store) => {
use aphoria::ClaimStore;
match store.list_claims(&aphoria::ClaimFilter::default()) {
Ok(remote_claims) => {
let suggestions = aphoria::compute_convergence_suggestions(
&result.observations,
&remote_claims,
None,
);
result.convergence_suggestions = suggestions;
}
Err(e) => {
tracing::warn!(
error = %e,
"convergence fetch failed: could not list remote claims"
);
}
}
}
Err(e) => {
tracing::warn!(
error = %e,
"convergence fetch failed: could not create remote claim store"
);
}
}
} else {
tracing::warn!(
"--suggest-convergence requires hosted mode \
(set [hosted] enabled = true in aphoria.toml)"
);
}
}
// If --show-observations, print observations first
if show_observations {
use aphoria::report::format_observations;
@ -117,6 +158,7 @@ pub async fn handle_community_preview(
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let claims = match extract_claims(&args, config).await {

View File

@ -57,6 +57,7 @@ pub mod claims_explain;
pub mod claims_file;
pub mod community;
mod config;
pub mod convergence;
pub mod corpus;
mod corpus_build;
pub mod coverage;
@ -113,6 +114,7 @@ pub use config::{
GovernanceConfig, HostedConfig, LearningConfig, LlmConfig, OfflineFallback,
PredicateAliasConfig, PromotionConfig, ShadowConfig, SyncMode,
};
pub use convergence::compute_convergence_suggestions;
pub use corpus::{CorpusBuildResult, CorpusBuilderInfo, CorpusRegistry};
pub use corpus_build::{
build_corpus, create_corpus_item, export_corpus_as_pack, import_corpus_from_wiki,
@ -171,7 +173,12 @@ pub use shadow::{
ShadowDecision, ShadowDecisionKind, ShadowExecutor, ShadowExtractorRegistry, ShadowMatch,
ShadowMetrics, ShadowStatus, ShadowStore, ShadowTest,
};
pub use types::convergence::{ConvergenceSeverity, ConvergenceSuggestion, DriveClaimSummary};
pub use types::ingested_guides;
pub use types::promotion::{
tier_name_to_number as tier_name_to_number_for_promotion, validate_promotion, PromotionRequest,
PromotionResult, PromotionValidationError,
};
#[allow(deprecated)]
pub use types::ExtractedClaim; // Backward compat alias for Observation
pub use types::{

View File

@ -2,6 +2,7 @@
//!
//! Implements `ClaimStore` trait by calling StemeDB `/v1/claims` API endpoints.
use std::collections::HashMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
@ -69,6 +70,50 @@ struct ListClaimsResponse {
claims: Vec<AuthoredClaimDto>,
}
#[derive(Debug, Clone, Serialize)]
struct SearchClaimsQuery {
#[serde(skip_serializing_if = "Option::is_none")]
concept_pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
predicate: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
limit: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct SearchClaimsResponse {
claims: Vec<AuthoredClaimDto>,
}
#[derive(Debug, Clone, Serialize)]
struct ClaimStatsQuery {
concept_path: String,
predicate: String,
}
/// Statistics for a specific concept_path + predicate combination across all stored claims.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaimStatsResult {
/// The concept path this stats result covers.
pub concept_path: String,
/// The predicate this stats result covers.
pub predicate: String,
/// Total number of claims matching the concept_path + predicate pair.
pub matching_claims: usize,
/// Claim count broken down by authority tier (tier number → count).
pub by_tier: HashMap<u8, usize>,
/// Claim count broken down by status string (e.g. "active", "deprecated").
pub by_status: HashMap<String, usize>,
/// The most frequently occurring value across matching claims, if any.
pub most_common_value: Option<String>,
/// Whether at least one matching claim has authoritative (Tier 1) backing.
pub has_authoritative_backing: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AuthoredClaimDto {
id: String,
@ -182,6 +227,103 @@ impl RemoteClaimStore {
Err(last_error.unwrap_or_else(|| AphoriaError::Hosted("Max retries exceeded".to_string())))
}
/// Search claims by concept pattern, predicate, category, or tier.
///
/// Queries `GET /v1/claims/search` with the supplied filters. Returns an empty
/// vec when offline and the fallback strategy is `Skip`; returns an error when
/// the fallback strategy is `Fail`.
pub fn search_claims(
&self,
concept_pattern: Option<&str>,
predicate: Option<&str>,
category: Option<&str>,
max_tier: Option<u8>,
limit: Option<usize>,
) -> Result<Vec<AuthoredClaim>, AphoriaError> {
let query = SearchClaimsQuery {
concept_pattern: concept_pattern.map(str::to_string),
predicate: predicate.map(str::to_string),
category: category.map(str::to_string),
max_tier: max_tier.map(|t| t.to_string()),
limit: Some(limit.unwrap_or(50).to_string()),
};
let query_str = serde_qs::to_string(&query)
.map_err(|e| AphoriaError::Config(format!("Failed to build search query: {e}")))?;
let path = if query_str.is_empty() {
"/v1/claims/search".to_string()
} else {
format!("/v1/claims/search?{}", query_str)
};
match self.request::<SearchClaimsResponse>("GET", &path, None::<&()>) {
Ok(response) => {
let claims: Vec<AuthoredClaim> =
response.claims.into_iter().map(dto_to_claim).collect();
info!(count = claims.len(), "search_claims returned results");
Ok(claims)
}
Err(e) if is_network_error(&e) => {
self.handle_network_error("search_claims", &|| {
// Search results cannot be reproduced from the local cache,
// so return an empty vec as the best-effort offline result.
warn!("Remote unreachable for search_claims, returning empty result");
Ok(vec![])
})
}
Err(e) => Err(e),
}
}
/// Retrieve statistics for a specific concept_path + predicate pair.
///
/// Queries `GET /v1/claims/stats?concept_path={}&predicate={}`. Returns a
/// zero-valued `ClaimStatsResult` when offline and the fallback strategy is
/// `Skip`; returns an error when the fallback strategy is `Fail`.
pub fn get_claim_stats(
&self,
concept_path: &str,
predicate: &str,
) -> Result<ClaimStatsResult, AphoriaError> {
let query = ClaimStatsQuery {
concept_path: concept_path.to_string(),
predicate: predicate.to_string(),
};
let query_str = serde_qs::to_string(&query)
.map_err(|e| AphoriaError::Config(format!("Failed to build stats query: {e}")))?;
let path = format!("/v1/claims/stats?{}", query_str);
match self.request::<ClaimStatsResult>("GET", &path, None::<&()>) {
Ok(stats) => {
info!(
concept_path,
predicate,
matching_claims = stats.matching_claims,
"get_claim_stats returned results"
);
Ok(stats)
}
Err(e) if is_network_error(&e) => {
self.handle_network_error("get_claim_stats", &|| {
// Return a zero-stats result so callers can safely proceed offline.
Ok(ClaimStatsResult {
concept_path: concept_path.to_string(),
predicate: predicate.to_string(),
matching_claims: 0,
by_tier: HashMap::new(),
by_status: HashMap::new(),
most_common_value: None,
has_authoritative_backing: false,
})
})
}
Err(e) => Err(e),
}
}
/// Perform the actual HTTP request.
fn do_request<T: for<'de> Deserialize<'de>>(
&self,
@ -473,6 +615,7 @@ mod tests {
offline_fallback: OfflineFallback::Skip,
max_retries: 3,
retry_delay_ms: 1000,
team_id: None,
};
let result = RemoteClaimStore::new(&config);

View File

@ -7,4 +7,4 @@ pub mod cache;
pub mod client;
pub use cache::ClaimCache;
pub use client::RemoteClaimStore;
pub use client::{ClaimStatsResult, RemoteClaimStore};

View File

@ -230,6 +230,46 @@ impl ReportFormatter for JsonReport {
report["claims"] = serde_json::json!(claims_json);
}
// Add convergence suggestions if present
if !result.convergence_suggestions.is_empty() {
let convergence_json: Vec<serde_json::Value> = result
.convergence_suggestions
.iter()
.map(|s| {
let mut json = serde_json::json!({
"concept_path": s.concept_path,
"predicate": s.predicate,
"local_value": s.local_value,
"org_value": s.org_value,
"org_tier": s.org_tier,
"org_tier_name": s.org_tier_name,
"matching_claims_count": s.matching_claims_count,
"severity": s.severity.display_name(),
"file": s.file,
"line": s.line,
});
if let Some(ref dc) = s.driving_claim {
json["driving_claim"] = serde_json::json!({
"claim_id": dc.claim_id,
"invariant": dc.invariant,
"consequence": dc.consequence,
"provenance": dc.provenance,
"evidence": dc.evidence,
});
}
json
})
.collect();
report["convergence_suggestions"] = serde_json::json!(convergence_json);
// Update summary with convergence count
report["summary"]["convergence_suggestions"] =
serde_json::json!(result.convergence_suggestions.len());
}
// Add timing if benchmark mode was enabled
if let Some(timing) = &result.timing {
let mut timing_json = serde_json::json!({
@ -300,6 +340,7 @@ mod tests {
observations: vec![],
deprecated_usages: vec![],
verify: None,
convergence_suggestions: vec![],
};
let output = formatter.format(&result);

View File

@ -4,6 +4,7 @@
//! detailed conflict sections, and action items.
use super::{extract_leaf_concept, object_value_display, verdict_label, ReportFormatter};
use crate::types::convergence::ConvergenceSeverity;
use crate::types::{ScanResult, Verdict};
use crate::verify::AuditVerdict;
@ -103,6 +104,9 @@ impl ReportFormatter for MarkdownReport {
}
}
// Show convergence suggestions even when there are no authority conflicts
append_convergence_section(&mut out, result);
return out;
}
@ -313,6 +317,9 @@ impl ReportFormatter for MarkdownReport {
}
}
// Convergence Suggestions section
append_convergence_section(&mut out, result);
// Extracted Observations section
if let Some(claims) = &result.claims {
out.push_str("## Extracted Observations\n\n");
@ -341,6 +348,75 @@ impl ReportFormatter for MarkdownReport {
}
}
/// Append a `## Convergence Suggestions` section to `out`.
///
/// No-ops when `result.convergence_suggestions` is empty, so callers do not
/// need to guard the call.
fn append_convergence_section(out: &mut String, result: &ScanResult) {
if result.convergence_suggestions.is_empty() {
return;
}
out.push_str("## Convergence Suggestions\n\n");
out.push_str("| Severity | Pattern | Local | Org | Tier | File |\n");
out.push_str("|----------|---------|-------|-----|------|------|\n");
for suggestion in &result.convergence_suggestions {
let severity_label = suggestion.severity.display_name();
// Combine concept_path and predicate into a single pattern column
let pattern = format!("{}.{}", suggestion.concept_path, suggestion.predicate);
out.push_str(&format!(
"| {} | `{}` | `{}` | `{}` | {} | `{}:{}` |\n",
severity_label,
pattern,
suggestion.local_value,
suggestion.org_value,
suggestion.org_tier_name,
suggestion.file,
suggestion.line,
));
}
out.push('\n');
// Detail blocks for Authoritative suggestions (highest priority)
let authoritative: Vec<_> = result
.convergence_suggestions
.iter()
.filter(|s| s.severity == ConvergenceSeverity::Authoritative)
.collect();
if !authoritative.is_empty() {
out.push_str("### Authoritative Suggestions\n\n");
for suggestion in authoritative {
out.push_str(&format!(
"#### `{}.{}`\n\n",
suggestion.concept_path, suggestion.predicate,
));
out.push_str(&format!(
"- **Local:** `{}` | **Org:** `{}` ({} tier)\n",
suggestion.local_value, suggestion.org_value, suggestion.org_tier_name,
));
out.push_str(&format!("- **Location:** `{}:{}`\n", suggestion.file, suggestion.line,));
let claim_word = if suggestion.matching_claims_count == 1 { "claim" } else { "claims" };
out.push_str(&format!(
"- **Org coverage:** {} matching {}\n",
suggestion.matching_claims_count, claim_word,
));
if let Some(ref dc) = suggestion.driving_claim {
out.push_str(&format!("- **Invariant:** {}\n", dc.invariant));
out.push_str(&format!("- **Consequence:** {}\n", dc.consequence));
if !dc.evidence.is_empty() {
out.push_str(&format!("- **Evidence:** {}\n", dc.evidence.join(", ")));
}
}
out.push('\n');
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -392,6 +468,7 @@ mod tests {
observations: vec![],
deprecated_usages: vec![],
verify: None,
convergence_suggestions: vec![],
};
let output = formatter.format(&result);

View File

@ -478,6 +478,7 @@ mod tests {
observations: vec![],
deprecated_usages: vec![],
verify: None,
convergence_suggestions: vec![],
};
let output = formatter.format(&result);

View File

@ -6,6 +6,7 @@
use comfy_table::{Cell, CellAlignment, Color, ContentArrangement, Table};
use super::{object_value_display, verdict_label, ReportFormatter};
use crate::types::convergence::ConvergenceSeverity;
use crate::types::{extract_leaf_concept, ScanResult, Verdict};
use crate::verify::AuditVerdict;
@ -125,6 +126,9 @@ impl ReportFormatter for TableReport {
}
}
// Show convergence suggestions even when there are no authority conflicts
append_convergence_section(&mut output, result);
return output;
}
@ -342,6 +346,9 @@ impl ReportFormatter for TableReport {
}
}
// Convergence Suggestions section
append_convergence_section(&mut output, result);
// Extracted Observations section (only when --show-claims is used)
if let Some(claims) = &result.claims {
if claims.is_empty() {
@ -436,6 +443,51 @@ impl ReportFormatter for TableReport {
}
}
/// Append a convergence suggestions section to `output`.
///
/// No-ops when `result.convergence_suggestions` is empty, so callers do not
/// need to guard the call.
fn append_convergence_section(output: &mut String, result: &ScanResult) {
if result.convergence_suggestions.is_empty() {
return;
}
let count = result.convergence_suggestions.len();
output.push_str(&format!("\nConvergence Suggestions ({count}):\n"));
for suggestion in &result.convergence_suggestions {
let bullet = match suggestion.severity {
ConvergenceSeverity::Authoritative => "",
ConvergenceSeverity::Advisory => "",
ConvergenceSeverity::Informational => "",
};
let severity_label = suggestion.severity.display_name();
output.push_str(&format!(
" {bullet} {severity_label:<15} {} .{}\n",
suggestion.concept_path, suggestion.predicate,
));
let claim_word = if suggestion.matching_claims_count == 1 { "claim" } else { "claims" };
output.push_str(&format!(
" Local: {} -> Org: {} ({} tier · {} matching {})\n",
suggestion.local_value,
suggestion.org_value,
suggestion.org_tier_name,
suggestion.matching_claims_count,
claim_word,
));
output.push_str(&format!(" {}:{}\n", suggestion.file, suggestion.line));
if let Some(ref dc) = suggestion.driving_claim {
output.push_str(&format!(" Invariant: {}\n", dc.invariant));
}
output.push('\n');
}
}
/// Format a number with thousands separators for readability.
fn format_number(n: usize) -> String {
let s = n.to_string();
@ -498,6 +550,7 @@ mod tests {
observations: vec![],
deprecated_usages: vec![],
verify: None,
convergence_suggestions: vec![],
}
}

View File

@ -153,6 +153,7 @@ pub async fn run_scan(args: ScanArgs, config: &AphoriaConfig) -> Result<ScanResu
observations: all_claims.to_vec(), // Always populate for verification/coverage
deprecated_usages: vec![], // TODO: Populate from lifecycle store during scan
verify: verify_report,
convergence_suggestions: vec![],
})
}

View File

@ -126,6 +126,7 @@ async fn test_conflict_detection_tls_disabled() {
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let mut config = AphoriaConfig::default();
@ -198,6 +199,7 @@ async fn test_conflict_detection_jwt_audience_disabled() {
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let mut config = AphoriaConfig::default();
@ -272,6 +274,7 @@ async fn test_no_conflicts_when_compliant() {
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let mut config = AphoriaConfig::default();

View File

@ -49,6 +49,7 @@ async fn test_show_observations_flag_populates_observations() {
strict: false,
show_observations: true,
explain_authority: false,
suggest_convergence: false,
};
let result = run_scan(args, &AphoriaConfig::default()).await.expect("scan should succeed");
@ -96,6 +97,7 @@ async fn test_show_observations_formatting() {
strict: false,
show_observations: true,
explain_authority: false,
suggest_convergence: false,
};
let result = run_scan(args, &AphoriaConfig::default()).await.expect("scan should succeed");
@ -136,6 +138,7 @@ async fn test_show_observations_disabled_by_default() {
strict: false,
show_observations: false, // Explicitly disabled
explain_authority: false,
suggest_convergence: false,
};
let result = run_scan(args, &AphoriaConfig::default()).await.expect("scan should succeed");
@ -175,6 +178,7 @@ async fn test_show_observations_with_verify_report() {
strict: false,
show_observations: true,
explain_authority: false,
suggest_convergence: false,
};
let result = run_scan(args, &AphoriaConfig::default()).await.expect("scan should succeed");
@ -221,6 +225,7 @@ async fn test_show_observations_empty_project() {
strict: false,
show_observations: true,
explain_authority: false,
suggest_convergence: false,
};
let result = run_scan(args, &AphoriaConfig::default())

View File

@ -72,6 +72,7 @@ fn test_scan_result_has_drifts() {
claims: None,
observations: vec![],
verify: None,
convergence_suggestions: vec![],
};
assert!(result.has_drifts());
@ -109,6 +110,7 @@ fn test_drift_json_output_format() {
claims: None,
observations: vec![],
verify: None,
convergence_suggestions: vec![],
};
let formatter = JsonReport;
@ -148,6 +150,7 @@ fn test_drift_sarif_output_format() {
claims: None,
observations: vec![],
verify: None,
convergence_suggestions: vec![],
};
let formatter = SarifReport;
@ -189,6 +192,7 @@ fn test_drift_table_output_format() {
claims: None,
observations: vec![],
verify: None,
convergence_suggestions: vec![],
};
let formatter = TableReport;

View File

@ -133,6 +133,7 @@ version = "0.1.0"
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let result = run_scan(args, &config_b).await.expect("scan should succeed");

View File

@ -118,6 +118,7 @@ async fn test_scan_returns_result() {
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let mut config = AphoriaConfig::default();

View File

@ -112,6 +112,7 @@ version = "0.1.0"
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let mut config = AphoriaConfig::default();
@ -171,6 +172,7 @@ version = "0.1.0"
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let mut config = AphoriaConfig::default();
@ -240,6 +242,7 @@ version = "0.1.0"
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let ephemeral_result = run_scan(ephemeral_args, &config).await.expect("ephemeral scan");
@ -258,6 +261,7 @@ version = "0.1.0"
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let persistent_result = run_scan(persistent_args, &config).await.expect("persistent scan");
@ -339,6 +343,7 @@ version = "0.1.0"
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");
@ -393,6 +398,7 @@ version = "0.1.0"
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");
@ -441,6 +447,7 @@ version = "0.1.0"
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");
@ -498,6 +505,7 @@ version = "0.1.0"
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let result1 = run_scan(args1, &config).await.expect("first scan should succeed");
@ -529,6 +537,7 @@ version = "0.1.0"
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let result2 = run_scan(args2, &config).await.expect("second scan should succeed");
@ -588,6 +597,7 @@ version = "0.1.0"
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");

View File

@ -242,6 +242,7 @@ async fn test_staged_with_persist_and_sync() {
strict: false,
show_observations: false,
explain_authority: false,
suggest_convergence: false,
};
let result = run_scan(args, &config).await.expect("scan should succeed");

View File

@ -78,6 +78,10 @@ pub struct ScanArgs {
/// When enabled, displays which tiers have conflicting sources, per-tier
/// verdicts, and explains why the primary tier was chosen.
pub explain_authority: bool,
/// Whether to fetch remote org claims and show convergence suggestions.
/// Only active in hosted mode (config.hosted.enabled).
pub suggest_convergence: bool,
}
/// Arguments for the acknowledge command.

View File

@ -0,0 +1,206 @@
//! Convergence suggestion types for remote org-pattern alignment.
//!
//! A `ConvergenceSuggestion` describes a detected divergence between local code
//! behaviour and an org-wide pattern stored in a remote StemeDB instance. The
//! divergence is discovered at query time (read path) — the local scan produces
//! an `Observation` and a remote claim is fetched; this type records the delta.
//!
//! Severity is derived from the authority tier of the driving org claim:
//! - Tier 0-2 (regulatory / clinical / observational): **Authoritative**
//! - Tier 3 (expert opinion): **Advisory**
//! - Tier 4-5 (community / anecdotal): **Informational**
//!
//! No mutation occurs here. Suggestions are produced at read time and discarded
//! unless the developer explicitly acts on them.
use serde::{Deserialize, Serialize};
/// A suggestion that the local code diverges from an org-wide pattern.
///
/// Produced when a local `Observation` disagrees with a claim fetched from the
/// remote StemeDB instance. The developer can choose to align, ignore, or
/// escalate the suggestion.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConvergenceSuggestion {
/// The concept path where divergence was detected.
pub concept_path: String,
/// The predicate where divergence was detected.
pub predicate: String,
/// What the local code does, expressed as a human-readable string.
pub local_value: String,
/// What the org pattern says the value should be.
pub org_value: String,
/// Authority tier number of the org claim driving this suggestion (0-5).
///
/// 0 = regulatory, 1 = clinical, 2 = observational, 3 = expert,
/// 4 = community, 5 = anecdotal.
pub org_tier: u8,
/// Human-readable name for the tier (e.g. `"Expert"`, `"Regulatory"`).
pub org_tier_name: String,
/// How many claims share this concept path in the remote org StemeDB.
pub matching_claims_count: usize,
/// Summary of the primary org claim that triggered this suggestion.
///
/// `None` when the suggestion was synthesised from aggregate statistics
/// rather than a single authoritative claim.
pub driving_claim: Option<DriveClaimSummary>,
/// Severity derived from `org_tier`.
pub severity: ConvergenceSeverity,
/// Path to the file containing the diverging observation.
pub file: String,
/// Line number in `file` where the observation was detected.
pub line: usize,
}
/// Lightweight summary of the org claim that drives a convergence suggestion.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriveClaimSummary {
/// Claim identifier (e.g. `"tls-cert-verify-001"`).
pub claim_id: String,
/// The invariant the claim enforces.
pub invariant: String,
/// What breaks if the invariant is violated.
pub consequence: String,
/// Who established this claim and when.
pub provenance: String,
/// Supporting evidence references.
pub evidence: Vec<String>,
}
/// How authoritative is a convergence suggestion?
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ConvergenceSeverity {
/// Tier 0-2: regulatory, clinical, or observational — strong guidance.
Authoritative,
/// Tier 3: expert opinion — worth considering.
Advisory,
/// Tier 4-5: community or anecdotal — informational only.
Informational,
}
impl ConvergenceSeverity {
/// Derive severity from an integer tier number.
pub fn from_tier(tier: u8) -> Self {
match tier {
0..=2 => Self::Authoritative,
3 => Self::Advisory,
_ => Self::Informational,
}
}
/// Short uppercase label suitable for CLI output.
pub fn display_name(&self) -> &'static str {
match self {
Self::Authoritative => "AUTHORITATIVE",
Self::Advisory => "ADVISORY",
Self::Informational => "INFORMATIONAL",
}
}
}
impl std::fmt::Display for ConvergenceSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.display_name())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_severity_from_tier() {
assert_eq!(ConvergenceSeverity::from_tier(0), ConvergenceSeverity::Authoritative);
assert_eq!(ConvergenceSeverity::from_tier(1), ConvergenceSeverity::Authoritative);
assert_eq!(ConvergenceSeverity::from_tier(2), ConvergenceSeverity::Authoritative);
assert_eq!(ConvergenceSeverity::from_tier(3), ConvergenceSeverity::Advisory);
assert_eq!(ConvergenceSeverity::from_tier(4), ConvergenceSeverity::Informational);
assert_eq!(ConvergenceSeverity::from_tier(5), ConvergenceSeverity::Informational);
assert_eq!(ConvergenceSeverity::from_tier(9), ConvergenceSeverity::Informational);
}
#[test]
fn test_severity_display_name() {
assert_eq!(ConvergenceSeverity::Authoritative.display_name(), "AUTHORITATIVE");
assert_eq!(ConvergenceSeverity::Advisory.display_name(), "ADVISORY");
assert_eq!(ConvergenceSeverity::Informational.display_name(), "INFORMATIONAL");
}
#[test]
fn test_severity_display_trait() {
assert_eq!(ConvergenceSeverity::Authoritative.to_string(), "AUTHORITATIVE");
}
#[test]
fn test_convergence_suggestion_serde_roundtrip() {
let suggestion = ConvergenceSuggestion {
concept_path: "code://rust/tls/cert_verification".to_string(),
predicate: "enabled".to_string(),
local_value: "false".to_string(),
org_value: "true".to_string(),
org_tier: 1,
org_tier_name: "Clinical".to_string(),
matching_claims_count: 3,
driving_claim: Some(DriveClaimSummary {
claim_id: "tls-cert-verify-001".to_string(),
invariant: "TLS cert verification MUST be enabled".to_string(),
consequence: "MITM attacks become trivially possible".to_string(),
provenance: "Security review by jml 2024-12-15".to_string(),
evidence: vec!["RFC 5246 §7.4.2".to_string()],
}),
severity: ConvergenceSeverity::Authoritative,
file: "src/client.rs".to_string(),
line: 42,
};
let json = serde_json::to_string(&suggestion).expect("serialization failed");
let restored: ConvergenceSuggestion =
serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(restored.concept_path, suggestion.concept_path);
assert_eq!(restored.org_tier, suggestion.org_tier);
assert_eq!(restored.severity, suggestion.severity);
let driving = restored.driving_claim.expect("driving claim missing");
assert_eq!(driving.claim_id, "tls-cert-verify-001");
assert_eq!(driving.evidence.len(), 1);
}
#[test]
fn test_convergence_suggestion_no_driving_claim() {
let suggestion = ConvergenceSuggestion {
concept_path: "code://go/http/timeout".to_string(),
predicate: "set".to_string(),
local_value: "false".to_string(),
org_value: "true".to_string(),
org_tier: 4,
org_tier_name: "Community".to_string(),
matching_claims_count: 0,
driving_claim: None,
severity: ConvergenceSeverity::Informational,
file: "main.go".to_string(),
line: 7,
};
let json = serde_json::to_string(&suggestion).expect("serialization failed");
let restored: ConvergenceSuggestion =
serde_json::from_str(&json).expect("deserialization failed");
assert!(restored.driving_claim.is_none());
assert_eq!(restored.severity, ConvergenceSeverity::Informational);
}
}

View File

@ -3,8 +3,10 @@
pub mod authored_claim;
mod claim;
mod command;
pub mod convergence;
pub mod ingested_guides;
mod language;
pub mod promotion;
mod result;
mod verdict;

View File

@ -0,0 +1,307 @@
//! Promotion request and result types for claim tier advancement.
//!
//! Promotion raises a claim to a higher authority tier (lower tier number),
//! recording stronger evidence for the claim's invariant. The original claim
//! is never mutated; the promotion creates a new claim that supersedes it
//! (append-only invariant preserved).
//!
//! Tier ordering (lower number = higher authority):
//! ```text
//! 0 = regulatory
//! 1 = clinical
//! 2 = observational
//! 3 = expert / team_policy
//! 4 = community
//! 5 = anecdotal
//! ```
//!
//! A promotion is only valid when `target_tier_number < current_tier_number`.
use serde::{Deserialize, Serialize};
/// Request to promote a claim to a higher authority tier.
///
/// Promotion creates a new claim that supersedes the original (append-only).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromotionRequest {
/// ID of the claim to promote.
pub claim_id: String,
/// Target tier name (must represent higher authority than current tier).
///
/// Accepted values: `"regulatory"`, `"clinical"`, `"observational"`,
/// `"team_policy"`, `"expert"`, `"community"`, `"anecdotal"`.
pub target_tier: String,
/// Evidence supporting the higher tier (at least one entry required).
pub evidence: Vec<String>,
/// Human-readable justification for the promotion.
pub reason: String,
/// Identity of the person or agent performing the promotion.
pub promoted_by: String,
}
/// Result of a promotion operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromotionResult {
/// The original claim ID that was promoted.
pub original_claim_id: String,
/// The new claim ID created to supersede the original.
///
/// Only meaningful when `success == true`.
pub new_claim_id: String,
/// Human-readable name of the previous tier.
pub previous_tier: String,
/// Human-readable name of the new tier.
pub new_tier: String,
/// Whether the promotion succeeded.
pub success: bool,
/// Error message when `success == false`.
pub error: Option<String>,
}
/// Validation error for a promotion request.
///
/// Returned before any write is attempted, so no state has changed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromotionValidationError {
/// Target tier has equal or lower authority than the current tier.
TierNotHigher {
/// Current tier number.
current: u8,
/// Requested target tier number.
requested: u8,
},
/// No evidence provided; at least one reference is required for promotion.
MissingEvidence,
/// The `reason` field is empty or whitespace-only.
MissingReason,
/// The claim to be promoted was not found.
ClaimNotFound(String),
/// The claim is deprecated and cannot be promoted.
ClaimDeprecated,
}
impl std::fmt::Display for PromotionValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TierNotHigher { current, requested } => write!(
f,
"target tier {} has equal or lower authority than current tier {} \
(lower number = higher authority)",
requested, current
),
Self::MissingEvidence => {
write!(f, "promotion requires at least one evidence reference")
}
Self::MissingReason => write!(f, "promotion requires a non-empty reason"),
Self::ClaimNotFound(id) => write!(f, "claim '{}' not found", id),
Self::ClaimDeprecated => write!(
f,
"cannot promote a deprecated claim; supersede it with a new active claim first"
),
}
}
}
/// Convert a tier name string to its integer tier number.
///
/// Returns `None` for unknown tier names.
/// `team_policy` maps to 3 for integer comparison (same as `expert`).
pub fn tier_name_to_number(tier: &str) -> Option<u8> {
match tier.to_lowercase().as_str() {
"regulatory" => Some(0),
"clinical" => Some(1),
"observational" => Some(2),
"team_policy" | "expert" => Some(3),
"community" => Some(4),
"anecdotal" => Some(5),
_ => None,
}
}
/// Validate a promotion request before any write occurs.
///
/// Returns `Ok(())` when the request is valid. Returns the first validation
/// error found, checking: deprecated → evidence → reason → tier.
pub fn validate_promotion(
request: &PromotionRequest,
current_tier: &str,
is_deprecated: bool,
) -> Result<(), PromotionValidationError> {
if is_deprecated {
return Err(PromotionValidationError::ClaimDeprecated);
}
if request.evidence.is_empty() {
return Err(PromotionValidationError::MissingEvidence);
}
if request.reason.trim().is_empty() {
return Err(PromotionValidationError::MissingReason);
}
let current_num = tier_name_to_number(current_tier).unwrap_or(5);
let target_num = match tier_name_to_number(&request.target_tier) {
Some(n) => n,
None => {
return Err(PromotionValidationError::TierNotHigher {
current: current_num,
requested: 5,
});
}
};
if target_num >= current_num {
return Err(PromotionValidationError::TierNotHigher {
current: current_num,
requested: target_num,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tier_name_to_number_known_tiers() {
assert_eq!(tier_name_to_number("regulatory"), Some(0));
assert_eq!(tier_name_to_number("clinical"), Some(1));
assert_eq!(tier_name_to_number("observational"), Some(2));
assert_eq!(tier_name_to_number("team_policy"), Some(3));
assert_eq!(tier_name_to_number("expert"), Some(3));
assert_eq!(tier_name_to_number("community"), Some(4));
assert_eq!(tier_name_to_number("anecdotal"), Some(5));
}
#[test]
fn test_tier_name_to_number_case_insensitive() {
assert_eq!(tier_name_to_number("Regulatory"), Some(0));
assert_eq!(tier_name_to_number("CLINICAL"), Some(1));
assert_eq!(tier_name_to_number("Expert"), Some(3));
}
#[test]
fn test_tier_name_to_number_unknown() {
assert_eq!(tier_name_to_number(""), None);
assert_eq!(tier_name_to_number("tier3"), None);
}
#[test]
fn test_error_display_tier_not_higher() {
let err = PromotionValidationError::TierNotHigher { current: 3, requested: 4 };
let msg = err.to_string();
assert!(msg.contains('4'));
assert!(msg.contains('3'));
assert!(msg.contains("lower authority"));
}
#[test]
fn test_error_display_missing_evidence() {
assert!(PromotionValidationError::MissingEvidence.to_string().contains("evidence"));
}
#[test]
fn test_error_display_claim_not_found() {
let err = PromotionValidationError::ClaimNotFound("my-claim-001".to_string());
assert!(err.to_string().contains("my-claim-001"));
}
fn valid_request() -> PromotionRequest {
PromotionRequest {
claim_id: "test-001".to_string(),
target_tier: "expert".to_string(),
evidence: vec!["ADR-007".to_string()],
reason: "Adopted as org-wide standard".to_string(),
promoted_by: "jml".to_string(),
}
}
#[test]
fn test_validate_promotion_happy_path() {
let req = PromotionRequest { target_tier: "expert".to_string(), ..valid_request() };
assert!(validate_promotion(&req, "community", false).is_ok());
}
#[test]
fn test_validate_promotion_rejected_deprecated() {
let req = valid_request();
assert_eq!(
validate_promotion(&req, "community", true),
Err(PromotionValidationError::ClaimDeprecated)
);
}
#[test]
fn test_validate_promotion_rejected_missing_evidence() {
let req = PromotionRequest { evidence: vec![], ..valid_request() };
assert_eq!(
validate_promotion(&req, "community", false),
Err(PromotionValidationError::MissingEvidence)
);
}
#[test]
fn test_validate_promotion_rejected_empty_reason() {
let req = PromotionRequest { reason: " ".to_string(), ..valid_request() };
assert_eq!(
validate_promotion(&req, "community", false),
Err(PromotionValidationError::MissingReason)
);
}
#[test]
fn test_validate_promotion_rejected_same_tier() {
let req = PromotionRequest { target_tier: "expert".to_string(), ..valid_request() };
assert_eq!(
validate_promotion(&req, "expert", false),
Err(PromotionValidationError::TierNotHigher { current: 3, requested: 3 })
);
}
#[test]
fn test_validate_promotion_rejected_lower_tier() {
let req = PromotionRequest { target_tier: "clinical".to_string(), ..valid_request() };
assert_eq!(
validate_promotion(&req, "regulatory", false),
Err(PromotionValidationError::TierNotHigher { current: 0, requested: 1 })
);
}
#[test]
fn test_validate_promotion_anecdotal_to_regulatory() {
let req = PromotionRequest { target_tier: "regulatory".to_string(), ..valid_request() };
assert!(validate_promotion(&req, "anecdotal", false).is_ok());
}
#[test]
fn test_validate_promotion_unknown_target_tier() {
let req = PromotionRequest { target_tier: "mythical".to_string(), ..valid_request() };
assert!(matches!(
validate_promotion(&req, "community", false),
Err(PromotionValidationError::TierNotHigher { .. })
));
}
#[test]
fn test_promotion_result_serde_roundtrip() {
let result = PromotionResult {
original_claim_id: "test-001".to_string(),
new_claim_id: "test-002".to_string(),
previous_tier: "community".to_string(),
new_tier: "expert".to_string(),
success: true,
error: None,
};
let json = serde_json::to_string(&result).expect("serialization failed");
let restored: PromotionResult =
serde_json::from_str(&json).expect("deserialization failed");
assert!(restored.success);
assert!(restored.error.is_none());
}
}

View File

@ -74,6 +74,10 @@ pub struct ScanResult {
/// When present, contains per-claim PASS/CONFLICT/MISSING verdicts from
/// comparing observations against human-authored claims.
pub verify: Option<VerifyReport>,
/// Convergence suggestions from remote org claims (only populated when
/// `--suggest-convergence` is enabled and hosted mode is configured).
pub convergence_suggestions: Vec<crate::types::convergence::ConvergenceSuggestion>,
}
/// Timing breakdown for benchmark mode.
@ -117,6 +121,7 @@ impl ScanResult {
observations: vec![],
deprecated_usages: vec![],
verify: None,
convergence_suggestions: vec![],
}
}
@ -511,6 +516,7 @@ mod tests {
observations: vec![],
deprecated_usages: vec![],
verify: None,
convergence_suggestions: vec![],
};
assert!(!result.has_blocks());

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -133,7 +133,8 @@ pub use aphoria::{
// From stemedb_claims module
pub use stemedb_claims::{
AuthoredClaimDto, AuthoredValueDto, CreateClaimRequest, CreateClaimResponse,
AuthoredClaimDto, AuthoredValueDto, ClaimSearchQuery, ClaimStatsDto,
CreateClaimRequest, CreateClaimResponse,
};
// From subjects module

View File

@ -1,7 +1,8 @@
//! DTOs for StemeDB claims endpoints.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use utoipa::{IntoParams, ToSchema};
/// Request to create a claim in StemeDB.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@ -67,3 +68,40 @@ pub enum AuthoredValueDto {
/// Text value
Text(String),
}
/// Search query supporting glob/wildcard patterns.
#[derive(Debug, Deserialize, IntoParams, ToSchema)]
pub struct ClaimSearchQuery {
/// Glob pattern, e.g. "*/auth/*" or "maxwell/wallet/*"
pub concept_pattern: Option<String>,
/// Exact predicate or prefix match, e.g. "imported" or "key_"
pub predicate: Option<String>,
/// Filter by category: "security", "architecture", etc.
pub category: Option<String>,
/// Filter by authority tier number (0=regulatory, 1=clinical, 2=observational, 3=expert,
/// 4=community, 5=anecdotal). Only claims at or below this tier are returned.
pub max_tier: Option<u8>,
/// Filter by status: "active", "deprecated", "superseded"
pub status: Option<String>,
/// Max results (default 50, max 200)
pub limit: Option<usize>,
}
/// Statistics for a concept_path/predicate combination.
#[derive(Debug, Serialize, ToSchema)]
pub struct ClaimStatsDto {
/// concept_path of the queried claim
pub concept_path: String,
/// predicate of the queried claim
pub predicate: String,
/// Total claims matching this concept_path + predicate in the store
pub matching_claims: usize,
/// Breakdown by tier number: tier -> count
pub by_tier: HashMap<u8, usize>,
/// Breakdown by status string: status -> count
pub by_status: HashMap<String, usize>,
/// Most common value ("true"/"false" for booleans, actual value for strings/numbers)
pub most_common_value: Option<String>,
/// Whether this concept_path/predicate has Tier 0-1 (regulatory/clinical) backing
pub has_authoritative_backing: bool,
}

View File

@ -397,6 +397,7 @@ pub async fn verify_claims_handler(
format: "json".to_string(),
exit_code_enabled: false,
explain_authority: false,
suggest_convergence: false,
mode: ScanMode::Ephemeral, // Fast, no persistence
debug: false,
sync: false,
@ -465,6 +466,7 @@ pub async fn coverage(
format: "json".to_string(),
exit_code_enabled: false,
explain_authority: false,
suggest_convergence: false,
mode: ScanMode::Ephemeral,
debug: false,
sync: false,

View File

@ -57,6 +57,7 @@ pub async fn scan(
format: req.format.clone(),
exit_code_enabled: req.fail_on_flag,
explain_authority: false,
suggest_convergence: false,
mode: aphoria::ScanMode::Ephemeral,
debug: req.debug,
sync: false,

View File

@ -89,7 +89,11 @@ pub use aphoria::{
};
pub use stemedb_claims::{
create_claim as create_stemedb_claim, delete_claim as delete_stemedb_claim,
get_claim as get_stemedb_claim, list_claims as list_stemedb_claims,
create_claim as create_stemedb_claim,
delete_claim as delete_stemedb_claim,
get_claim as get_stemedb_claim,
get_claim_stats as get_stemedb_claim_stats,
list_claims as list_stemedb_claims,
search_claims as search_stemedb_claims,
};
pub use subjects::{list_predicates, list_subjects};

View File

@ -17,7 +17,10 @@ use stemedb_ingest::worker::serialize_assertion;
use stemedb_storage::{key_codec, KVStore};
use crate::{
dto::{AuthoredClaimDto, AuthoredValueDto, CreateClaimRequest, CreateClaimResponse},
dto::{
AuthoredClaimDto, AuthoredValueDto, ClaimSearchQuery, ClaimStatsDto,
CreateClaimRequest, CreateClaimResponse,
},
error::{ApiError, Result},
AppState,
};
@ -320,8 +323,8 @@ fn dto_to_assertion(dto: &AuthoredClaimDto) -> Result<Assertion> {
confidence: 1.0, // Authored claims have full confidence
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
.map(|d| d.as_secs())
.unwrap_or(0),
hlc_timestamp: Default::default(),
vector: None,
})
@ -431,3 +434,219 @@ fn source_class_to_tier_string(source_class: stemedb_core::types::SourceClass) -
}
.to_string()
}
/// Convert an authority tier string to its numeric tier number (0=regulatory, 5=anecdotal).
fn tier_string_to_number(tier: &str) -> Option<u8> {
match tier.to_lowercase().as_str() {
"regulatory" => Some(0),
"clinical" | "rfc" => Some(1),
"observational" | "team_policy" | "team-policy" => Some(2),
"expert" => Some(3),
"community" => Some(4),
"anecdotal" => Some(5),
_ => None,
}
}
/// Convert an `AuthoredValueDto` to a display string.
fn value_to_string(value: &AuthoredValueDto) -> String {
match value {
AuthoredValueDto::Bool(b) => b.to_string(),
AuthoredValueDto::Number(n) => n.to_string(),
AuthoredValueDto::Text(s) => s.clone(),
}
}
/// Simple wildcard glob matcher. `*` matches any number of path segments or characters.
///
/// - `*` alone matches everything
/// - `*/auth/*` matches `maxwell/wallet/auth/jwt`
/// - No leading wildcard anchors to the start of the string
fn matches_pattern(pattern: &str, subject: &str) -> bool {
if pattern == "*" {
return true;
}
let parts: Vec<&str> = pattern.split('*').collect();
if parts.is_empty() {
return true;
}
let mut remaining = subject;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
// First non-empty part with no leading '*': must match at start
if !remaining.starts_with(part) {
return false;
}
remaining = &remaining[part.len()..];
} else if i == parts.len() - 1 && !pattern.ends_with('*') {
// Last part and pattern doesn't end with '*': must match at end
return remaining.ends_with(part);
} else {
// Middle part: find anywhere in remaining
if let Some(pos) = remaining.find(part) {
remaining = &remaining[pos + part.len()..];
} else {
return false;
}
}
}
true
}
/// Scan all claim assertions from the store (shared by search and stats handlers).
async fn fetch_all_claims(state: &AppState) -> Result<Vec<AuthoredClaimDto>> {
let subjects_prefix = b"\x00SUBJECTS:claim://";
let subject_entries = state.store.scan_prefix(subjects_prefix).await?;
let mut claims = Vec::new();
for (key, _) in subject_entries {
if let Some(subject) = key_codec::extract_subject_from_subjects_key(&key) {
let subject_key = key_codec::subject_index_key(&subject);
let hash_list = state.store.scan_prefix(&subject_key).await?;
for (_, hash_bytes) in hash_list {
let hash_hex = hex::encode(&hash_bytes);
let assertion_key = key_codec::assertion_key(&subject, &hash_hex);
if let Some(data) = state.store.get(&assertion_key).await? {
if let Ok(assertion) =
stemedb_core::serde::deserialize::<stemedb_core::types::Assertion>(&data)
{
if let Ok(dto) = assertion_to_dto(&assertion) {
claims.push(dto);
}
}
}
}
}
}
Ok(claims)
}
// ============================================================================
// Search & Stats Handlers
// ============================================================================
/// Search claims using glob/wildcard patterns and tier/category/status filters.
#[utoipa::path(
get,
path = "/v1/claims/search",
params(ClaimSearchQuery),
responses(
(status = 200, description = "Search results", body = Vec<AuthoredClaimDto>),
(status = 500, description = "Internal server error")
),
tag = "claims"
)]
pub async fn search_claims(
State(state): State<AppState>,
axum::extract::Query(query): axum::extract::Query<ClaimSearchQuery>,
) -> Result<Json<Vec<AuthoredClaimDto>>> {
info!(
concept_pattern = ?query.concept_pattern,
predicate = ?query.predicate,
category = ?query.category,
max_tier = ?query.max_tier,
status = ?query.status,
"Searching claims in StemeDB"
);
let limit = query.limit.unwrap_or(50).min(200);
let mut claims = fetch_all_claims(&state).await?;
if let Some(ref pattern) = query.concept_pattern {
claims.retain(|c| matches_pattern(pattern, &c.concept_path));
}
if let Some(ref predicate) = query.predicate {
claims.retain(|c| c.predicate == *predicate || c.predicate.starts_with(predicate.as_str()));
}
if let Some(ref category) = query.category {
claims.retain(|c| c.category == *category);
}
if let Some(max_tier) = query.max_tier {
claims.retain(|c| {
tier_string_to_number(&c.authority_tier)
.map(|t| t <= max_tier)
.unwrap_or(false)
});
}
if let Some(ref status) = query.status {
claims.retain(|c| c.status == *status);
}
claims.truncate(limit);
Ok(Json(claims))
}
/// Get adoption statistics for a concept_path/predicate combination.
///
/// Accepts `concept_path` and `predicate` as query params to avoid
/// slash-in-path-param routing ambiguity.
#[utoipa::path(
get,
path = "/v1/claims/stats",
params(
("concept_path" = String, Query, description = "Concept path to analyze"),
("predicate" = String, Query, description = "Predicate to analyze"),
),
responses(
(status = 200, description = "Claim statistics", body = ClaimStatsDto),
(status = 400, description = "Missing concept_path or predicate"),
(status = 500, description = "Internal server error")
),
tag = "claims"
)]
pub async fn get_claim_stats(
State(state): State<AppState>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<Json<ClaimStatsDto>> {
let concept_path = params
.get("concept_path")
.ok_or_else(|| ApiError::InvalidRequest("Missing query param: concept_path".to_string()))?
.clone();
let predicate = params
.get("predicate")
.ok_or_else(|| ApiError::InvalidRequest("Missing query param: predicate".to_string()))?
.clone();
info!(concept_path, predicate, "Getting claim stats from StemeDB");
let all_claims = fetch_all_claims(&state).await?;
let matching: Vec<&AuthoredClaimDto> = all_claims
.iter()
.filter(|c| c.concept_path == concept_path && c.predicate == predicate)
.collect();
let mut by_tier: std::collections::HashMap<u8, usize> = std::collections::HashMap::new();
let mut by_status: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut value_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut has_authoritative_backing = false;
for claim in &matching {
if let Some(tier) = tier_string_to_number(&claim.authority_tier) {
*by_tier.entry(tier).or_insert(0) += 1;
if tier <= 1 {
has_authoritative_backing = true;
}
}
*by_status.entry(claim.status.clone()).or_insert(0) += 1;
*value_counts.entry(value_to_string(&claim.value)).or_insert(0) += 1;
}
let most_common_value = value_counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(val, _)| val);
Ok(Json(ClaimStatsDto {
concept_path,
predicate,
matching_claims: matching.len(),
by_tier,
by_status,
most_common_value,
has_authoritative_backing,
}))
}

View File

@ -448,7 +448,11 @@ fn build_api_routes(config: &SecurityConfig) -> Router<AppState> {
.route("/v1/meter/quota", get(handlers::get_quota_status))
.route("/v1/provenance/{hash}", get(handlers::get_provenance))
// Claims endpoints (StemeDB-backed)
// NOTE: literal routes (/search, /stats) registered before parameterized
// routes (/:concept_path/:predicate) so axum resolves them first.
.route("/v1/claims", get(handlers::list_stemedb_claims))
.route("/v1/claims/search", get(handlers::search_stemedb_claims))
.route("/v1/claims/stats", get(handlers::get_stemedb_claim_stats))
.route("/v1/claims/:concept_path/:predicate", get(handlers::get_stemedb_claim))
.route(
"/v1/claims/:concept_path/:predicate",