From 200b84751eadfdfabb2db07ff0e53950a294fcbe Mon Sep 17 00:00:00 2001 From: jml Date: Wed, 25 Feb 2026 08:21:37 +0000 Subject: [PATCH] feat: add claims search, promote, stats commands and convergence engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- applications/aphoria/src/baseline.rs | 1 + applications/aphoria/src/cli/claims.rs | 62 ++ applications/aphoria/src/cli/mod.rs | 5 + applications/aphoria/src/convergence.rs | 623 ++++++++++++++++++ applications/aphoria/src/handlers/claims.rs | 381 +++++++++++ applications/aphoria/src/handlers/mod.rs | 7 + applications/aphoria/src/handlers/promote.rs | 411 ++++++++++++ applications/aphoria/src/handlers/scan.rs | 44 +- applications/aphoria/src/lib.rs | 7 + applications/aphoria/src/remote/client.rs | 143 ++++ applications/aphoria/src/remote/mod.rs | 2 +- applications/aphoria/src/report/json.rs | 41 ++ applications/aphoria/src/report/markdown.rs | 77 +++ applications/aphoria/src/report/sarif.rs | 1 + applications/aphoria/src/report/table.rs | 53 ++ applications/aphoria/src/scan/scanner.rs | 1 + .../aphoria/src/tests/conflict_detection.rs | 3 + .../aphoria/src/tests/day3_debugging.rs | 5 + .../aphoria/src/tests/drift_detection.rs | 4 + applications/aphoria/src/tests/golden_path.rs | 1 + applications/aphoria/src/tests/scan_basic.rs | 1 + applications/aphoria/src/tests/scan_modes.rs | 10 + .../aphoria/src/tests/staged_scanning.rs | 1 + applications/aphoria/src/types/command.rs | 4 + applications/aphoria/src/types/convergence.rs | 206 ++++++ applications/aphoria/src/types/mod.rs | 2 + applications/aphoria/src/types/promotion.rs | 307 +++++++++ applications/aphoria/src/types/result.rs | 6 + applications/stemedb-dashboard/next-env.d.ts | 2 +- crates/stemedb-api/src/dto/mod.rs | 3 +- crates/stemedb-api/src/dto/stemedb_claims.rs | 40 +- .../src/handlers/aphoria/claims.rs | 2 + .../stemedb-api/src/handlers/aphoria/scan.rs | 1 + crates/stemedb-api/src/handlers/mod.rs | 8 +- .../src/handlers/stemedb_claims.rs | 225 ++++++- crates/stemedb-api/src/routers.rs | 4 + 36 files changed, 2684 insertions(+), 10 deletions(-) create mode 100644 applications/aphoria/src/convergence.rs create mode 100644 applications/aphoria/src/handlers/promote.rs create mode 100644 applications/aphoria/src/types/convergence.rs create mode 100644 applications/aphoria/src/types/promotion.rs diff --git a/applications/aphoria/src/baseline.rs b/applications/aphoria/src/baseline.rs index 1d9014c..49e524d 100644 --- a/applications/aphoria/src/baseline.rs +++ b/applications/aphoria/src/baseline.rs @@ -53,6 +53,7 @@ pub async fn show_diff(config: &AphoriaConfig) -> Result { strict: false, show_observations: false, explain_authority: false, + suggest_convergence: false, }; let result = run_scan(args, config).await?; diff --git a/applications/aphoria/src/cli/claims.rs b/applications/aphoria/src/cli/claims.rs index 1d9c87b..458430e 100644 --- a/applications/aphoria/src/cli/claims.rs +++ b/applications/aphoria/src/cli/claims.rs @@ -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, + + /// Filter by predicate + #[arg(long)] + predicate: Option, + + /// Filter by category + #[arg(long)] + category: Option, + + /// Maximum tier number to include (0=regulatory ... 5=anecdotal) + #[arg(long)] + max_tier: Option, + + /// 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, + + /// 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, + }, } diff --git a/applications/aphoria/src/cli/mod.rs b/applications/aphoria/src/cli/mod.rs index dc02662..8fa934f 100644 --- a/applications/aphoria/src/cli/mod.rs +++ b/applications/aphoria/src/cli/mod.rs @@ -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) diff --git a/applications/aphoria/src/convergence.rs b/applications/aphoria/src/convergence.rs new file mode 100644 index 0000000..b3211f5 --- /dev/null +++ b/applications/aphoria/src/convergence.rs @@ -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` sorted Authoritative → Advisory → +/// Informational. +pub fn compute_convergence_suggestions( + local_observations: &[Observation], + remote_claims: &[AuthoredClaim], + max_suggestion_tier: Option, +) -> Vec { + let max_tier = max_suggestion_tier.unwrap_or(5); + + // Pre-compute the tier number for every remote claim once. + let claim_tiers: Vec = + 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 = 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"); + } +} diff --git a/applications/aphoria/src/handlers/claims.rs b/applications/aphoria/src/handlers/claims.rs index 14e7db7..4ae3f86 100644 --- a/applications/aphoria/src/handlers/claims.rs +++ b/applications/aphoria/src/handlers/claims.rs @@ -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, + predicate: Option, + category: Option, + max_tier: Option, + 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) = + match load_claims_with_migration(&root, config).await { + Ok(v) => v, + Err(code) => return code, + }; + + let mut claims: Vec = 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, + 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) = + 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 = std::collections::HashMap::new(); + let mut by_status: std::collections::HashMap = 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 = 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) + } + } +} diff --git a/applications/aphoria/src/handlers/mod.rs b/applications/aphoria/src/handlers/mod.rs index 126e3c1..21bb5e1 100644 --- a/applications/aphoria/src/handlers/mod.rs +++ b/applications/aphoria/src/handlers/mod.rs @@ -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 { diff --git a/applications/aphoria/src/handlers/promote.rs b/applications/aphoria/src/handlers/promote.rs new file mode 100644 index 0000000..26dd816 --- /dev/null +++ b/applications/aphoria/src/handlers/promote.rs @@ -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 { + 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) -> 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" + ); + } +} diff --git a/applications/aphoria/src/handlers/scan.rs b/applications/aphoria/src/handlers/scan.rs index c025a04..7b3508b 100644 --- a/applications/aphoria/src/handlers/scan.rs +++ b/applications/aphoria/src/handlers/scan.rs @@ -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 { diff --git a/applications/aphoria/src/lib.rs b/applications/aphoria/src/lib.rs index 33c0e85..3acd5fd 100644 --- a/applications/aphoria/src/lib.rs +++ b/applications/aphoria/src/lib.rs @@ -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::{ diff --git a/applications/aphoria/src/remote/client.rs b/applications/aphoria/src/remote/client.rs index 7a0b699..2cffa4e 100644 --- a/applications/aphoria/src/remote/client.rs +++ b/applications/aphoria/src/remote/client.rs @@ -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, } +#[derive(Debug, Clone, Serialize)] +struct SearchClaimsQuery { + #[serde(skip_serializing_if = "Option::is_none")] + concept_pattern: Option, + #[serde(skip_serializing_if = "Option::is_none")] + predicate: Option, + #[serde(skip_serializing_if = "Option::is_none")] + category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_tier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + limit: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct SearchClaimsResponse { + claims: Vec, +} + +#[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, + /// Claim count broken down by status string (e.g. "active", "deprecated"). + pub by_status: HashMap, + /// The most frequently occurring value across matching claims, if any. + pub most_common_value: Option, + /// 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, + limit: Option, + ) -> Result, 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::("GET", &path, None::<&()>) { + Ok(response) => { + let claims: Vec = + 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 { + 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::("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 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); diff --git a/applications/aphoria/src/remote/mod.rs b/applications/aphoria/src/remote/mod.rs index b756d41..0d16e6c 100644 --- a/applications/aphoria/src/remote/mod.rs +++ b/applications/aphoria/src/remote/mod.rs @@ -7,4 +7,4 @@ pub mod cache; pub mod client; pub use cache::ClaimCache; -pub use client::RemoteClaimStore; +pub use client::{ClaimStatsResult, RemoteClaimStore}; diff --git a/applications/aphoria/src/report/json.rs b/applications/aphoria/src/report/json.rs index e300550..aca623c 100644 --- a/applications/aphoria/src/report/json.rs +++ b/applications/aphoria/src/report/json.rs @@ -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 = 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); diff --git a/applications/aphoria/src/report/markdown.rs b/applications/aphoria/src/report/markdown.rs index cbf0e95..3dcc22e 100644 --- a/applications/aphoria/src/report/markdown.rs +++ b/applications/aphoria/src/report/markdown.rs @@ -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); diff --git a/applications/aphoria/src/report/sarif.rs b/applications/aphoria/src/report/sarif.rs index 69a03cb..ecb45ec 100644 --- a/applications/aphoria/src/report/sarif.rs +++ b/applications/aphoria/src/report/sarif.rs @@ -478,6 +478,7 @@ mod tests { observations: vec![], deprecated_usages: vec![], verify: None, + convergence_suggestions: vec![], }; let output = formatter.format(&result); diff --git a/applications/aphoria/src/report/table.rs b/applications/aphoria/src/report/table.rs index 20ca4c9..af3d525 100644 --- a/applications/aphoria/src/report/table.rs +++ b/applications/aphoria/src/report/table.rs @@ -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![], } } diff --git a/applications/aphoria/src/scan/scanner.rs b/applications/aphoria/src/scan/scanner.rs index befcbc6..5a11ab7 100644 --- a/applications/aphoria/src/scan/scanner.rs +++ b/applications/aphoria/src/scan/scanner.rs @@ -153,6 +153,7 @@ pub async fn run_scan(args: ScanArgs, config: &AphoriaConfig) -> Result, + + /// 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, +} + +/// 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); + } +} diff --git a/applications/aphoria/src/types/mod.rs b/applications/aphoria/src/types/mod.rs index a739740..a2724c5 100644 --- a/applications/aphoria/src/types/mod.rs +++ b/applications/aphoria/src/types/mod.rs @@ -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; diff --git a/applications/aphoria/src/types/promotion.rs b/applications/aphoria/src/types/promotion.rs new file mode 100644 index 0000000..019d645 --- /dev/null +++ b/applications/aphoria/src/types/promotion.rs @@ -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, + + /// 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, +} + +/// 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 { + 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()); + } +} diff --git a/applications/aphoria/src/types/result.rs b/applications/aphoria/src/types/result.rs index 35576a8..fa4b9b5 100644 --- a/applications/aphoria/src/types/result.rs +++ b/applications/aphoria/src/types/result.rs @@ -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, + + /// Convergence suggestions from remote org claims (only populated when + /// `--suggest-convergence` is enabled and hosted mode is configured). + pub convergence_suggestions: Vec, } /// 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()); diff --git a/applications/stemedb-dashboard/next-env.d.ts b/applications/stemedb-dashboard/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/applications/stemedb-dashboard/next-env.d.ts +++ b/applications/stemedb-dashboard/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/crates/stemedb-api/src/dto/mod.rs b/crates/stemedb-api/src/dto/mod.rs index d319d6e..dcc52eb 100644 --- a/crates/stemedb-api/src/dto/mod.rs +++ b/crates/stemedb-api/src/dto/mod.rs @@ -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 diff --git a/crates/stemedb-api/src/dto/stemedb_claims.rs b/crates/stemedb-api/src/dto/stemedb_claims.rs index 77ba7ba..7aadb33 100644 --- a/crates/stemedb-api/src/dto/stemedb_claims.rs +++ b/crates/stemedb-api/src/dto/stemedb_claims.rs @@ -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, + /// Exact predicate or prefix match, e.g. "imported" or "key_" + pub predicate: Option, + /// Filter by category: "security", "architecture", etc. + pub category: Option, + /// 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, + /// Filter by status: "active", "deprecated", "superseded" + pub status: Option, + /// Max results (default 50, max 200) + pub limit: Option, +} + +/// 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, + /// Breakdown by status string: status -> count + pub by_status: HashMap, + /// Most common value ("true"/"false" for booleans, actual value for strings/numbers) + pub most_common_value: Option, + /// Whether this concept_path/predicate has Tier 0-1 (regulatory/clinical) backing + pub has_authoritative_backing: bool, +} diff --git a/crates/stemedb-api/src/handlers/aphoria/claims.rs b/crates/stemedb-api/src/handlers/aphoria/claims.rs index 7cc614c..f4ec573 100644 --- a/crates/stemedb-api/src/handlers/aphoria/claims.rs +++ b/crates/stemedb-api/src/handlers/aphoria/claims.rs @@ -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, diff --git a/crates/stemedb-api/src/handlers/aphoria/scan.rs b/crates/stemedb-api/src/handlers/aphoria/scan.rs index 2411370..00b28d6 100644 --- a/crates/stemedb-api/src/handlers/aphoria/scan.rs +++ b/crates/stemedb-api/src/handlers/aphoria/scan.rs @@ -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, diff --git a/crates/stemedb-api/src/handlers/mod.rs b/crates/stemedb-api/src/handlers/mod.rs index 691a754..2dab64c 100644 --- a/crates/stemedb-api/src/handlers/mod.rs +++ b/crates/stemedb-api/src/handlers/mod.rs @@ -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}; diff --git a/crates/stemedb-api/src/handlers/stemedb_claims.rs b/crates/stemedb-api/src/handlers/stemedb_claims.rs index 3f7a2c2..a91f74e 100644 --- a/crates/stemedb-api/src/handlers/stemedb_claims.rs +++ b/crates/stemedb-api/src/handlers/stemedb_claims.rs @@ -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 { 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 { + 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> { + 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::(&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), + (status = 500, description = "Internal server error") + ), + tag = "claims" +)] +pub async fn search_claims( + State(state): State, + axum::extract::Query(query): axum::extract::Query, +) -> Result>> { + 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, + axum::extract::Query(params): axum::extract::Query>, +) -> Result> { + 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 = std::collections::HashMap::new(); + let mut by_status: std::collections::HashMap = std::collections::HashMap::new(); + let mut value_counts: std::collections::HashMap = + 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, + })) +} diff --git a/crates/stemedb-api/src/routers.rs b/crates/stemedb-api/src/routers.rs index ee1fca6..243be33 100644 --- a/crates/stemedb-api/src/routers.rs +++ b/crates/stemedb-api/src/routers.rs @@ -448,7 +448,11 @@ fn build_api_routes(config: &SecurityConfig) -> Router { .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",