Implements Phase 4 (A4) - Community corpus as first-class citizens: - **Community Corpus Builder** - Queries StemeDB pattern aggregates - **Wiki Import** - Bootstrap corpus from markdown docs (aphoria corpus import wiki) - **Pattern Aggregation** - Automatic learning from local scans (--sync flag) - **Storage Layer** - StemeDBPatternStore with content-addressed deduplication - **Promotion Logic** - Multi-tier thresholds (95%/80%/50% adoption rates) - **Corpus Build** - Unified registry for RFC/OWASP/Vendor/Community sources - **Trust Packs** - Export corpus as signed, distributable artifacts - **Documentation** - bootstrap-corpus.md guide + CLI reference updates Technical details: - Pattern aggregates stored as assertions with predicate "pattern_aggregate" - Content-addressed subjects via BLAKE3(subject:predicate:value) - PatternAggregator handles write path (observations → patterns) - StemeDBPatternStore handles read path (pattern queries) - Integration tests + fixtures in tests/wiki_import_test.rs Deleted hardcoded.rs (368 lines) - corpus now fully emergent from StemeDB. Deleted enriched-corpus-patterns.md (677 lines) - feature shipped. Closes VG-026 (community corpus), part of A4 milestone. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1143 lines
40 KiB
Rust
1143 lines
40 KiB
Rust
//! Verification engine for matching authored claims against observations.
|
|
//!
|
|
//! This module compares what developers have declared in `.aphoria/claims.toml`
|
|
//! against what extractors actually find in code. It produces a `VerifyReport`
|
|
//! with pass/conflict/missing/unclaimed verdicts for each claim.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use serde::Serialize;
|
|
use stemedb_core::types::ObjectValue;
|
|
|
|
use crate::types::authored_claim::{AuthoredClaim, ClaimStatus, ComparisonMode};
|
|
use crate::types::Observation;
|
|
|
|
/// Result of verifying a single claim against observations.
|
|
#[derive(Debug, Clone)]
|
|
pub struct VerifyResult {
|
|
/// The claim being verified (None for unclaimed observations).
|
|
pub claim: Option<AuthoredClaim>,
|
|
/// The verdict: pass, conflict, missing, or unclaimed.
|
|
pub verdict: AuditVerdict,
|
|
/// Observations that matched this claim's tail-path.
|
|
pub matching_observations: Vec<Observation>,
|
|
/// Human-readable explanation of the verdict.
|
|
pub explanation: String,
|
|
}
|
|
|
|
/// Verdict for a single claim verification.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum AuditVerdict {
|
|
/// Observation matches the claim.
|
|
Pass,
|
|
/// Observation contradicts the claim.
|
|
Conflict,
|
|
/// No matching observation found for the claim.
|
|
Missing,
|
|
/// Observation exists but has no covering claim (only for unclaimed observations).
|
|
Unclaimed,
|
|
}
|
|
|
|
impl std::fmt::Display for AuditVerdict {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
AuditVerdict::Pass => write!(f, "PASS"),
|
|
AuditVerdict::Conflict => write!(f, "CONFLICT"),
|
|
AuditVerdict::Missing => write!(f, "MISSING"),
|
|
AuditVerdict::Unclaimed => write!(f, "UNCLAIMED"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Summary counts for a verification report.
|
|
#[derive(Debug, Clone, Default, Serialize)]
|
|
pub struct VerifySummary {
|
|
/// Total number of active claims verified.
|
|
pub total_claims: usize,
|
|
/// Claims whose observations match.
|
|
pub pass: usize,
|
|
/// Claims contradicted by observations.
|
|
pub conflict: usize,
|
|
/// Claims with no matching observations.
|
|
pub missing: usize,
|
|
/// Observations with no covering claim.
|
|
pub unclaimed: usize,
|
|
}
|
|
|
|
/// Full verification report: per-claim results plus summary.
|
|
#[derive(Debug, Clone)]
|
|
pub struct VerifyReport {
|
|
/// Per-claim results.
|
|
pub results: Vec<VerifyResult>,
|
|
/// Aggregate counts.
|
|
pub summary: VerifySummary,
|
|
}
|
|
|
|
/// Extract the tail path (last 2 segments) from a concept path.
|
|
///
|
|
/// Mirrors `ConceptIndex::make_key` logic but without the predicate suffix.
|
|
///
|
|
/// # Examples
|
|
/// - `"code://rust/myapp/tls/cert_verification"` → `Some("tls/cert_verification")`
|
|
/// - `"maxwell/wallet/atomics/ordering"` → `Some("atomics/ordering")`
|
|
/// - `"single"` → `None` (fewer than 2 segments)
|
|
pub fn tail_path(concept_path: &str) -> Option<String> {
|
|
// Strip scheme if present
|
|
let path = concept_path.find("://").map(|i| &concept_path[i + 3..]).unwrap_or(concept_path);
|
|
|
|
let mut segments = path.rsplit('/').filter(|s| !s.is_empty());
|
|
let tail2 = segments.next()?;
|
|
let tail1 = segments.next()?;
|
|
Some(format!("{tail1}/{tail2}"))
|
|
}
|
|
|
|
/// Verify authored claims against extracted observations.
|
|
///
|
|
/// For each active claim:
|
|
/// 1. Compute the tail-path from the claim's concept_path
|
|
/// 2. Look up observations with matching tail-paths
|
|
/// 3. Apply the claim's `ComparisonMode` to determine the verdict
|
|
///
|
|
/// Observations whose tail-paths are not covered by any claim are reported
|
|
/// as `Unclaimed`.
|
|
pub fn verify_claims(claims: &[AuthoredClaim], observations: &[Observation]) -> VerifyReport {
|
|
// Index observations by tail-path
|
|
let mut obs_by_tail: HashMap<String, Vec<&Observation>> = HashMap::new();
|
|
for obs in observations {
|
|
if let Some(tp) = tail_path(&obs.concept_path) {
|
|
obs_by_tail.entry(tp).or_default().push(obs);
|
|
}
|
|
}
|
|
|
|
let mut results = Vec::new();
|
|
let mut claimed_tails: HashMap<String, bool> = HashMap::new();
|
|
let mut summary = VerifySummary::default();
|
|
|
|
// Verify each active claim
|
|
for claim in claims {
|
|
if claim.status != ClaimStatus::Active {
|
|
continue;
|
|
}
|
|
|
|
// Check if claim path contains wildcard
|
|
let has_wildcard = claim.concept_path.contains("/*");
|
|
|
|
let matching: Vec<&Observation> = if has_wildcard {
|
|
// Wildcard mode: match against observation full concept paths
|
|
let mut matched_obs = Vec::new();
|
|
for (obs_tail, obs_list) in &obs_by_tail {
|
|
// Check each observation's full concept_path against the wildcard pattern
|
|
for obs in obs_list.iter() {
|
|
// Strip scheme (e.g., "code://rust/" or "rfc://") from observation's concept_path
|
|
let obs_path = if let Some(scheme_end) = obs.concept_path.find("://") {
|
|
// Find the first '/' after the scheme
|
|
let after_scheme = &obs.concept_path[scheme_end + 3..];
|
|
if let Some(slash_pos) = after_scheme.find('/') {
|
|
&after_scheme[slash_pos + 1..]
|
|
} else {
|
|
after_scheme
|
|
}
|
|
} else {
|
|
&obs.concept_path
|
|
};
|
|
|
|
if wildcard_matches(&claim.concept_path, obs_path)
|
|
&& obs.predicate == claim.predicate
|
|
{
|
|
// Mark this tail as claimed
|
|
claimed_tails.insert(obs_tail.clone(), true);
|
|
matched_obs.push(*obs);
|
|
}
|
|
}
|
|
}
|
|
matched_obs
|
|
} else {
|
|
// Exact mode: use tail-path lookup
|
|
let tp = match tail_path(&claim.concept_path) {
|
|
Some(tp) => tp,
|
|
None => {
|
|
results.push(VerifyResult {
|
|
claim: Some(claim.clone()),
|
|
verdict: AuditVerdict::Missing,
|
|
matching_observations: Vec::new(),
|
|
explanation: format!(
|
|
"Cannot compute tail-path from concept_path '{}'",
|
|
claim.concept_path
|
|
),
|
|
});
|
|
summary.missing += 1;
|
|
summary.total_claims += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
claimed_tails.insert(tp.clone(), true);
|
|
|
|
obs_by_tail
|
|
.get(&tp)
|
|
.map(|v| v.as_slice())
|
|
.unwrap_or(&[])
|
|
.iter()
|
|
.filter(|obs| {
|
|
// Filter by predicate
|
|
if obs.predicate != claim.predicate {
|
|
return false;
|
|
}
|
|
|
|
// Also check that the observation's full path matches the claim's path prefix
|
|
// Strip scheme from observation's concept_path
|
|
let obs_path = if let Some(scheme_end) = obs.concept_path.find("://") {
|
|
let after_scheme = &obs.concept_path[scheme_end + 3..];
|
|
if let Some(slash_pos) = after_scheme.find('/') {
|
|
&after_scheme[slash_pos + 1..]
|
|
} else {
|
|
after_scheme
|
|
}
|
|
} else {
|
|
&obs.concept_path
|
|
};
|
|
|
|
// Check if observation path starts with claim path (without the tail)
|
|
// E.g., claim "maxwell/core/imports/serde" should only match observations
|
|
// under "maxwell/core/", not "maxwell/hypervisor/"
|
|
let claim_segments: Vec<&str> = claim.concept_path.split('/').collect();
|
|
let obs_segments: Vec<&str> = obs_path.split('/').collect();
|
|
|
|
// Check if all claim segments (except last 2, which are the tail) match observation
|
|
if claim_segments.len() > 2 {
|
|
let claim_prefix = &claim_segments[..claim_segments.len() - 2];
|
|
let obs_prefix = if obs_segments.len() >= claim_prefix.len() {
|
|
&obs_segments[..claim_prefix.len()]
|
|
} else {
|
|
&obs_segments[..]
|
|
};
|
|
|
|
claim_prefix == obs_prefix
|
|
} else {
|
|
// Claim has no prefix (<=2 segments), so just match by tail
|
|
true
|
|
}
|
|
})
|
|
.copied()
|
|
.collect()
|
|
};
|
|
|
|
let claim_obj_value = claim.value.to_object_value();
|
|
let (verdict, explanation) = match claim.comparison {
|
|
ComparisonMode::Equals => {
|
|
if matching.is_empty() {
|
|
(AuditVerdict::Missing, "No matching observation found".to_string())
|
|
} else if matching.iter().any(|o| o.value == claim_obj_value) {
|
|
(
|
|
AuditVerdict::Pass,
|
|
format!("Observation matches claim value: {}", claim.value),
|
|
)
|
|
} else {
|
|
let found_values: Vec<String> =
|
|
matching.iter().map(|o| format!("{:?}", o.value)).collect();
|
|
(
|
|
AuditVerdict::Conflict,
|
|
format!("Expected {}, found: {}", claim.value, found_values.join(", ")),
|
|
)
|
|
}
|
|
}
|
|
ComparisonMode::NotEquals => {
|
|
if matching.is_empty() {
|
|
// No observations means no contradiction — pass
|
|
(AuditVerdict::Pass, "No observations found (no contradiction)".to_string())
|
|
} else if matching.iter().any(|o| o.value == claim_obj_value) {
|
|
(
|
|
AuditVerdict::Conflict,
|
|
format!("Found observation with forbidden value: {}", claim.value),
|
|
)
|
|
} else {
|
|
(AuditVerdict::Pass, "All observations differ from forbidden value".to_string())
|
|
}
|
|
}
|
|
ComparisonMode::Present => {
|
|
if matching.is_empty() {
|
|
(
|
|
AuditVerdict::Missing,
|
|
"Expected observation to be present, but none found".to_string(),
|
|
)
|
|
} else {
|
|
(
|
|
AuditVerdict::Pass,
|
|
format!("Found {} matching observation(s)", matching.len()),
|
|
)
|
|
}
|
|
}
|
|
ComparisonMode::Absent => {
|
|
// Find observations that match the claim's specific value
|
|
let matching_value: Vec<&Observation> =
|
|
matching.iter().filter(|obs| obs.value == claim_obj_value).copied().collect();
|
|
|
|
if matching_value.is_empty() {
|
|
// The specific value is NOT present - this is what we want
|
|
(AuditVerdict::Pass, "Forbidden value not found (as expected)".to_string())
|
|
} else {
|
|
// The specific value IS present - conflict
|
|
let locations: Vec<String> =
|
|
matching_value.iter().map(|o| format!("{}:{}", o.file, o.line)).collect();
|
|
(
|
|
AuditVerdict::Conflict,
|
|
format!(
|
|
"Expected value {} to be absent, but found at: {}",
|
|
claim.value,
|
|
locations.join(", ")
|
|
),
|
|
)
|
|
}
|
|
}
|
|
ComparisonMode::Contains => {
|
|
if matching.is_empty() {
|
|
(AuditVerdict::Missing, "No observations found to check contains".to_string())
|
|
} else {
|
|
// Check if ANY observation contains the claim value
|
|
let found_containing = matching.iter().any(|obs| {
|
|
match (&obs.value, &claim_obj_value) {
|
|
(ObjectValue::Text(obs_str), ObjectValue::Text(claim_str)) => {
|
|
// Check if claim value appears as:
|
|
// 1. Exact match (obs == claim)
|
|
// 2. Substring (obs.contains(claim))
|
|
// 3. List element (split on comma and check)
|
|
if obs_str == claim_str {
|
|
return true;
|
|
}
|
|
if obs_str.contains(claim_str.as_str()) {
|
|
return true;
|
|
}
|
|
// For comma-separated lists, check if it's a complete element
|
|
obs_str.split(',').any(|element| element.trim() == claim_str)
|
|
}
|
|
_ => obs.value == claim_obj_value, // Fallback to exact equality
|
|
}
|
|
});
|
|
|
|
if found_containing {
|
|
(
|
|
AuditVerdict::Pass,
|
|
format!("Found observation containing '{}'", claim.value),
|
|
)
|
|
} else {
|
|
let found_values: Vec<String> =
|
|
matching.iter().map(|o| format!("{:?}", o.value)).collect();
|
|
(
|
|
AuditVerdict::Conflict,
|
|
format!(
|
|
"Expected observation to contain '{}', found: {}",
|
|
claim.value,
|
|
found_values.join(", ")
|
|
),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
ComparisonMode::NotContains => {
|
|
// Find observations that contain the claim value
|
|
let matching_containing: Vec<&Observation> = matching
|
|
.iter()
|
|
.filter(|obs| match (&obs.value, &claim_obj_value) {
|
|
(ObjectValue::Text(obs_str), ObjectValue::Text(claim_str)) => {
|
|
// Check substring or list element
|
|
if obs_str.contains(claim_str.as_str()) {
|
|
return true;
|
|
}
|
|
obs_str.split(',').any(|element| element.trim() == claim_str)
|
|
}
|
|
_ => obs.value == claim_obj_value,
|
|
})
|
|
.copied()
|
|
.collect();
|
|
|
|
if matching_containing.is_empty() {
|
|
// The forbidden value is NOT present - this is what we want
|
|
(
|
|
AuditVerdict::Pass,
|
|
format!("Forbidden value '{}' not found (as expected)", claim.value),
|
|
)
|
|
} else {
|
|
// The forbidden value IS present - conflict
|
|
let locations: Vec<String> = matching_containing
|
|
.iter()
|
|
.map(|o| format!("{}:{}", o.file, o.line))
|
|
.collect();
|
|
(
|
|
AuditVerdict::Conflict,
|
|
format!(
|
|
"Expected '{}' to not be present, but found at: {}",
|
|
claim.value,
|
|
locations.join(", ")
|
|
),
|
|
)
|
|
}
|
|
}
|
|
};
|
|
|
|
match verdict {
|
|
AuditVerdict::Pass => summary.pass += 1,
|
|
AuditVerdict::Conflict => summary.conflict += 1,
|
|
AuditVerdict::Missing => summary.missing += 1,
|
|
AuditVerdict::Unclaimed => summary.unclaimed += 1,
|
|
}
|
|
summary.total_claims += 1;
|
|
|
|
results.push(VerifyResult {
|
|
claim: Some(claim.clone()),
|
|
verdict,
|
|
matching_observations: matching.into_iter().cloned().collect(),
|
|
explanation,
|
|
});
|
|
}
|
|
|
|
// Find unclaimed observations
|
|
for (tp, obs_list) in &obs_by_tail {
|
|
if !claimed_tails.contains_key(tp) {
|
|
summary.unclaimed += obs_list.len();
|
|
for obs in obs_list {
|
|
results.push(VerifyResult {
|
|
claim: None,
|
|
verdict: AuditVerdict::Unclaimed,
|
|
matching_observations: vec![(*obs).clone()],
|
|
explanation: format!("Observation at {tp} has no covering claim"),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
VerifyReport { results, summary }
|
|
}
|
|
|
|
/// Entry in the extractor→claim map showing which extractors cover which claims.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ExtractorClaimMapping {
|
|
/// Claim ID.
|
|
pub claim_id: String,
|
|
/// Claim tail-path.
|
|
pub claim_tail_path: String,
|
|
/// Extractor names that can verify this claim.
|
|
pub covering_extractors: Vec<String>,
|
|
}
|
|
|
|
/// Entry for extractors that have no matching claims.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct UnmatchedExtractor {
|
|
/// Extractor name.
|
|
pub name: String,
|
|
/// Predicates the extractor declares.
|
|
pub predicates: Vec<(String, String)>,
|
|
}
|
|
|
|
/// Full extractor↔claim map result.
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ExtractorClaimMap {
|
|
/// Per-claim coverage.
|
|
pub claim_mappings: Vec<ExtractorClaimMapping>,
|
|
/// Extractors without matching claims.
|
|
pub unmatched_extractors: Vec<UnmatchedExtractor>,
|
|
}
|
|
|
|
/// Check if a wildcard pattern matches a tail-path suffix.
|
|
///
|
|
/// Supports `*` as a single-segment wildcard in two forms:
|
|
/// - `"imports/*"` matches `"imports/tokio"` but not `"imports/tokio/runtime"`
|
|
/// - `"message/*/derives"` matches `"message/agentmessage/derives"` but not `"message/a/b/derives"`
|
|
fn wildcard_matches(pattern: &str, target: &str) -> bool {
|
|
if pattern == target {
|
|
return true;
|
|
}
|
|
|
|
// Check for wildcard in pattern
|
|
if let Some(wildcard_pos) = pattern.find("/*") {
|
|
let before_wildcard = &pattern[..wildcard_pos];
|
|
let after_wildcard = &pattern[wildcard_pos + 2..]; // Skip "/*"
|
|
|
|
// Target must start with prefix
|
|
if !target.starts_with(before_wildcard) {
|
|
return false;
|
|
}
|
|
|
|
// Target must end with suffix (if suffix exists)
|
|
if !after_wildcard.is_empty() && !target.ends_with(after_wildcard) {
|
|
return false;
|
|
}
|
|
|
|
// Extract the middle part between prefix and suffix
|
|
let middle_start = before_wildcard.len();
|
|
let middle_end = if after_wildcard.is_empty() {
|
|
target.len()
|
|
} else {
|
|
target.len() - after_wildcard.len()
|
|
};
|
|
|
|
if middle_end <= middle_start {
|
|
return false;
|
|
}
|
|
|
|
let middle = &target[middle_start..middle_end];
|
|
|
|
// Middle must be exactly one segment (starts with '/' and has no other '/')
|
|
middle.starts_with('/') && !middle[1..].contains('/')
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Compute the mapping between extractors and authored claims.
|
|
///
|
|
/// For each active claim, finds extractors whose `verifiable_predicates()`
|
|
/// match the claim's tail-path. Reports claims with no covering extractor
|
|
/// and extractors with no matching claims.
|
|
pub fn compute_extractor_claim_map(
|
|
claims: &[AuthoredClaim],
|
|
extractors: &[Box<dyn crate::extractors::Extractor>],
|
|
) -> ExtractorClaimMap {
|
|
let mut claim_mappings = Vec::new();
|
|
let mut extractor_matched: HashMap<String, bool> = HashMap::new();
|
|
|
|
// Initialize all extractors as unmatched
|
|
for ext in extractors {
|
|
extractor_matched.insert(ext.name().to_string(), false);
|
|
}
|
|
|
|
for claim in claims {
|
|
if claim.status != ClaimStatus::Active {
|
|
continue;
|
|
}
|
|
|
|
let claim_tp = match tail_path(&claim.concept_path) {
|
|
Some(tp) => tp,
|
|
None => continue,
|
|
};
|
|
|
|
let mut covering = Vec::new();
|
|
|
|
for ext in extractors {
|
|
let preds = ext.verifiable_predicates();
|
|
for (tp_pattern, pred) in &preds {
|
|
if wildcard_matches(tp_pattern, &claim_tp) && *pred == claim.predicate {
|
|
covering.push(ext.name().to_string());
|
|
extractor_matched.insert(ext.name().to_string(), true);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
claim_mappings.push(ExtractorClaimMapping {
|
|
claim_id: claim.id.clone(),
|
|
claim_tail_path: claim_tp,
|
|
covering_extractors: covering,
|
|
});
|
|
}
|
|
|
|
let unmatched_extractors = extractors
|
|
.iter()
|
|
.filter(|ext| {
|
|
let preds = ext.verifiable_predicates();
|
|
!preds.is_empty() && !extractor_matched.get(ext.name()).copied().unwrap_or(false)
|
|
})
|
|
.map(|ext| UnmatchedExtractor {
|
|
name: ext.name().to_string(),
|
|
predicates: ext
|
|
.verifiable_predicates()
|
|
.into_iter()
|
|
.map(|(a, b)| (a.to_string(), b.to_string()))
|
|
.collect(),
|
|
})
|
|
.collect();
|
|
|
|
ExtractorClaimMap { claim_mappings, unmatched_extractors }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::types::authored_claim::AuthoredValue;
|
|
use stemedb_core::types::ObjectValue;
|
|
|
|
fn make_claim(
|
|
id: &str,
|
|
concept_path: &str,
|
|
predicate: &str,
|
|
value: AuthoredValue,
|
|
comparison: ComparisonMode,
|
|
) -> AuthoredClaim {
|
|
AuthoredClaim {
|
|
id: id.to_string(),
|
|
concept_path: concept_path.to_string(),
|
|
predicate: predicate.to_string(),
|
|
value,
|
|
comparison,
|
|
provenance: "test".to_string(),
|
|
invariant: "test invariant".to_string(),
|
|
consequence: "test consequence".to_string(),
|
|
authority_tier: "expert".to_string(),
|
|
evidence: vec![],
|
|
category: "test".to_string(),
|
|
status: ClaimStatus::Active,
|
|
supersedes: None,
|
|
created_by: "tester".to_string(),
|
|
created_at: "2026-02-08T12:00:00Z".to_string(),
|
|
updated_at: None,
|
|
}
|
|
}
|
|
|
|
fn make_obs(concept_path: &str, predicate: &str, value: ObjectValue) -> Observation {
|
|
Observation {
|
|
concept_path: concept_path.to_string(),
|
|
predicate: predicate.to_string(),
|
|
value,
|
|
file: "src/test.rs".to_string(),
|
|
line: 42,
|
|
matched_text: "test match".to_string(),
|
|
confidence: 1.0,
|
|
description: "test observation".to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_tail_path() {
|
|
assert_eq!(
|
|
tail_path("code://rust/myapp/tls/cert_verification"),
|
|
Some("tls/cert_verification".to_string())
|
|
);
|
|
assert_eq!(
|
|
tail_path("maxwell/wallet/atomics/ordering"),
|
|
Some("atomics/ordering".to_string())
|
|
);
|
|
assert_eq!(tail_path("single"), None);
|
|
assert_eq!(
|
|
tail_path("rfc://5246/tls/cert_verification"),
|
|
Some("tls/cert_verification".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_pass_equals() {
|
|
let claims = vec![make_claim(
|
|
"c1",
|
|
"project/atomics/ordering",
|
|
"ordering",
|
|
AuthoredValue::Text("SeqCst".to_string()),
|
|
ComparisonMode::Equals,
|
|
)];
|
|
let obs = vec![make_obs(
|
|
"code://rust/project/atomics/ordering",
|
|
"ordering",
|
|
ObjectValue::Text("SeqCst".to_string()),
|
|
)];
|
|
|
|
let report = verify_claims(&claims, &obs);
|
|
assert_eq!(report.summary.pass, 1);
|
|
assert_eq!(report.summary.conflict, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_conflict_equals() {
|
|
let claims = vec![make_claim(
|
|
"c1",
|
|
"project/atomics/ordering",
|
|
"ordering",
|
|
AuthoredValue::Text("SeqCst".to_string()),
|
|
ComparisonMode::Equals,
|
|
)];
|
|
let obs = vec![make_obs(
|
|
"code://rust/project/atomics/ordering",
|
|
"ordering",
|
|
ObjectValue::Text("Relaxed".to_string()),
|
|
)];
|
|
|
|
let report = verify_claims(&claims, &obs);
|
|
assert_eq!(report.summary.conflict, 1);
|
|
assert_eq!(report.summary.pass, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_missing() {
|
|
let claims = vec![make_claim(
|
|
"c1",
|
|
"project/config/timeout",
|
|
"timeout_ms",
|
|
AuthoredValue::Number(30.0),
|
|
ComparisonMode::Equals,
|
|
)];
|
|
let obs: Vec<Observation> = vec![];
|
|
|
|
let report = verify_claims(&claims, &obs);
|
|
assert_eq!(report.summary.missing, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_unclaimed() {
|
|
let claims: Vec<AuthoredClaim> = vec![];
|
|
let obs = vec![make_obs(
|
|
"code://rust/project/tls/cert_verification",
|
|
"enabled",
|
|
ObjectValue::Boolean(false),
|
|
)];
|
|
|
|
let report = verify_claims(&claims, &obs);
|
|
assert_eq!(report.summary.unclaimed, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_absent_pass() {
|
|
let claims = vec![make_claim(
|
|
"c1",
|
|
"core/imports/tokio",
|
|
"imported",
|
|
AuthoredValue::Bool(true),
|
|
ComparisonMode::Absent,
|
|
)];
|
|
let obs: Vec<Observation> = vec![];
|
|
|
|
let report = verify_claims(&claims, &obs);
|
|
assert_eq!(report.summary.pass, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_absent_conflict() {
|
|
let claims = vec![make_claim(
|
|
"c1",
|
|
"core/imports/tokio",
|
|
"imported",
|
|
AuthoredValue::Bool(true),
|
|
ComparisonMode::Absent,
|
|
)];
|
|
let obs = vec![make_obs(
|
|
"code://rust/core/imports/tokio",
|
|
"imported",
|
|
ObjectValue::Boolean(true),
|
|
)];
|
|
|
|
let report = verify_claims(&claims, &obs);
|
|
assert_eq!(report.summary.conflict, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_present_pass() {
|
|
let claims = vec![make_claim(
|
|
"c1",
|
|
"project/tls/cert_verification",
|
|
"enabled",
|
|
AuthoredValue::Bool(true),
|
|
ComparisonMode::Present,
|
|
)];
|
|
let obs = vec![make_obs(
|
|
"code://rust/project/tls/cert_verification",
|
|
"enabled",
|
|
ObjectValue::Boolean(true),
|
|
)];
|
|
|
|
let report = verify_claims(&claims, &obs);
|
|
assert_eq!(report.summary.pass, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_present_missing() {
|
|
let claims = vec![make_claim(
|
|
"c1",
|
|
"project/tls/cert_verification",
|
|
"enabled",
|
|
AuthoredValue::Bool(true),
|
|
ComparisonMode::Present,
|
|
)];
|
|
let obs: Vec<Observation> = vec![];
|
|
|
|
let report = verify_claims(&claims, &obs);
|
|
assert_eq!(report.summary.missing, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_not_equals_pass() {
|
|
let claims = vec![make_claim(
|
|
"c1",
|
|
"project/tls/min_version",
|
|
"version",
|
|
AuthoredValue::Text("1.0".to_string()),
|
|
ComparisonMode::NotEquals,
|
|
)];
|
|
let obs = vec![make_obs(
|
|
"code://rust/project/tls/min_version",
|
|
"version",
|
|
ObjectValue::Text("1.3".to_string()),
|
|
)];
|
|
|
|
let report = verify_claims(&claims, &obs);
|
|
assert_eq!(report.summary.pass, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_not_equals_conflict() {
|
|
let claims = vec![make_claim(
|
|
"c1",
|
|
"project/tls/min_version",
|
|
"version",
|
|
AuthoredValue::Text("1.0".to_string()),
|
|
ComparisonMode::NotEquals,
|
|
)];
|
|
let obs = vec![make_obs(
|
|
"code://rust/project/tls/min_version",
|
|
"version",
|
|
ObjectValue::Text("1.0".to_string()),
|
|
)];
|
|
|
|
let report = verify_claims(&claims, &obs);
|
|
assert_eq!(report.summary.conflict, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_deprecated_claims_skipped() {
|
|
let mut claim = make_claim(
|
|
"c1",
|
|
"project/atomics/ordering",
|
|
"required_ordering",
|
|
AuthoredValue::Text("SeqCst".to_string()),
|
|
ComparisonMode::Equals,
|
|
);
|
|
claim.status = ClaimStatus::Deprecated;
|
|
|
|
let obs = vec![make_obs(
|
|
"code://rust/project/atomics/ordering",
|
|
"ordering",
|
|
ObjectValue::Text("Relaxed".to_string()),
|
|
)];
|
|
|
|
let report = verify_claims(&[claim], &obs);
|
|
// Deprecated claim should not be verified — only unclaimed observation
|
|
assert_eq!(report.summary.total_claims, 0);
|
|
assert_eq!(report.summary.unclaimed, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_backward_compat_no_comparison_field() {
|
|
// Simulate a TOML claim without the `comparison` field — should default to Equals
|
|
let toml_str = r#"
|
|
[[claim]]
|
|
id = "old-claim"
|
|
concept_path = "test/concept/path"
|
|
predicate = "test_pred"
|
|
value = "test_value"
|
|
provenance = "Test"
|
|
invariant = "Test invariant"
|
|
consequence = "Test consequence"
|
|
authority_tier = "expert"
|
|
category = "safety"
|
|
created_by = "tester"
|
|
created_at = "2026-02-08T12:00:00Z"
|
|
"#;
|
|
let claims_file: crate::claims_file::ClaimsFile =
|
|
toml::from_str(toml_str).expect("parse TOML without comparison field");
|
|
assert_eq!(claims_file.claims.len(), 1);
|
|
assert_eq!(claims_file.claims[0].comparison, ComparisonMode::Equals);
|
|
}
|
|
|
|
#[test]
|
|
fn test_wildcard_matches() {
|
|
assert!(wildcard_matches("imports/*", "imports/tokio"));
|
|
assert!(wildcard_matches("imports/*", "imports/serde"));
|
|
assert!(!wildcard_matches("imports/*", "exports/tokio"));
|
|
assert!(!wildcard_matches("imports/*", "imports/tokio/runtime"));
|
|
assert!(wildcard_matches("tls/cert_verification", "tls/cert_verification"));
|
|
assert!(!wildcard_matches("tls/cert_verification", "tls/min_version"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_compute_extractor_claim_map() {
|
|
use crate::config::AphoriaConfig;
|
|
use crate::extractors::ExtractorRegistry;
|
|
|
|
let config = AphoriaConfig::default();
|
|
let registry = ExtractorRegistry::new(&config);
|
|
|
|
let claims = vec![
|
|
make_claim(
|
|
"tls-001",
|
|
"project/tls/cert_verification",
|
|
"enabled",
|
|
AuthoredValue::Bool(true),
|
|
ComparisonMode::Equals,
|
|
),
|
|
make_claim(
|
|
"import-001",
|
|
"core/imports/tokio",
|
|
"imported",
|
|
AuthoredValue::Bool(true),
|
|
ComparisonMode::Absent,
|
|
),
|
|
];
|
|
|
|
let map = compute_extractor_claim_map(&claims, registry.extractors());
|
|
|
|
// tls_verify should cover tls-001
|
|
let tls_mapping = map.claim_mappings.iter().find(|m| m.claim_id == "tls-001");
|
|
assert!(tls_mapping.is_some());
|
|
assert!(tls_mapping
|
|
.map(|m| m.covering_extractors.contains(&"tls_verify".to_string()))
|
|
.unwrap_or(false));
|
|
|
|
// import_graph should cover import-001 via wildcard
|
|
let import_mapping = map.claim_mappings.iter().find(|m| m.claim_id == "import-001");
|
|
assert!(import_mapping.is_some());
|
|
assert!(import_mapping
|
|
.map(|m| m.covering_extractors.contains(&"import_graph".to_string()))
|
|
.unwrap_or(false));
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_filters_by_predicate() {
|
|
// Bug 1 fix: Path matching must respect predicates
|
|
// Claim: core/imports/serde with predicate "imported" must be absent
|
|
// Observation 1: core/imports/serde with predicate "imported" = true → CONFLICT
|
|
// Observation 2: core/imports/serde with predicate "version" = "1.0" → ignore (different predicate)
|
|
let claim = make_claim(
|
|
"core-no-serde",
|
|
"core/imports/serde",
|
|
"imported",
|
|
AuthoredValue::Bool(true),
|
|
ComparisonMode::Absent,
|
|
);
|
|
let obs1 =
|
|
make_obs("code://rust/core/imports/serde", "imported", ObjectValue::Boolean(true));
|
|
let obs2 = make_obs(
|
|
"code://rust/core/imports/serde",
|
|
"version",
|
|
ObjectValue::Text("1.0".to_string()),
|
|
);
|
|
|
|
// With obs1 (matching predicate): should CONFLICT
|
|
let report = verify_claims(std::slice::from_ref(&claim), std::slice::from_ref(&obs1));
|
|
assert_eq!(report.summary.conflict, 1);
|
|
|
|
// With obs2 (different predicate): should PASS (ignores obs2)
|
|
let report = verify_claims(std::slice::from_ref(&claim), std::slice::from_ref(&obs2));
|
|
assert_eq!(report.summary.pass, 1);
|
|
|
|
// With both: should CONFLICT (only obs1 matters)
|
|
let report = verify_claims(&[claim], &[obs1, obs2]);
|
|
assert_eq!(report.summary.conflict, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_absent_checks_specific_value() {
|
|
// Bug 2 fix: Absent mode must check the specific claim value
|
|
// Claim: algorithm = "md5" must be absent
|
|
// Observation: algorithm = "sha256" → PASS (md5 not present)
|
|
// Observation: algorithm = "md5" → CONFLICT (md5 is present)
|
|
let claim = make_claim(
|
|
"no-md5",
|
|
"project/crypto/hashing/algorithm",
|
|
"algorithm",
|
|
AuthoredValue::Text("md5".to_string()),
|
|
ComparisonMode::Absent,
|
|
);
|
|
let obs_sha = make_obs(
|
|
"code://rust/project/crypto/hashing/algorithm",
|
|
"algorithm",
|
|
ObjectValue::Text("sha256".to_string()),
|
|
);
|
|
let obs_md5 = make_obs(
|
|
"code://rust/project/crypto/hashing/algorithm",
|
|
"algorithm",
|
|
ObjectValue::Text("md5".to_string()),
|
|
);
|
|
|
|
// With sha256: should PASS (md5 not found)
|
|
let report = verify_claims(std::slice::from_ref(&claim), std::slice::from_ref(&obs_sha));
|
|
assert_eq!(report.summary.pass, 1);
|
|
assert_eq!(report.summary.conflict, 0);
|
|
|
|
// With md5: should CONFLICT (md5 found)
|
|
let report = verify_claims(&[claim], &[obs_md5]);
|
|
assert_eq!(report.summary.conflict, 1);
|
|
assert_eq!(report.summary.pass, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_wildcard_pattern() {
|
|
// Bug 3 fix: Wildcard patterns must be supported in verification
|
|
// Claim: message/*/derives with predicate "traits" must include "Serialize"
|
|
// Observation 1: message/agentmessage/derives with "Clone,Debug,Serialize"
|
|
// Observation 2: message/daemonmessage/derives with "Clone,Debug,Serialize"
|
|
// Expected: Both observations match the wildcard → PASS
|
|
let claim = make_claim(
|
|
"vsock-serialize",
|
|
"project/message/*/derives",
|
|
"traits",
|
|
AuthoredValue::Text("Serialize".to_string()),
|
|
ComparisonMode::Present,
|
|
);
|
|
let obs1 = make_obs(
|
|
"code://rust/project/message/agentmessage/derives",
|
|
"traits",
|
|
ObjectValue::Text("Clone,Debug,Serialize".to_string()),
|
|
);
|
|
let obs2 = make_obs(
|
|
"code://rust/project/message/daemonmessage/derives",
|
|
"traits",
|
|
ObjectValue::Text("Clone,Debug,Serialize".to_string()),
|
|
);
|
|
|
|
let report = verify_claims(&[claim], &[obs1, obs2]);
|
|
assert_eq!(report.summary.pass, 1);
|
|
assert_eq!(report.summary.missing, 0);
|
|
assert_eq!(report.results[0].matching_observations.len(), 2); // Both matched
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_contains_substring() {
|
|
// Test 1: Single value contains substring
|
|
let claim = make_claim(
|
|
"contains-test",
|
|
"project/message/content",
|
|
"text",
|
|
AuthoredValue::Text("error".to_string()),
|
|
ComparisonMode::Contains,
|
|
);
|
|
let obs = make_obs(
|
|
"code://rust/project/message/content",
|
|
"text",
|
|
ObjectValue::Text("This is an error message".to_string()),
|
|
);
|
|
|
|
let report = verify_claims(&[claim], &[obs]);
|
|
assert_eq!(report.summary.pass, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_contains_list_element() {
|
|
// Test 2: Comma-separated list contains element
|
|
let claim = make_claim(
|
|
"serialize-present",
|
|
"project/message/derives",
|
|
"traits",
|
|
AuthoredValue::Text("Serialize".to_string()),
|
|
ComparisonMode::Contains,
|
|
);
|
|
let obs = make_obs(
|
|
"code://rust/project/message/derives",
|
|
"traits",
|
|
ObjectValue::Text("Clone,Debug,Serialize".to_string()),
|
|
);
|
|
|
|
let report = verify_claims(&[claim], &[obs]);
|
|
assert_eq!(report.summary.pass, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_contains_missing() {
|
|
// Test 3: Value not found in observation
|
|
let claim = make_claim(
|
|
"serialize-present",
|
|
"project/message/derives",
|
|
"traits",
|
|
AuthoredValue::Text("Serialize".to_string()),
|
|
ComparisonMode::Contains,
|
|
);
|
|
let obs = make_obs(
|
|
"code://rust/project/message/derives",
|
|
"traits",
|
|
ObjectValue::Text("Clone,Debug".to_string()),
|
|
);
|
|
|
|
let report = verify_claims(&[claim], &[obs]);
|
|
assert_eq!(report.summary.conflict, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_not_contains_pass() {
|
|
// Test 4: Forbidden value NOT present (PASS)
|
|
let claim = make_claim(
|
|
"no-clone",
|
|
"project/wallet/derives",
|
|
"traits",
|
|
AuthoredValue::Text("Clone".to_string()),
|
|
ComparisonMode::NotContains,
|
|
);
|
|
let obs = make_obs(
|
|
"code://rust/project/wallet/derives",
|
|
"traits",
|
|
ObjectValue::Text("Debug".to_string()),
|
|
);
|
|
|
|
let report = verify_claims(&[claim], &[obs]);
|
|
assert_eq!(report.summary.pass, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_not_contains_conflict() {
|
|
// Test 5: Forbidden value IS present (CONFLICT)
|
|
let claim = make_claim(
|
|
"no-clone",
|
|
"project/wallet/derives",
|
|
"traits",
|
|
AuthoredValue::Text("Clone".to_string()),
|
|
ComparisonMode::NotContains,
|
|
);
|
|
let obs = make_obs(
|
|
"code://rust/project/wallet/derives",
|
|
"traits",
|
|
ObjectValue::Text("Clone,Debug".to_string()),
|
|
);
|
|
|
|
let report = verify_claims(&[claim], &[obs]);
|
|
assert_eq!(report.summary.conflict, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_not_contains_substring() {
|
|
// Test 6: Forbidden substring present (CONFLICT)
|
|
let claim = make_claim(
|
|
"no-hardcoded",
|
|
"project/config/password",
|
|
"value",
|
|
AuthoredValue::Text("hardcoded".to_string()),
|
|
ComparisonMode::NotContains,
|
|
);
|
|
let obs = make_obs(
|
|
"code://rust/project/config/password",
|
|
"value",
|
|
ObjectValue::Text("my_hardcoded_password".to_string()),
|
|
);
|
|
|
|
let report = verify_claims(&[claim], &[obs]);
|
|
assert_eq!(report.summary.conflict, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_contains_with_whitespace() {
|
|
// Test 7: List with spaces around commas
|
|
let claim = make_claim(
|
|
"serialize-present",
|
|
"project/message/derives",
|
|
"traits",
|
|
AuthoredValue::Text("Serialize".to_string()),
|
|
ComparisonMode::Contains,
|
|
);
|
|
let obs = make_obs(
|
|
"code://rust/project/message/derives",
|
|
"traits",
|
|
ObjectValue::Text("Clone, Debug, Serialize".to_string()),
|
|
);
|
|
|
|
let report = verify_claims(&[claim], &[obs]);
|
|
assert_eq!(report.summary.pass, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_not_contains_no_observation() {
|
|
// Test 8: No observation (vacuously true - PASS)
|
|
let claim = make_claim(
|
|
"no-clone",
|
|
"project/wallet/derives",
|
|
"traits",
|
|
AuthoredValue::Text("Clone".to_string()),
|
|
ComparisonMode::NotContains,
|
|
);
|
|
|
|
let report = verify_claims(&[claim], &[]);
|
|
assert_eq!(report.summary.pass, 1);
|
|
}
|
|
}
|