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:
parent
4096967c20
commit
200b84751e
@ -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?;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
623
applications/aphoria/src/convergence.rs
Normal file
623
applications/aphoria/src/convergence.rs
Normal 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 (0–5).
|
||||
///
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
411
applications/aphoria/src/handlers/promote.rs
Normal file
411
applications/aphoria/src/handlers/promote.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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::{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -7,4 +7,4 @@ pub mod cache;
|
||||
pub mod client;
|
||||
|
||||
pub use cache::ClaimCache;
|
||||
pub use client::RemoteClaimStore;
|
||||
pub use client::{ClaimStatsResult, RemoteClaimStore};
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -478,6 +478,7 @@ mod tests {
|
||||
observations: vec![],
|
||||
deprecated_usages: vec![],
|
||||
verify: None,
|
||||
convergence_suggestions: vec![],
|
||||
};
|
||||
|
||||
let output = formatter.format(&result);
|
||||
|
||||
@ -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![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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![],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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.
|
||||
|
||||
206
applications/aphoria/src/types/convergence.rs
Normal file
206
applications/aphoria/src/types/convergence.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
307
applications/aphoria/src/types/promotion.rs
Normal file
307
applications/aphoria/src/types/promotion.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
|
||||
2
applications/stemedb-dashboard/next-env.d.ts
vendored
2
applications/stemedb-dashboard/next-env.d.ts
vendored
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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};
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user