Skip to main content

cleanlib_client/
derive_status.rs

1//! `derive_status` — canonical algorithm per App dispatch §4 binding contract.
2//!
3//! Each CleanLibrary SDK (sdk-js, sdk-py, sdk-go, cleanlib-client Rust)
4//! implements this algorithm INDEPENDENTLY from the spec; the cross-SDK
5//! contract test (`tests/contract.rs`) verifies byte-identical
6//! `(status, reason_code)` across all 4 implementations on every fixture
7//! in `cleanlib-contract-fixtures` v1.0.0.
8//!
9//! Precedence:
10//!
11//! 1. **Substance** (signals top-to-bottom; `unavailable` substrate falls through):
12//!    - `exploitability.exploitation_likelihood == "CRITICAL"`
13//!      → DENY + `VERDICT_EXPLOITATION_CRITICAL`
14//!    - `exploitability.in_kev` AND `availability.kev == "available"` AND
15//!      `exploitability.exploit_risk_score >= 70`
16//!      → DENY + `VERDICT_KEV_LISTED`
17//!    - `rich_data.has_obfuscation`
18//!      → DENY + `VERDICT_OBFUSCATED`
19//!    - `remediation.fix` OR `remediation.recommended_version` present
20//!      → WARN + `VERDICT_HAS_REMEDIATION`
21//!    - `rich_data.abandonment_score >= 0.7`
22//!      → WARN + `VERDICT_ABANDONED`
23//!    - `rich_data.recommended_version` present (under `rich_data`,
24//!       not `remediation`)
25//!      → ALLOW + `VERDICT_RECOMMENDED_VERSION_NEWER`
26//!    - default
27//!      → ALLOW + `VERDICT_CLEAN`
28//!
29//! 2. **Freshness override**: when the substance-driving signal's
30//!    `availability == "degraded_stale"`, the substance-derived status
31//!    tier is PRESERVED and the `reason_code` overrides to
32//!    `VERDICT_DEGRADED_STALE`.
33//!
34//! 3. **Block tie-break in remediation**: `fix` > `recommended_version`
35//!    > other blocks.
36
37use serde_json::Value;
38
39use crate::envelope::{ReasonCode, Status, VerdictEnvelopeV1};
40
41/// Return value pair — sister of sdk-py `StatusResult` + sdk-go `StatusResult`.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub struct DerivedStatus {
44    pub status: Status,
45    pub reason_code: ReasonCode,
46}
47
48/// Apply the substance-precedence + freshness-override rule to a parsed
49/// `VerdictEnvelopeV1`. Returns `(status, reason_code)` pair.
50///
51/// Cross-SDK contract: identical output across all 4 SDK implementations
52/// for every fixture in `cleanlib-contract-fixtures` v1.0.0.
53pub fn derive_status(envelope: &VerdictEnvelopeV1) -> DerivedStatus {
54    let rich = envelope.rich_data.as_ref();
55    let rem = envelope.remediation.as_ref();
56    let exp = envelope.exploitability.as_ref();
57    // CLEANLIB-104 M3: `availability` is non-Optional (`serde_json::Value`);
58    // wrap in Some(&_) so the untyped `as_str/as_bool` helpers below (which
59    // take `Option<&Value>`) continue to work. `Value::Null` on absent-key
60    // payloads makes helper lookups return None — same fail-open behavior
61    // as the pre-M3 outer-Option `None` case.
62    let avail = Some(&envelope.availability);
63
64    let mut status: Status;
65    let mut reason: ReasonCode;
66    // `freshness_signal` is the substrate availability tag for the driving
67    // signal; the freshness-override step rewrites the reason_code without
68    // disturbing the status tier.
69    let mut freshness_signal: Option<String> = None;
70
71    if as_str(exp, "exploitation_likelihood") == Some("CRITICAL") {
72        status = Status::Deny;
73        reason = ReasonCode::VerdictExploitationCritical;
74        freshness_signal = as_str(avail, "exploitation_fusion").map(str::to_string);
75    } else if as_bool(exp, "in_kev")
76        && as_str(avail, "kev") == Some("available")
77        && as_f64(exp, "exploit_risk_score") >= 70.0
78    {
79        status = Status::Deny;
80        reason = ReasonCode::VerdictKevListed;
81        freshness_signal = as_str(avail, "kev").map(str::to_string);
82    } else if as_bool(rich, "has_obfuscation") {
83        status = Status::Deny;
84        reason = ReasonCode::VerdictObfuscated;
85    } else if get(rem, "fix").is_some() || get(rem, "recommended_version").is_some() {
86        status = Status::Warn;
87        reason = ReasonCode::VerdictHasRemediation;
88        // Block tie-break: `fix` > `recommended_version` > other blocks.
89        let block = get(rem, "fix").or_else(|| get(rem, "recommended_version"));
90        if let Some(b) = block {
91            if let Some(s) = b.get("availability").and_then(Value::as_str) {
92                freshness_signal = Some(s.to_string());
93            }
94        }
95    } else if as_f64(rich, "abandonment_score") >= 0.7 {
96        status = Status::Warn;
97        reason = ReasonCode::VerdictAbandoned;
98    } else if get(rich, "recommended_version").is_some() {
99        status = Status::Allow;
100        reason = ReasonCode::VerdictRecommendedVersionNewer;
101    } else {
102        status = Status::Allow;
103        reason = ReasonCode::VerdictClean;
104    }
105
106    // CLEANLIB-493 fix (b1, monotonic floor): substance may only ESCALATE the
107    // adapter's baseline status, never discard it. `verdict_to_envelope_v1` sets
108    // `envelope.status` from verdict_label × severity; when no substance branch
109    // exceeds that tier (sub-blocks absent until the enrich cascade lands, or a
110    // downgrade branch such as recommended_version -> ALLOW), the baseline holds.
111    // Guarantees tier_rank(derive_status(v_to_e).status) >= tier_rank(v_to_e.status)
112    // -- the ratified dispatch-§4 line-60 equality by construction; survives fix
113    // (a) landing later. Identical semantics to sdk-py/js 0.4.10 + sdk-go v0.4.9.
114    let rank = |s: &Status| -> u8 {
115        match s {
116            Status::Allow => 0,
117            Status::Warn => 1,
118            Status::Deny => 2,
119        }
120    };
121    let baseline_status = match envelope.status.as_str() {
122        "DENY" => Status::Deny,
123        "WARN" => Status::Warn,
124        _ => Status::Allow,
125    };
126    if rank(&baseline_status) > rank(&status) {
127        status = baseline_status;
128        // Adopt the adapter's baseline reason when the baseline wins.
129        if let Ok(br) = serde_json::from_value::<ReasonCode>(serde_json::Value::String(
130            envelope.reason_code.clone(),
131        )) {
132            reason = br;
133        }
134    }
135
136    // Freshness override: keep status tier, rewrite reason_code.
137    if freshness_signal.as_deref() == Some("degraded_stale") {
138        reason = ReasonCode::VerdictDegradedStale;
139    }
140
141    // Suppress unused-mut warning for `status` on paths that never reassign
142    // — `_ = &mut status;` keeps the let-mut binding load-bearing for
143    // future-rule additions without triggering a clippy alarm here.
144    let _ = &mut status;
145    let _ = &mut reason;
146
147    DerivedStatus { status, reason_code: reason }
148}
149
150// ─── small json-projection helpers (tolerate Option<Value> + type-skew) ─────
151
152fn get<'a>(obj: Option<&'a Value>, key: &str) -> Option<&'a Value> {
153    let v = obj?.get(key)?;
154    if v.is_null() {
155        None
156    } else {
157        Some(v)
158    }
159}
160
161fn as_str<'a>(obj: Option<&'a Value>, key: &str) -> Option<&'a str> {
162    get(obj, key).and_then(Value::as_str)
163}
164
165fn as_bool(obj: Option<&Value>, key: &str) -> bool {
166    get(obj, key).and_then(Value::as_bool).unwrap_or(false)
167}
168
169fn as_f64(obj: Option<&Value>, key: &str) -> f64 {
170    get(obj, key).and_then(Value::as_f64).unwrap_or(0.0)
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    fn parse(json: &str) -> VerdictEnvelopeV1 {
178        serde_json::from_str(json).unwrap()
179    }
180
181    #[test]
182    fn clean_envelope_yields_allow_clean() {
183        let env = parse(
184            r#"{
185                "status":"ALLOW","reason_code":"VERDICT_CLEAN",
186                "human_message":"ok","as_of":"2026-05-28"
187            }"#,
188        );
189        let d = derive_status(&env);
190        assert_eq!(d.status, Status::Allow);
191        assert_eq!(d.reason_code, ReasonCode::VerdictClean);
192    }
193
194    #[test]
195    fn exploitation_critical_wins_over_kev_and_remediation() {
196        // EXPLOITATION_CRITICAL is the topmost precedence rule.
197        let env = parse(
198            r#"{
199                "status":"DENY","reason_code":"VERDICT_EXPLOITATION_CRITICAL",
200                "human_message":"e","as_of":"2026-05-28",
201                "exploitability":{
202                  "exploitation_likelihood":"CRITICAL",
203                  "in_kev":true,
204                  "exploit_risk_score":99
205                },
206                "availability":{"kev":"available","exploitation_fusion":"available"},
207                "remediation":{"fix":{"availability":"available"}}
208            }"#,
209        );
210        let d = derive_status(&env);
211        assert_eq!(d.status, Status::Deny);
212        assert_eq!(d.reason_code, ReasonCode::VerdictExploitationCritical);
213    }
214
215    #[test]
216    fn freshness_override_preserves_tier_only_rewrites_reason() {
217        // remediation.fix.availability="degraded_stale" → WARN preserved,
218        // reason rewritten to VERDICT_DEGRADED_STALE.
219        let env = parse(
220            r#"{
221                "status":"WARN","reason_code":"VERDICT_DEGRADED_STALE",
222                "human_message":"stale","as_of":"2026-05-28",
223                "remediation":{"fix":{"availability":"degraded_stale"}}
224            }"#,
225        );
226        let d = derive_status(&env);
227        assert_eq!(d.status, Status::Warn);
228        assert_eq!(d.reason_code, ReasonCode::VerdictDegradedStale);
229    }
230
231    #[test]
232    fn kev_requires_all_three_conditions() {
233        // in_kev=true alone is not enough; need kev=available AND score>=70.
234        let env = parse(
235            r#"{
236                "status":"ALLOW","reason_code":"VERDICT_CLEAN",
237                "human_message":"x","as_of":"2026-05-28",
238                "exploitability":{"in_kev":true,"exploit_risk_score":50},
239                "availability":{"kev":"available"}
240            }"#,
241        );
242        let d = derive_status(&env);
243        assert_eq!(d.status, Status::Allow);
244        assert_eq!(d.reason_code, ReasonCode::VerdictClean);
245    }
246}