cleanlib-client 0.1.4

HTTP client SDK for the CleanLibrary verdict API — VerdictEnvelopeV1 types, derive_status logic, transport, config, and risk-acceptance YAML emitter shared between cleanlib-cli and other CleanLibrary consumers.
Documentation
//! `verdict_to_envelope_v1` — structural adapter from the App's wire-shape
//! `Verdict` to the canonical customer-facing [`VerdictEnvelopeV1`].
//!
//! Ratified at `decisions/2026-06-05-cycle-14-cleanlib-48-envelope-shape-ratification.md`.
//! Sister of:
//! - `cleanlib_sdk.verdict_to_envelope_v1` (sdk-py, planned)
//! - `verdictToEnvelopeV1` (sdk-js, planned)
//! - `VerdictToEnvelopeV1` (sdk-go, planned)
//!
//! ## Algorithm
//!
//! The adapter is a PURE STRUCTURAL CONVERSION — it does not call the
//! enrich-customer cascade. The output envelope's `remediation` /
//! `exploitability` / `availability` slots are always `None`; consumers
//! populate them via [`crate::HttpEnrichClient`]-equivalent calls before
//! running [`crate::derive_status`] for canonical decision derivation.
//!
//! Field mapping:
//!
//! | Envelope field | Source on `Verdict` |
//! |---|---|
//! | `status` | derived from `verdict.verdict` (label) + `verdict.severity` per the table below |
//! | `reason_code` | derived from `verdict.verdict` + `verdict.severity` per the table below |
//! | `human_message` | `verdict.reasoning` (passthrough) |
//! | `as_of` | `verdict.computed_at` if present, else `verdict.data_freshness_at`, else `""` |
//! | `rich_data` | JSON object built from the legacy Verdict's rich-data fields + suggested_actions/similar_to/evidence_gaps/previous_verdict |
//! | `remediation` | `None` (filled by enrich cascade) |
//! | `exploitability` | `None` (filled by enrich cascade) |
//! | `availability` | `None` (filled by enrich cascade) |
//!
//! Status/reason_code mapping table (App's view; consumer may re-derive
//! after enriching):
//!
//! | verdict label | severity | status | reason_code |
//! |---|---|---|---|
//! | `VECTOR_VERDICT` | `CRITICAL` | DENY | VERDICT_EXPLOITATION_CRITICAL |
//! | `VECTOR_VERDICT` | `HIGH` | DENY | VERDICT_LOW_TRUST |
//! | `VECTOR_VERDICT` | `MEDIUM` | WARN | VERDICT_ABANDONED |
//! | `VECTOR_VERDICT` | `LOW` / `NONE` | ALLOW | VERDICT_CLEAN |
//! | `DM_THRESHOLD_BLOCK` | `HIGH` / `CRITICAL` | DENY | VERDICT_DENY_LIST |
//! | `DM_THRESHOLD_BLOCK` | `MEDIUM` | WARN | VERDICT_DENY_LIST |
//! | `DM_THRESHOLD_BLOCK` | `LOW` / `NONE` | ALLOW | VERDICT_CLEAN |
//! | `ALLOWED_NO_FINDINGS` | any | ALLOW | VERDICT_CLEAN |
//! | `INSUFFICIENT_DATA` | any | ALLOW | VERDICT_CLEAN |
//! | (unknown label) | any | ALLOW | VERDICT_CLEAN |
//!
//! Freshness handling: if `verdict.stale_since_at` is `Some(_)`, the
//! reason_code is overridden to `VERDICT_DEGRADED_STALE` (status tier
//! preserved). This is the App's view of staleness — distinct from the
//! enrich-substrate `availability == "degraded_stale"` path that
//! [`crate::derive_status`] applies after the enrich cascade lands.
//!
//! Customers who want the canonical (status, reason_code) derived from
//! BOTH the App's verdict AND the enrich cascade should:
//!
//! ```rust,ignore
//! let verdict = client.fetch_verdict(eco, pkg, ver).await?;
//! let mut envelope = verdict_to_envelope_v1(&verdict);
//! envelope.remediation = enrich_client.get_remediation(eco, pkg).await.ok();
//! envelope.exploitability = enrich_client.get_exploitability(eco, pkg, ver).await.ok();
//! let derived = derive_status(&envelope);
//! ```

use serde_json::{json, Map, Value};

use crate::envelope::{ReasonCode, Status, VerdictEnvelopeV1};
use crate::types::Verdict;

/// Pure-function structural conversion: `Verdict` (App wire shape) →
/// [`VerdictEnvelopeV1`] (canonical customer-facing shape). See module
/// docs for the field-mapping + status/reason_code derivation table.
///
/// Enrich-cascade fields (`remediation`, `exploitability`, `availability`)
/// are always `None`; consumers populate them before calling
/// [`crate::derive_status`] for the canonical decision.
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);

    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: None,
    }
}

fn derive_initial_status_reason(verdict: &Verdict) -> (Status, ReasonCode) {
    // Normalize the strings to upper-case so case-skew on the wire doesn't
    // misclassify (cleanlib-core::Severity serializes as upper-case enum
    // names; the SDK Verdict type carries Option<String> so we tolerate
    // legacy lower-case fixtures too).
    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),
        // ALLOWED_NO_FINDINGS / INSUFFICIENT_DATA / unknown → ALLOW + CLEAN
        _ => (Status::Allow, ReasonCode::VerdictClean),
    };

    // App-side staleness override: if the Verdict says it's stale, surface
    // VERDICT_DEGRADED_STALE while preserving the status tier. Sister of
    // the enrich-substrate freshness override applied by derive_status.
    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,
            }),
        );
    }
    // Surface the App-canonical signals customers ask about most often
    // (confidence, composite_score, source, severity) so consumers don't
    // have to keep the original Verdict alongside the envelope.
    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_none());
    }

    #[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);
        // Status tier preserved (ALLOW for AllowedNoFindings); reason_code
        // overridden to VERDICT_DEGRADED_STALE.
        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 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);
        // Forward-compat: unknown labels default to ALLOW+CLEAN so older
        // SDKs don't break when the App adds a new label.
        assert_eq!(env.status, "ALLOW");
        assert_eq!(env.reason_code, "VERDICT_CLEAN");
    }

    #[test]
    fn round_trip_produces_envelope_that_parses_back_unchanged() {
        // Critical: the produced envelope must serialize then parse back
        // to the same shape — proves we're not producing malformed JSON.
        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);
    }
}