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