use serde_json::{json, Map, Value};
use crate::envelope::{ReasonCode, Status, VerdictEnvelopeV1};
use crate::types::Verdict;
pub fn verdict_to_envelope_v1(verdict: &Verdict) -> VerdictEnvelopeV1 {
let (status, reason_code) = derive_initial_status_reason(verdict);
let as_of = verdict
.computed_at
.clone()
.or_else(|| verdict.data_freshness_at.clone())
.unwrap_or_default();
let rich_data = build_rich_data(verdict);
let availability_value = if verdict.availability == crate::types::AvailabilityBlock::default() {
serde_json::Value::Null
} else {
let mut obj = Map::new();
if verdict.availability.degraded_stale {
obj.insert("degraded_stale".to_string(), json!(true));
}
if let Some(v) = &verdict.availability.kev {
obj.insert("kev".to_string(), json!(v));
}
if let Some(v) = &verdict.availability.epss {
obj.insert("epss".to_string(), json!(v));
}
if let Some(v) = &verdict.availability.exploitation_fusion {
obj.insert("exploitation_fusion".to_string(), json!(v));
}
Value::Object(obj)
};
VerdictEnvelopeV1 {
status: status.as_str().to_string(),
reason_code: reason_code.as_str().to_string(),
human_message: verdict.reasoning.clone(),
as_of,
rich_data: Some(rich_data),
remediation: None,
exploitability: None,
availability: availability_value,
}
}
fn derive_initial_status_reason(verdict: &Verdict) -> (Status, ReasonCode) {
let label = verdict.verdict.to_ascii_uppercase();
let severity = verdict
.severity
.as_deref()
.unwrap_or("NONE")
.to_ascii_uppercase();
let (status, reason) = match (label.as_str(), severity.as_str()) {
("VECTOR_VERDICT", "CRITICAL") => (Status::Deny, ReasonCode::VerdictExploitationCritical),
("VECTOR_VERDICT", "HIGH") => (Status::Deny, ReasonCode::VerdictLowTrust),
("VECTOR_VERDICT", "MEDIUM") => (Status::Warn, ReasonCode::VerdictAbandoned),
("VECTOR_VERDICT", _) => (Status::Allow, ReasonCode::VerdictClean),
("DM_THRESHOLD_BLOCK", "CRITICAL" | "HIGH") => {
(Status::Deny, ReasonCode::VerdictDenyList)
}
("DM_THRESHOLD_BLOCK", "MEDIUM") => (Status::Warn, ReasonCode::VerdictDenyList),
("DM_THRESHOLD_BLOCK", _) => (Status::Allow, ReasonCode::VerdictClean),
("INSUFFICIENT_DATA", _) => (Status::Warn, ReasonCode::VerdictNotYetAssessed),
_ => (Status::Allow, ReasonCode::VerdictClean),
};
let reason = match verdict.source.to_ascii_uppercase().as_str() {
"MALICIOUS_TRIAGE" => ReasonCode::VerdictMalicious,
"CVE_FINDING_ON_RANSOMWARE" => ReasonCode::VerdictRansomwareListed,
"CVE_FINDING_ON_KEV" => ReasonCode::VerdictKevListed,
_ => reason,
};
if verdict.stale_since_at.is_some() {
(status, ReasonCode::VerdictDegradedStale)
} else {
(status, reason)
}
}
fn build_rich_data(verdict: &Verdict) -> Value {
let mut m = Map::new();
if !verdict.suggested_actions.is_empty() {
m.insert(
"suggested_actions".to_string(),
json!(verdict.suggested_actions),
);
}
if !verdict.similar_to.is_empty() {
m.insert("similar_to".to_string(), json!(verdict.similar_to));
}
if !verdict.evidence_gaps.is_empty() {
m.insert("evidence_gaps".to_string(), json!(verdict.evidence_gaps));
}
if let Some(pv) = &verdict.previous_verdict {
m.insert(
"previous_verdict".to_string(),
json!({
"verdict_id": pv.verdict_id,
"verdict": pv.verdict,
"computed_at": pv.computed_at,
"diff": pv.diff,
}),
);
}
m.insert("confidence".to_string(), json!(verdict.confidence));
m.insert("composite_score".to_string(), json!(verdict.composite_score));
if !verdict.source.is_empty() {
m.insert("source".to_string(), json!(verdict.source));
}
if let Some(s) = &verdict.severity {
m.insert("severity".to_string(), json!(s));
}
if let Some(s) = &verdict.staleness_reason {
m.insert("staleness_reason".to_string(), json!(s));
}
Value::Object(m)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::PreviousVerdict;
fn allowed_clean_verdict() -> Verdict {
Verdict {
verdict_id: "v1".into(),
verdict: "ALLOWED_NO_FINDINGS".into(),
source: "ALLOWED_NO_FINDINGS".into(),
confidence: 0.98,
composite_score: 12,
severity: Some("NONE".into()),
reasoning: "No known CVEs; healthy maintenance signals".into(),
similar_to: vec![],
evidence_gaps: vec![],
suggested_actions: vec![],
data_freshness_at: Some("2026-05-20T10:00:00Z".into()),
computed_at: Some("2026-05-20T10:30:00Z".into()),
..Default::default()
}
}
fn malware_verdict() -> Verdict {
Verdict {
verdict_id: "v2".into(),
verdict: "VECTOR_VERDICT".into(),
source: "VECTOR_VERDICT".into(),
confidence: 0.99,
composite_score: 92,
severity: Some("CRITICAL".into()),
reasoning: "Confirmed malware: bitcoin-wallet credential exfiltration".into(),
suggested_actions: vec![
"DENY across all customers".into(),
"Rotate wallet keys".into(),
],
computed_at: Some("2026-05-20T09:31:00Z".into()),
..Default::default()
}
}
#[test]
fn allowed_no_findings_maps_to_allow_clean() {
let env = verdict_to_envelope_v1(&allowed_clean_verdict());
assert_eq!(env.status, "ALLOW");
assert_eq!(env.reason_code, "VERDICT_CLEAN");
assert_eq!(env.as_of, "2026-05-20T10:30:00Z");
assert!(env.human_message.contains("No known CVEs"));
assert!(env.remediation.is_none());
assert!(env.exploitability.is_none());
assert!(env.availability.is_null());
}
#[test]
fn vector_verdict_critical_maps_to_deny_exploitation_critical() {
let env = verdict_to_envelope_v1(&malware_verdict());
assert_eq!(env.status, "DENY");
assert_eq!(env.reason_code, "VERDICT_EXPLOITATION_CRITICAL");
let rich = env.rich_data.as_ref().expect("rich_data populated");
let actions = rich
.get("suggested_actions")
.and_then(Value::as_array)
.expect("suggested_actions in rich_data");
assert_eq!(actions.len(), 2);
assert_eq!(rich.get("composite_score"), Some(&json!(92)));
}
#[test]
fn vector_verdict_high_maps_to_deny_low_trust() {
let mut v = malware_verdict();
v.severity = Some("HIGH".into());
let env = verdict_to_envelope_v1(&v);
assert_eq!(env.status, "DENY");
assert_eq!(env.reason_code, "VERDICT_LOW_TRUST");
}
#[test]
fn vector_verdict_medium_maps_to_warn_abandoned() {
let mut v = malware_verdict();
v.severity = Some("MEDIUM".into());
let env = verdict_to_envelope_v1(&v);
assert_eq!(env.status, "WARN");
assert_eq!(env.reason_code, "VERDICT_ABANDONED");
}
#[test]
fn dm_threshold_block_high_maps_to_deny_deny_list() {
let mut v = allowed_clean_verdict();
v.verdict = "DM_THRESHOLD_BLOCK".into();
v.severity = Some("HIGH".into());
let env = verdict_to_envelope_v1(&v);
assert_eq!(env.status, "DENY");
assert_eq!(env.reason_code, "VERDICT_DENY_LIST");
}
#[test]
fn stale_since_at_overrides_reason_code_to_degraded_stale() {
let mut v = allowed_clean_verdict();
v.stale_since_at = Some("2026-04-01T00:00:00Z".into());
let env = verdict_to_envelope_v1(&v);
assert_eq!(env.status, "ALLOW");
assert_eq!(env.reason_code, "VERDICT_DEGRADED_STALE");
}
#[test]
fn previous_verdict_lands_in_rich_data() {
let mut v = allowed_clean_verdict();
v.previous_verdict = Some(PreviousVerdict {
verdict_id: "vprev".into(),
verdict: "ALLOWED_NO_FINDINGS".into(),
computed_at: "2026-04-01T00:00:00Z".into(),
diff: "verdict_unchanged".into(),
});
let env = verdict_to_envelope_v1(&v);
let rich = env.rich_data.as_ref().expect("rich_data");
let pv = rich.get("previous_verdict").expect("previous_verdict in rich_data");
assert_eq!(pv.get("verdict_id").and_then(Value::as_str), Some("vprev"));
}
#[test]
fn as_of_falls_back_to_data_freshness_when_computed_at_absent() {
let mut v = allowed_clean_verdict();
v.computed_at = None;
let env = verdict_to_envelope_v1(&v);
assert_eq!(env.as_of, "2026-05-20T10:00:00Z");
}
#[test]
fn cleanlib_495_s1_insufficient_data_fails_closed_to_warn_not_yet_assessed() {
let mut v = allowed_clean_verdict();
v.verdict = "INSUFFICIENT_DATA".into();
v.severity = Some("NONE".into());
let env = verdict_to_envelope_v1(&v);
assert_eq!(env.status, "WARN", "INSUFFICIENT_DATA must fail closed to WARN");
assert_eq!(env.reason_code, "VERDICT_NOT_YET_ASSESSED");
}
#[test]
fn unknown_verdict_label_falls_through_to_allow_clean() {
let mut v = allowed_clean_verdict();
v.verdict = "FUTURE_NEW_LABEL_X".into();
v.severity = Some("MEDIUM".into());
let env = verdict_to_envelope_v1(&v);
assert_eq!(env.status, "ALLOW");
assert_eq!(env.reason_code, "VERDICT_CLEAN");
}
#[test]
fn cleanlib_176_source_malicious_triage_refines_reason_to_malicious() {
let mut v = malware_verdict();
v.source = "MALICIOUS_TRIAGE".into();
let env = verdict_to_envelope_v1(&v);
assert_eq!(env.status, "DENY");
assert_eq!(env.reason_code, "VERDICT_MALICIOUS");
}
#[test]
fn cleanlib_176_source_on_kev_refines_reason_to_kev_listed() {
let mut v = malware_verdict();
v.source = "CVE_FINDING_ON_KEV".into();
v.severity = Some("HIGH".into());
let env = verdict_to_envelope_v1(&v);
assert_eq!(env.status, "DENY");
assert_eq!(env.reason_code, "VERDICT_KEV_LISTED");
}
#[test]
fn cleanlib_176_source_on_ransomware_refines_reason_to_ransomware_listed() {
let mut v = malware_verdict();
v.source = "CVE_FINDING_ON_RANSOMWARE".into();
let env = verdict_to_envelope_v1(&v);
assert_eq!(env.reason_code, "VERDICT_RANSOMWARE_LISTED");
}
#[test]
fn cleanlib_176_plain_cve_finding_keeps_severity_driven_reason() {
let mut v = malware_verdict();
v.source = "CVE_FINDING".into();
v.severity = Some("HIGH".into());
let env = verdict_to_envelope_v1(&v);
assert_eq!(env.status, "DENY");
assert_eq!(env.reason_code, "VERDICT_LOW_TRUST");
}
#[test]
fn high_severity_cve_diverges_from_wire_coarse_state() {
let mut v = malware_verdict(); v.source = "CVE_FINDING".into();
v.severity = Some("HIGH".into());
let env = verdict_to_envelope_v1(&v);
assert_eq!(env.status, "DENY");
assert_ne!(env.status, "WARN", "SDK status must not collapse to the wire display hint");
}
#[test]
fn cleanlib_176_staleness_wins_over_source_refinement() {
let mut v = malware_verdict();
v.source = "MALICIOUS_TRIAGE".into();
v.stale_since_at = Some("2026-04-01T00:00:00Z".into());
let env = verdict_to_envelope_v1(&v);
assert_eq!(env.status, "DENY");
assert_eq!(env.reason_code, "VERDICT_DEGRADED_STALE");
}
#[test]
fn round_trip_produces_envelope_that_parses_back_unchanged() {
let env = verdict_to_envelope_v1(&malware_verdict());
let s = serde_json::to_string(&env).unwrap();
let back: VerdictEnvelopeV1 = serde_json::from_str(&s).unwrap();
assert_eq!(back.status, env.status);
assert_eq!(back.reason_code, env.reason_code);
assert_eq!(back.human_message, env.human_message);
assert_eq!(back.as_of, env.as_of);
}
#[test]
fn cleanlib_105_availability_sub_fields_appear_on_envelope_when_populated() {
let mut v = allowed_clean_verdict();
v.availability = crate::types::AvailabilityBlock {
degraded_stale: true,
kev: Some("available".to_string()),
epss: Some("available".to_string()),
exploitation_fusion: Some("available".to_string()),
};
let env = verdict_to_envelope_v1(&v);
assert_eq!(env.availability["degraded_stale"], Value::Bool(true));
assert_eq!(env.availability["kev"], Value::String("available".to_string()));
assert_eq!(env.availability["epss"], Value::String("available".to_string()));
assert_eq!(env.availability["exploitation_fusion"], Value::String("available".to_string()));
}
#[test]
fn cleanlib_105_default_availability_still_omits_key_on_wire() {
let v = allowed_clean_verdict();
let env = verdict_to_envelope_v1(&v);
assert!(env.availability.is_null());
let s = serde_json::to_string(&env).unwrap();
assert!(!s.contains("\"availability\""));
}
#[test]
fn cleanlib_105_degraded_stale_only_matches_pre_m1_wire_shape() {
let mut v = allowed_clean_verdict();
v.availability = crate::types::AvailabilityBlock {
degraded_stale: true,
kev: None,
epss: None,
exploitation_fusion: None,
};
let env = verdict_to_envelope_v1(&v);
assert_eq!(
env.availability,
serde_json::json!({"degraded_stale": true})
);
}
#[test]
fn cleanlib_105_kev_only_populated_still_emits_object() {
let mut v = allowed_clean_verdict();
v.availability = crate::types::AvailabilityBlock {
degraded_stale: false,
kev: Some("available".to_string()),
epss: None,
exploitation_fusion: None,
};
let env = verdict_to_envelope_v1(&v);
assert_eq!(
env.availability,
serde_json::json!({"kev": "available"})
);
}
}