Skip to main content

cleanlib_client/
types.rs

1//! Verdict + ancillary response types per Client spec rev1 §2.4 +
2//! App Rev 4 §4.1 Vector verdict shape.
3//!
4//! All fields default-tolerant via `#[serde(default)]` so the SDK can
5//! consume partial responses during cycle-3 → cycle-N spec evolution
6//! without forcing a recompile-and-redeploy on every App-side schema
7//! widening.
8
9use serde::{Deserialize, Serialize};
10
11/// `Verdict` mirrors App Rev 4 §4.1 `Verdict` struct surfaced via
12/// `GET /v1/customer/verdicts/{ecosystem}/{package}/{version}`.
13///
14/// Cycle-9 R1 fix-forward Lane-2 M1: adds `severity` + `decision` to align
15/// with the App-canonical envelope shape (sister of `cleanlib-core::Verdict`
16/// + js/py/go SDK envelope carrying). All new fields are `Option<String>`
17/// to preserve serde-default tolerance — pre-R1 verdict payloads (without
18/// these fields) deserialize cleanly with `None`. Sister-shape with the
19/// `VerdictEnvelopeV1` schema-locked at `cleanlib-contract-fixtures@v1.0.0`.
20#[derive(Debug, Clone, Deserialize, Serialize)]
21#[serde(default)]
22pub struct Verdict {
23    pub verdict_id: String,
24    /// `ALLOWED_NO_FINDINGS` | `VECTOR_VERDICT` | `DM_THRESHOLD_BLOCK` |
25    /// `INSUFFICIENT_DATA` per locked Verdict-label enum.
26    pub verdict: String,
27    pub source: String,
28    pub confidence: f64,
29    pub composite_score: u8,
30    pub reasoning: String,
31    pub similar_to: Vec<String>,
32    pub evidence_gaps: Vec<String>,
33    pub suggested_actions: Vec<String>,
34    pub data_freshness_at: Option<String>,
35    pub data_oldest_signal_at: Option<String>,
36    pub stale_since_at: Option<String>,
37    pub staleness_reason: Option<String>,
38    pub computed_at: Option<String>,
39    /// App-canonical severity tier (`NONE` | `LOW` | `MEDIUM` | `HIGH` |
40    /// `CRITICAL` per `cleanlib-core::Severity`). Cycle-9 Lane-2 M1 close.
41    /// `Option<String>` for serde-default tolerance against pre-M1 payloads.
42    pub severity: Option<String>,
43    /// Coarse gating decision (`ALLOW` | `WARN` | `DENY` |
44    /// `RISK_ACCEPTANCE_REQUIRED`). Sister of js/py/go SDK carrying.
45    /// Cycle-9 Lane-2 M1 close. Optional for serde-default tolerance.
46    pub decision: Option<String>,
47    /// Prior-verdict comparison shape; envelope emits `null` when no prior
48    /// verdict exists. v0.1.3 parity-ripple with `sdk-go::PreviousVerdict`
49    /// (cycle-13 M1' ship). NOTE: no `skip_serializing_if` — Verdict is
50    /// bincode-serialized by `cleanlib-cli` PersistentCache, which is
51    /// positional and breaks if fields are conditionally omitted. JSON
52    /// consumers see `previous_verdict: null` which matches the App's
53    /// canonical envelope shape.
54    pub previous_verdict: Option<PreviousVerdict>,
55}
56
57/// Prior-verdict comparison. Surfaces when the CleanLibrary App has a
58/// stored prior verdict for the same `(ecosystem, package, version)` that
59/// differs from the current one — useful for AI agents and dashboards
60/// that want to flag verdict-state changes since the last fetch.
61/// Sister-shape with `cleanlib_sdk_go::PreviousVerdict` and
62/// `cleanlib-core::PreviousVerdict` in the App.
63#[derive(Debug, Clone, Default, Deserialize, Serialize)]
64#[serde(default)]
65pub struct PreviousVerdict {
66    pub verdict_id: String,
67    pub verdict: String,
68    pub computed_at: String,
69    pub diff: String,
70}
71
72impl Default for Verdict {
73    fn default() -> Self {
74        Self {
75            verdict_id: String::new(),
76            verdict: String::new(),
77            source: String::new(),
78            confidence: 0.0,
79            composite_score: 0,
80            reasoning: String::new(),
81            similar_to: Vec::new(),
82            evidence_gaps: Vec::new(),
83            suggested_actions: Vec::new(),
84            data_freshness_at: None,
85            data_oldest_signal_at: None,
86            stale_since_at: None,
87            staleness_reason: None,
88            computed_at: None,
89            severity: None,
90            decision: None,
91            previous_verdict: None,
92        }
93    }
94}
95
96/// One package identity for policy-preview / scan requests.
97#[derive(Debug, Clone, Deserialize, Serialize)]
98pub struct PackageRef {
99    pub ecosystem: String,
100    pub name: String,
101    pub version: String,
102}
103
104/// Body of `POST /v1/customer/policy/preview` — packages + optional
105/// hypothetical policy override (JSON-shaped; YAML-source customers
106/// convert client-side).
107#[derive(Debug, Clone, Serialize)]
108pub struct PolicyPreviewRequest {
109    pub packages: Vec<PackageRef>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub policy: Option<serde_json::Value>,
112}
113
114/// Per-package decision returned from `/v1/customer/policy/preview` or
115/// embedded in audit entries.
116#[derive(Debug, Clone, Deserialize, Serialize, Default)]
117#[serde(default)]
118pub struct PolicyDecision {
119    pub ecosystem: String,
120    pub package: String,
121    pub version: String,
122    /// `ALLOW` | `DENY` | `WARN` | `INSUFFICIENT_DATA` | `RISK_ACCEPTANCE_REQUIRED`
123    pub decision: String,
124    pub reason: String,
125    pub verdict_id: Option<String>,
126    pub policy_rule_id: Option<String>,
127}
128
129/// Response from `POST /v1/customer/policy/preview`.
130#[derive(Debug, Clone, Deserialize, Serialize, Default)]
131#[serde(default)]
132pub struct PolicyPreviewResponse {
133    pub decisions: Vec<PolicyDecision>,
134}
135
136/// One audit log entry returned from `GET /v1/customer/audit`.
137#[derive(Debug, Clone, Deserialize, Serialize, Default)]
138#[serde(default)]
139pub struct AuditEntry {
140    pub request_id: String,
141    pub at: String,
142    pub ecosystem: String,
143    pub package: String,
144    pub version: String,
145    pub decision: String,
146    pub reason: String,
147    pub verdict_id: Option<String>,
148}
149
150/// Response from `GET /v1/customer/audit`.
151#[derive(Debug, Clone, Deserialize, Serialize, Default)]
152#[serde(default)]
153pub struct AuditResponse {
154    pub entries: Vec<AuditEntry>,
155    pub next_cursor: Option<String>,
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn parses_minimal_verdict() {
164        let json = r#"{
165            "verdict_id": "01JBYK000",
166            "verdict": "ALLOWED_NO_FINDINGS",
167            "source": "ALLOWED_NO_FINDINGS"
168        }"#;
169        let v: Verdict = serde_json::from_str(json).unwrap();
170        assert_eq!(v.verdict_id, "01JBYK000");
171        assert_eq!(v.verdict, "ALLOWED_NO_FINDINGS");
172        assert_eq!(v.confidence, 0.0);
173        assert!(v.similar_to.is_empty());
174    }
175
176    #[test]
177    fn parses_full_verdict() {
178        let json = r#"{
179            "verdict_id": "01JBYK001",
180            "verdict": "VECTOR_VERDICT",
181            "source": "VECTOR_VERDICT",
182            "confidence": 0.98,
183            "composite_score": 92,
184            "reasoning": "Confirmed malware",
185            "similar_to": ["01JBYK999"],
186            "evidence_gaps": [],
187            "suggested_actions": ["DENY across customers"],
188            "data_freshness_at": "2026-05-21T10:00:00Z",
189            "computed_at": "2026-05-21T10:01:00Z"
190        }"#;
191        let v: Verdict = serde_json::from_str(json).unwrap();
192        assert_eq!(v.composite_score, 92);
193        assert_eq!(v.confidence, 0.98);
194        assert_eq!(v.similar_to.len(), 1);
195        assert_eq!(v.suggested_actions[0], "DENY across customers");
196    }
197
198    #[test]
199    fn parses_policy_preview_response() {
200        let json = r#"{
201            "decisions": [
202                {"ecosystem":"npm","package":"left-pad","version":"1.3.0","decision":"ALLOW","reason":"ok"},
203                {"ecosystem":"npm","package":"event-stream","version":"3.3.6","decision":"DENY","reason":"malware","verdict_id":"01JBYK999"}
204            ]
205        }"#;
206        let resp: PolicyPreviewResponse = serde_json::from_str(json).unwrap();
207        assert_eq!(resp.decisions.len(), 2);
208        assert_eq!(resp.decisions[0].decision, "ALLOW");
209        assert_eq!(resp.decisions[1].decision, "DENY");
210        assert_eq!(resp.decisions[1].verdict_id.as_deref(), Some("01JBYK999"));
211    }
212
213    #[test]
214    fn parses_audit_response_with_cursor() {
215        let json = r#"{
216            "entries": [
217                {"request_id":"req-1","at":"2026-05-22T10:00:00Z","ecosystem":"npm","package":"lodash","version":"4.17.21","decision":"ALLOW","reason":"ok"}
218            ],
219            "next_cursor": "abc123"
220        }"#;
221        let resp: AuditResponse = serde_json::from_str(json).unwrap();
222        assert_eq!(resp.entries.len(), 1);
223        assert_eq!(resp.next_cursor.as_deref(), Some("abc123"));
224    }
225
226    #[test]
227    fn empty_audit_response_is_valid() {
228        let json = r#"{"entries": []}"#;
229        let resp: AuditResponse = serde_json::from_str(json).unwrap();
230        assert!(resp.entries.is_empty());
231        assert!(resp.next_cursor.is_none());
232    }
233
234    #[test]
235    fn policy_preview_request_omits_none_policy() {
236        let req = PolicyPreviewRequest {
237            packages: vec![PackageRef {
238                ecosystem: "npm".to_string(),
239                name: "lodash".to_string(),
240                version: "4.17.21".to_string(),
241            }],
242            policy: None,
243        };
244        let json = serde_json::to_string(&req).unwrap();
245        // None policy should not appear in serialized output
246        assert!(!json.contains("policy"));
247        assert!(json.contains("lodash"));
248    }
249
250    #[test]
251    fn policy_preview_request_emits_policy_when_some() {
252        let req = PolicyPreviewRequest {
253            packages: vec![],
254            policy: Some(serde_json::json!({"rules": []})),
255        };
256        let json = serde_json::to_string(&req).unwrap();
257        assert!(json.contains("\"policy\""));
258        assert!(json.contains("\"rules\""));
259    }
260
261    #[test]
262    fn round_trips_via_json() {
263        let v = Verdict {
264            verdict_id: "01JBYK002".to_string(),
265            verdict: "INSUFFICIENT_DATA".to_string(),
266            source: "INSUFFICIENT_DATA".to_string(),
267            stale_since_at: Some("2026-04-21T00:00:00Z".to_string()),
268            staleness_reason: Some("upstream silent >30d".to_string()),
269            ..Default::default()
270        };
271        let s = serde_json::to_string(&v).unwrap();
272        let parsed: Verdict = serde_json::from_str(&s).unwrap();
273        assert_eq!(parsed.verdict_id, "01JBYK002");
274        assert_eq!(parsed.stale_since_at.as_deref(), Some("2026-04-21T00:00:00Z"));
275    }
276
277    // ─── Lane-2 M1 — severity + decision carrying ──────────────────────
278
279    #[test]
280    fn verdict_round_trips_severity_and_decision() {
281        let v = Verdict {
282            verdict_id: "01JM1S001".to_string(),
283            verdict: "VECTOR_VERDICT".to_string(),
284            source: "VECTOR_VERDICT".to_string(),
285            severity: Some("HIGH".to_string()),
286            decision: Some("DENY".to_string()),
287            ..Default::default()
288        };
289        let s = serde_json::to_string(&v).unwrap();
290        let parsed: Verdict = serde_json::from_str(&s).unwrap();
291        assert_eq!(parsed.severity.as_deref(), Some("HIGH"));
292        assert_eq!(parsed.decision.as_deref(), Some("DENY"));
293    }
294
295    #[test]
296    fn verdict_tolerates_missing_severity_and_decision() {
297        // Pre-M1 payload shape — no severity/decision fields. Must still parse
298        // via serde-default tolerance per the struct's `#[serde(default)]`.
299        let pre_m1_json = r#"{
300            "verdict_id": "01JM1S002",
301            "verdict": "ALLOWED_NO_FINDINGS",
302            "source": "ALLOWED_NO_FINDINGS",
303            "confidence": 0.95,
304            "composite_score": 8,
305            "reasoning": "",
306            "similar_to": [],
307            "evidence_gaps": [],
308            "suggested_actions": []
309        }"#;
310        let v: Verdict = serde_json::from_str(pre_m1_json).expect("pre-M1 shape must still parse");
311        assert!(v.severity.is_none());
312        assert!(v.decision.is_none());
313    }
314
315    #[test]
316    fn verdict_decision_canonical_values_match_js_py_go() {
317        // Lane-2 M1 acceptance: decision values match js/py/go SDK envelope.
318        // Schema-locked set: ALLOW | WARN | DENY | RISK_ACCEPTANCE_REQUIRED.
319        for d in ["ALLOW", "WARN", "DENY", "RISK_ACCEPTANCE_REQUIRED"] {
320            let v = Verdict {
321                decision: Some(d.to_string()),
322                ..Default::default()
323            };
324            let s = serde_json::to_string(&v).unwrap();
325            assert!(s.contains(&format!("\"decision\":\"{}\"", d)));
326        }
327    }
328
329    #[test]
330    fn verdict_severity_canonical_values_match_cleanlib_core() {
331        // Sister of `cleanlib-core::Severity` enum: NONE | LOW | MEDIUM | HIGH | CRITICAL.
332        for sev in ["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"] {
333            let v = Verdict {
334                severity: Some(sev.to_string()),
335                ..Default::default()
336            };
337            let s = serde_json::to_string(&v).unwrap();
338            assert!(s.contains(&format!("\"severity\":\"{}\"", sev)));
339        }
340    }
341}