use serde::{Deserialize, Serialize};
use cleanlib_client::types::{Verdict,PreviousVerdict,AvailabilityBlock};
use cleanlib_client::CustomerState;
use super::sanitize::{mask_engine_tag, mask_fixture_label};
use super::output::decision_tier_str_with_severity;
#[derive(Debug, Clone, Copy,Default)]
#[allow(dead_code)]
pub struct RenderOpts {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerdictResponse {
pub verdict_id: String,
pub verdict: String,
pub source: String,
pub confidence: f64,
pub composite_score: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub decision: Option<String>,
pub state: String,
pub state_label: String,
pub reasoning: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub similar_to: Option<Vec<String>>,
pub evidence_gaps: Vec<String>,
pub suggested_actions: Vec<String>,
pub severity: Option<String>,
pub data_freshness_at: Option<String>,
pub data_oldest_signal_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stale_since_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub staleness_reason: Option<String>,
pub computed_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_verdict: Option<PreviousVerdict>,
#[serde(default, skip_serializing_if = "availability_block_is_default")]
pub availability: AvailabilityBlock,
#[serde(skip_serializing_if = "Option::is_none")]
pub envelope_version: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audit_record_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audit_record_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attestation: Option<serde_json::Value>,
}
fn availability_block_is_default(a: &AvailabilityBlock) -> bool {
*a == AvailabilityBlock::default()
}
impl From<&Verdict> for VerdictResponse {
fn from(v: &Verdict) -> Self {
let state = CustomerState::from_wire(&v.source);
VerdictResponse {
verdict_id: v.verdict_id.clone(),
verdict: mask_engine_tag(&v.verdict),
source: mask_fixture_label(&mask_engine_tag(&v.source)),
confidence: v.confidence.clone(),
composite_score: v.composite_score.clone(),
decision: Some(
decision_tier_str_with_severity(&v.verdict, v.severity.as_deref(), v.composite_score)
.to_string(),
),
state: state.as_str().to_string(),
state_label: state.label().to_string(),
reasoning: v.reasoning.clone(),
suggested_actions: v.suggested_actions.clone(),
similar_to: (!v.similar_to.is_empty()).then(|| v.similar_to.clone()),
evidence_gaps: v.evidence_gaps.clone(),
severity: v.severity.clone(),
data_freshness_at: v.data_freshness_at.clone(),
data_oldest_signal_at: v.data_oldest_signal_at.clone(),
stale_since_at: v.stale_since_at.clone(),
staleness_reason: v.staleness_reason.clone(),
computed_at: v.computed_at.clone(),
previous_verdict: v.previous_verdict.clone(),
availability: v.availability.clone(),
envelope_version: v.envelope_version,
policy_version: v.policy_version.clone(),
audit_record_id: v.audit_record_id.clone(),
audit_record_hash: v.audit_record_hash.clone(),
attestation: v.attestation.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Severity {
Low,
Medium,
High,
Critical,
}
pub fn render_verdict(verdict: &Verdict, _opts: &RenderOpts) {
let response = VerdictResponse::from(verdict);
let result: Result<String, serde_json::Error> = serde_json::to_string_pretty(&response);
match result {
Ok(json) => {
eprintln!(
"warning: `decision` in --output json is DEPRECATED and will be removed in 0.2.0; use `state`/`state_label` + the exit code."
);
println!("{}", json);
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cleanlib_client::types::{Verdict, PreviousVerdict, AvailabilityBlock};
fn make_verdict(verdict_label: &str, composite_score: u8) -> Verdict {
Verdict {
verdict_id: "vrd-test-001".to_string(),
verdict: verdict_label.to_string(),
source: "npm".to_string(),
confidence: 0.85,
composite_score,
reasoning: "Test reasoning.".to_string(),
similar_to: vec![],
evidence_gaps: vec![],
suggested_actions: vec![],
data_freshness_at: None,
data_oldest_signal_at: None,
stale_since_at: None,
staleness_reason: None,
computed_at: None,
severity: None,
decision: None,
previous_verdict: None,
availability: AvailabilityBlock::default(),
..Default::default()
}
}
#[test]
fn cleanlib_496b_decision_dual_emitted_alongside_state() {
let out = serde_json::to_value(&VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 20))).unwrap();
let obj = out.as_object().unwrap();
assert!(obj.contains_key("decision"), "deprecated `decision` retained for the 0.1.x window (CLEANLIB-496B option B)");
assert_eq!(out["decision"], "ALLOW"); assert!(obj.contains_key("state"), "authoritative state still present alongside deprecated decision");
assert!(obj.contains_key("state_label"));
}
#[test]
fn cleanlib_496_attestation_and_audit_fields_surface() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 20);
v.envelope_version = Some(2);
v.policy_version = Some("1.1.0".into());
v.audit_record_id = Some("01KZ9X7C52PSDQD9DKFVCJH2CQ".into());
v.audit_record_hash = Some("a41d6451384f787b3b0470d80ab6a90decc1dbd81dce60692d13b2018abbc231".into());
v.attestation = Some(serde_json::json!({
"attestation": { "served_at": "2026-08-06T00:00:00.000000Z" },
"signature_b64": "MEUCIQD-signature-bytes",
"key_id": "cleanlib-cosign-staging",
}));
let out = serde_json::to_value(&VerdictResponse::from(&v)).unwrap();
assert_eq!(out["envelope_version"], 2);
assert_eq!(out["policy_version"], "1.1.0");
assert_eq!(out["audit_record_id"], "01KZ9X7C52PSDQD9DKFVCJH2CQ");
assert_eq!(out["audit_record_hash"], "a41d6451384f787b3b0470d80ab6a90decc1dbd81dce60692d13b2018abbc231");
assert_eq!(out["attestation"]["key_id"], "cleanlib-cosign-staging");
assert_eq!(out["attestation"]["signature_b64"], "MEUCIQD-signature-bytes");
}
#[test]
fn cleanlib_496_unsigned_v1_omits_attestation_fields() {
let out = serde_json::to_value(&VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 20))).unwrap();
let obj = out.as_object().unwrap();
for k in ["envelope_version", "policy_version", "audit_record_id", "audit_record_hash", "attestation"] {
assert!(!obj.contains_key(k), "unsigned response must omit `{k}`");
}
}
#[test]
fn verdict_field_engine_tag_is_masked() {
let r = VerdictResponse::from(&make_verdict("VECTOR_VERDICT", 85));
assert_eq!(r.verdict, "Engine signal");
}
#[test]
fn source_with_engine_tag_is_masked() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.source = "VECTOR_VERDICT".to_string();
let r = VerdictResponse::from(&v);
assert_eq!(r.source, "Engine signal");
}
#[test]
fn source_with_fixture_prefix_is_hidden() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.source = "mock_npm_fixture".to_string();
let r = VerdictResponse::from(&v);
assert_eq!(r.source, "");
}
#[test]
fn customer_facing_source_passes_through() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.source = "Cleanstart-curated catalog".to_string();
let r = VerdictResponse::from(&v);
assert_eq!(r.source, "Cleanstart-curated catalog");
}
#[test]
fn similar_to_empty_coerced_to_none() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.similar_to = vec![];
let r = VerdictResponse::from(&v);
assert!(r.similar_to.is_none());
}
#[test]
fn similar_to_nonempty_preserved() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.similar_to = vec!["vrd-abc".to_string(), "vrd-def".to_string()];
let r = VerdictResponse::from(&v);
assert_eq!(r.similar_to, Some(vec!["vrd-abc".to_string(), "vrd-def".to_string()]));
}
#[test]
fn similar_to_omitted_from_json_when_empty_input() {
let r = VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 30));
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("similar_to").is_none(), "similar_to must be absent from JSON when empty");
}
#[test]
fn similar_to_present_in_json_when_nonempty() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.similar_to = vec!["vrd-xyz".to_string()];
let r = VerdictResponse::from(&v);
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("similar_to").is_some());
}
#[test]
fn stale_since_at_omitted_from_json_when_none() {
let r = VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 30));
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("stale_since_at").is_none());
}
#[test]
fn stale_since_at_present_in_json_when_set() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.stale_since_at = Some("2026-01-01T00:00:00Z".to_string());
let r = VerdictResponse::from(&v);
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json["stale_since_at"], "2026-01-01T00:00:00Z");
}
#[test]
fn staleness_reason_omitted_from_json_when_none() {
let r = VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 30));
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("staleness_reason").is_none());
}
#[test]
fn previous_verdict_omitted_from_json_when_none() {
let r = VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 30));
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("previous_verdict").is_none());
}
#[test]
fn previous_verdict_present_in_json_when_set() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.previous_verdict = Some(PreviousVerdict {
verdict_id: "vrd-prev".to_string(),
verdict: "ALLOWED_NO_FINDINGS".to_string(),
computed_at: "2026-01-01T00:00:00Z".to_string(),
diff: "no change".to_string(),
});
let r = VerdictResponse::from(&v);
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json["previous_verdict"]["verdict_id"], "vrd-prev");
}
#[test]
fn availability_omitted_from_json_when_default() {
let r = VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 30));
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("availability").is_none());
}
#[test]
fn availability_present_in_json_when_set() {
let mut v = make_verdict("ALLOWED_NO_FINDINGS", 30);
v.availability = AvailabilityBlock {
degraded_stale: true,
..AvailabilityBlock::default()
};
let r = VerdictResponse::from(&v);
let json = serde_json::to_value(&r).unwrap();
assert_eq!(json["availability"]["degraded_stale"], true);
}
#[test]
fn availability_default_deserialize_on_missing_key() {
let raw = r#"{
"verdict_id":"vrd-test-noavail",
"verdict":"ALLOWED_NO_FINDINGS",
"source":"npm",
"confidence":1.0,
"composite_score":0,
"reasoning":"no block on wire",
"similar_to":[],
"evidence_gaps":[],
"suggested_actions":[],
"data_freshness_at":null,
"data_oldest_signal_at":null,
"stale_since_at":null,
"staleness_reason":null,
"computed_at":null,
"severity":null,
"decision":null,
"previous_verdict":null
}"#;
let v: Verdict = serde_json::from_str(raw).expect("deserialize succeeds");
assert_eq!(v.availability, AvailabilityBlock::default());
assert_eq!(v.availability.degraded_stale, false);
}
#[test]
fn scalar_fields_pass_through_unchanged() {
let v = make_verdict("ALLOWED_NO_FINDINGS", 42);
let r = VerdictResponse::from(&v);
assert_eq!(r.verdict_id, "vrd-test-001");
assert_eq!(r.confidence, 0.85);
assert_eq!(r.composite_score, 42);
assert_eq!(r.reasoning, "Test reasoning.");
}
fn make_verdict_with_source(source: &str, verdict_label: &str) -> Verdict {
let mut v = make_verdict(verdict_label, 30);
v.source = source.to_string();
v
}
#[test]
fn state_field_derived_from_wire_source_insufficient_data() {
let r = VerdictResponse::from(&make_verdict_with_source(
"INSUFFICIENT_DATA",
"INSUFFICIENT_DATA",
));
assert_eq!(r.state, "not_yet_assessed");
assert_eq!(r.state_label, "Not yet assessed");
}
#[test]
fn state_field_derived_from_wire_source_clean() {
let r = VerdictResponse::from(&make_verdict_with_source(
"ALLOWED_NO_FINDINGS",
"ALLOWED_NO_FINDINGS",
));
assert_eq!(r.state, "clean");
assert_eq!(r.state_label, "Clean");
}
#[test]
fn state_field_derived_from_wire_source_vulnerable() {
let r = VerdictResponse::from(&make_verdict_with_source("CVE_FINDING", "VECTOR_VERDICT"));
assert_eq!(r.state, "vulnerable");
assert_eq!(r.state_label, "Vulnerable");
}
#[test]
fn state_field_derived_from_wire_source_actively_exploited() {
let r = VerdictResponse::from(&make_verdict_with_source(
"CVE_FINDING_ON_KEV",
"VECTOR_VERDICT",
));
assert_eq!(r.state, "actively_exploited");
assert_eq!(r.state_label, "Actively exploited");
}
#[test]
fn state_field_fails_closed_to_not_yet_assessed_on_unknown_wire_source() {
let r = VerdictResponse::from(&make_verdict_with_source(
"SOME_FUTURE_WIRE_TAG",
"SOME_FUTURE_WIRE_TAG",
));
assert_eq!(r.state, "not_yet_assessed");
assert_ne!(r.state, "clean");
}
#[test]
fn state_field_always_present_in_json_output() {
let r = VerdictResponse::from(&make_verdict("ALLOWED_NO_FINDINGS", 30));
let json = serde_json::to_value(&r).unwrap();
assert!(json.get("state").is_some(), "state key must be present");
assert!(!json["state"].is_null(), "state must not be null");
assert!(
json.get("state_label").is_some(),
"state_label key must be present"
);
assert!(
!json["state_label"].is_null(),
"state_label must not be null"
);
}
#[test]
fn verdict_field_no_longer_leaks_insufficient_data_codename() {
let r = VerdictResponse::from(&make_verdict("INSUFFICIENT_DATA", 30));
assert!(
!r.verdict.contains("INSUFFICIENT_DATA"),
"raw wire codename leaked in verdict field: {}",
r.verdict
);
assert_eq!(r.verdict, "Not yet assessed");
}
}